x
This commit is contained in:
1 parent
94cbb52348
commit
167d07f0c2
7 files changed
+1284
-21
No files matched your search
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
createMenuTranslations,
|
||||
executeVersionedMenuWrite,
|
||||
fingerprintMenu,
|
||||
isValidMenuUrl,
|
||||
lockMenuForTransaction,
|
||||
menusRoute,
|
||||
resolveMenuItemLabel,
|
||||
} from "./menus";
|
||||
|
||||
const menu = {
|
||||
id: 10,
|
||||
name: "footer-explore",
|
||||
items: [
|
||||
{
|
||||
id: 2,
|
||||
label: "Contact",
|
||||
url: "/contact",
|
||||
order: 1,
|
||||
columnImage: null,
|
||||
hoverImage: null,
|
||||
translations: [
|
||||
{ langCode: "zh", label: "联系我们" },
|
||||
{ langCode: "th", label: "ติดต่อเรา" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
label: "Shop",
|
||||
url: "/shop",
|
||||
order: 0,
|
||||
columnImage: null,
|
||||
hoverImage: null,
|
||||
translations: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("footer menu helpers", () => {
|
||||
test("creates a deterministic SHA-256 fingerprint independent of query ordering", () => {
|
||||
const reordered = {
|
||||
...menu,
|
||||
items: [...menu.items].reverse().map((item) => ({
|
||||
...item,
|
||||
translations: [...item.translations].reverse(),
|
||||
})),
|
||||
};
|
||||
|
||||
expect(fingerprintMenu(menu)).toBe(fingerprintMenu(reordered));
|
||||
expect(fingerprintMenu(menu)).toMatch(/^sha256:[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
test("changes the fingerprint when editable content changes", () => {
|
||||
const changed = structuredClone(menu);
|
||||
changed.items[0]!.url = "/support";
|
||||
|
||||
expect(fingerprintMenu(changed)).not.toBe(fingerprintMenu(menu));
|
||||
});
|
||||
|
||||
test("resolves a requested translation without returning all languages", () => {
|
||||
expect(resolveMenuItemLabel(menu.items[0]!, "zh", false)).toEqual({
|
||||
label: "联系我们",
|
||||
usedFallback: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("reports fallback use and can suppress the base label", () => {
|
||||
expect(resolveMenuItemLabel(menu.items[1]!, "zh", true)).toEqual({
|
||||
label: "Shop",
|
||||
usedFallback: true,
|
||||
});
|
||||
expect(resolveMenuItemLabel(menu.items[1]!, "zh", false)).toEqual({
|
||||
label: null,
|
||||
usedFallback: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts safe menu URL schemes and rejects unsafe values", () => {
|
||||
expect(isValidMenuUrl("/collections/fine-fragrance")).toBe(true);
|
||||
expect(isValidMenuUrl("https://example.com/contact")).toBe(true);
|
||||
expect(isValidMenuUrl("http://example.com/contact")).toBe(true);
|
||||
expect(isValidMenuUrl("mailto:test@example.com")).toBe(true);
|
||||
expect(isValidMenuUrl("mailto:test@example.com?subject=Support")).toBe(true);
|
||||
expect(isValidMenuUrl("tel:+1-555-123-4567")).toBe(true);
|
||||
expect(isValidMenuUrl("tel:+66(0)2123-4567")).toBe(true);
|
||||
expect(isValidMenuUrl("mailto:not-an-address")).toBe(false);
|
||||
expect(isValidMenuUrl("tel:call-us")).toBe(false);
|
||||
expect(isValidMenuUrl("//example.com")).toBe(false);
|
||||
expect(isValidMenuUrl("javascript:alert(1)")).toBe(false);
|
||||
expect(isValidMenuUrl("/bad path")).toBe(false);
|
||||
expect(isValidMenuUrl("mailto:test@example.com%0d%0aBcc:attacker@example.com")).toBe(false);
|
||||
});
|
||||
|
||||
test("maps supported translation keys for atomic item creation", () => {
|
||||
expect(createMenuTranslations({
|
||||
th: "สินค้า",
|
||||
zh: "产品",
|
||||
})).toEqual({
|
||||
translations: [
|
||||
{ langCode: "th", label: "สินค้า" },
|
||||
{ langCode: "zh", label: "产品" },
|
||||
],
|
||||
});
|
||||
expect(createMenuTranslations({ de: "Produkte" })).toEqual({
|
||||
error: "Unsupported locale: de",
|
||||
translations: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("does not invoke the write callback for a stale version", async () => {
|
||||
let writes = 0;
|
||||
const result = await executeVersionedMenuWrite(
|
||||
menu,
|
||||
"sha256:stale",
|
||||
async () => {
|
||||
writes += 1;
|
||||
return "written";
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.written).toBe(false);
|
||||
expect(writes).toBe(0);
|
||||
expect(result.currentVersion).toBe(fingerprintMenu(menu));
|
||||
});
|
||||
|
||||
test("takes a transaction-scoped PostgreSQL advisory lock using the menu id", async () => {
|
||||
let query = "";
|
||||
let values: unknown[] = [];
|
||||
await lockMenuForTransaction({
|
||||
$queryRaw: async (strings: TemplateStringsArray, ...parameters: unknown[]) => {
|
||||
query = strings.join("?");
|
||||
values = parameters;
|
||||
return [];
|
||||
},
|
||||
}, menu.id);
|
||||
|
||||
expect(query).toBe("SELECT pg_advisory_xact_lock(?)");
|
||||
expect(values).toEqual([menu.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("footer menu route validation", () => {
|
||||
test("rejects unsupported menu names before querying the database", async () => {
|
||||
const response = await menusRoute.handle(
|
||||
new Request("http://localhost/menus/footer/header"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({
|
||||
success: false,
|
||||
code: "INVALID_FOOTER_MENU",
|
||||
partialWrite: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects unsupported locales before querying the database", async () => {
|
||||
const response = await menusRoute.handle(
|
||||
new Request("http://localhost/menus/footer/footer-explore?lang=de"),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({
|
||||
success: false,
|
||||
code: "INVALID_LOCALE",
|
||||
partialWrite: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,233 @@ import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma.js";
|
||||
import { adminOnly } from "../middleware/auth.js";
|
||||
|
||||
export const FOOTER_MENU_NAMES = ["footer-explore", "footer-support"] as const;
|
||||
export const MENU_LANGUAGES = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"] as const;
|
||||
|
||||
type FooterMenuName = typeof FOOTER_MENU_NAMES[number];
|
||||
type MenuLanguage = typeof MENU_LANGUAGES[number];
|
||||
type MenuTranslation = { langCode: string; label: string };
|
||||
type VersionedMenuItem = {
|
||||
id: number;
|
||||
label: string;
|
||||
url: string;
|
||||
order: number;
|
||||
columnImage: string | null;
|
||||
hoverImage: string | null;
|
||||
translations: MenuTranslation[];
|
||||
};
|
||||
type VersionedMenu = {
|
||||
id: number;
|
||||
name: string;
|
||||
items: VersionedMenuItem[];
|
||||
};
|
||||
|
||||
const footerMenuInclude = {
|
||||
items: {
|
||||
orderBy: [{ order: "asc" as const }, { id: "asc" as const }],
|
||||
include: { translations: true },
|
||||
},
|
||||
};
|
||||
|
||||
const isFooterMenuName = (name: string): name is FooterMenuName =>
|
||||
FOOTER_MENU_NAMES.includes(name as FooterMenuName);
|
||||
|
||||
const isMenuLanguage = (lang: string): lang is MenuLanguage =>
|
||||
MENU_LANGUAGES.includes(lang as MenuLanguage);
|
||||
|
||||
const normalizeMenuForVersion = (menu: VersionedMenu) => ({
|
||||
name: menu.name,
|
||||
items: [...menu.items]
|
||||
.sort((a, b) => a.order - b.order || a.id - b.id)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
url: item.url,
|
||||
order: item.order,
|
||||
columnImage: item.columnImage,
|
||||
hoverImage: item.hoverImage,
|
||||
translations: [...item.translations]
|
||||
.sort((a, b) => a.langCode.localeCompare(b.langCode))
|
||||
.map(({ langCode, label }) => ({ langCode, label })),
|
||||
})),
|
||||
});
|
||||
|
||||
export function fingerprintMenu(menu: VersionedMenu): string {
|
||||
const serialized = JSON.stringify(normalizeMenuForVersion(menu));
|
||||
return `sha256:${new Bun.CryptoHasher("sha256").update(serialized).digest("hex")}`;
|
||||
}
|
||||
|
||||
export function resolveMenuItemLabel(
|
||||
item: Pick<VersionedMenuItem, "label" | "translations">,
|
||||
lang: MenuLanguage,
|
||||
fallback = true,
|
||||
) {
|
||||
const translation = item.translations.find((entry) => entry.langCode === lang);
|
||||
if (translation) return { label: translation.label, usedFallback: false };
|
||||
return { label: fallback ? item.label : null, usedFallback: true };
|
||||
}
|
||||
|
||||
export function isValidMenuUrl(url: string): boolean {
|
||||
if (!url || /\s/.test(url) || /%0a|%0d/i.test(url)) return false;
|
||||
if (url.startsWith("/") && !url.startsWith("//")) return true;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol === "https:" || parsed.protocol === "http:") return true;
|
||||
if (parsed.protocol === "mailto:") {
|
||||
return /^[^@/?#]+@[^@/?#]+$/.test(parsed.pathname);
|
||||
}
|
||||
if (parsed.protocol === "tel:") {
|
||||
return /^\+?[0-9().-]+$/.test(parsed.pathname);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createMenuTranslations(translations: Record<string, string> = {}) {
|
||||
const entries = Object.entries(translations);
|
||||
const invalidLocale = entries.find(([lang]) => !isMenuLanguage(lang));
|
||||
if (invalidLocale) {
|
||||
return { error: `Unsupported locale: ${invalidLocale[0]}`, translations: [] };
|
||||
}
|
||||
return {
|
||||
translations: entries.map(([langCode, label]) => ({ langCode, label })),
|
||||
};
|
||||
}
|
||||
|
||||
const errorResponse = (set: { status?: number | string }, status: number, code: string, error: string) => {
|
||||
set.status = status;
|
||||
return { success: false, code, error, partialWrite: false };
|
||||
};
|
||||
|
||||
const getFooterMenu = async (
|
||||
name: FooterMenuName,
|
||||
db: any = prisma,
|
||||
): Promise<VersionedMenu | null> =>
|
||||
db.menu.findUnique({
|
||||
where: { name },
|
||||
include: footerMenuInclude,
|
||||
});
|
||||
|
||||
export const lockMenuForTransaction = async (tx: any, menuId: number) => {
|
||||
await tx.$queryRaw`SELECT pg_advisory_xact_lock(${menuId})`;
|
||||
};
|
||||
|
||||
export async function executeVersionedMenuWrite<T>(
|
||||
menu: VersionedMenu,
|
||||
expectedVersion: unknown,
|
||||
write: () => Promise<T>,
|
||||
) {
|
||||
const currentVersion = fingerprintMenu(menu);
|
||||
if (typeof expectedVersion === "string" && expectedVersion !== currentVersion) {
|
||||
return {
|
||||
written: false as const,
|
||||
currentVersion,
|
||||
};
|
||||
}
|
||||
return {
|
||||
written: true as const,
|
||||
currentVersion,
|
||||
value: await write(),
|
||||
};
|
||||
}
|
||||
|
||||
const checkVersion = (
|
||||
menu: VersionedMenu,
|
||||
expectedVersion: unknown,
|
||||
set: { status?: number | string },
|
||||
) => {
|
||||
const currentVersion = fingerprintMenu(menu);
|
||||
if (typeof expectedVersion === "string" && expectedVersion !== currentVersion) {
|
||||
return {
|
||||
conflict: errorResponse(
|
||||
set,
|
||||
409,
|
||||
"MENU_VERSION_CONFLICT",
|
||||
"Menu changed after it was read. Fetch the menu again before writing.",
|
||||
),
|
||||
currentVersion,
|
||||
};
|
||||
}
|
||||
return { currentVersion };
|
||||
};
|
||||
|
||||
const runMenuTransaction = async <T>(
|
||||
name: FooterMenuName,
|
||||
expectedVersion: unknown,
|
||||
write: (tx: any, menu: VersionedMenu) => Promise<T>,
|
||||
) =>
|
||||
prisma.$transaction(async (tx: any) => {
|
||||
const identity = await tx.menu.findUnique({
|
||||
where: { name },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!identity) return { missing: true as const };
|
||||
await lockMenuForTransaction(tx, identity.id);
|
||||
|
||||
const menu = await getFooterMenu(name, tx);
|
||||
if (!menu) return { missing: true as const };
|
||||
return executeVersionedMenuWrite(menu, expectedVersion, async () => {
|
||||
const value = await write(tx, menu);
|
||||
const updated = await getFooterMenu(name, tx);
|
||||
if (!updated) throw new Error(`Menu ${name} disappeared during transaction.`);
|
||||
return { value, version: fingerprintMenu(updated) };
|
||||
});
|
||||
});
|
||||
|
||||
const unwrapTransaction = (
|
||||
result: Awaited<ReturnType<typeof runMenuTransaction>>,
|
||||
name: FooterMenuName,
|
||||
set: { status?: number | string },
|
||||
) => {
|
||||
if ("missing" in result) {
|
||||
return { error: errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`) };
|
||||
}
|
||||
if (!result.written) {
|
||||
return {
|
||||
error: errorResponse(
|
||||
set,
|
||||
409,
|
||||
"MENU_VERSION_CONFLICT",
|
||||
"Menu changed after it was read. Fetch the menu again before writing.",
|
||||
),
|
||||
};
|
||||
}
|
||||
return { value: result.value };
|
||||
};
|
||||
|
||||
const compactMenu = (
|
||||
menu: VersionedMenu,
|
||||
lang: MenuLanguage | "all",
|
||||
fallback: boolean,
|
||||
) => ({
|
||||
name: menu.name,
|
||||
lang,
|
||||
version: fingerprintMenu(menu),
|
||||
items: menu.items.map((item) => {
|
||||
const base = {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
order: item.order,
|
||||
columnImage: item.columnImage,
|
||||
hoverImage: item.hoverImage,
|
||||
};
|
||||
if (lang === "all") {
|
||||
return {
|
||||
...base,
|
||||
label: item.label,
|
||||
translations: Object.fromEntries(
|
||||
[...item.translations]
|
||||
.sort((a, b) => a.langCode.localeCompare(b.langCode))
|
||||
.map((translation) => [translation.langCode, translation.label]),
|
||||
),
|
||||
};
|
||||
}
|
||||
return { ...base, ...resolveMenuItemLabel(item, lang, fallback) };
|
||||
}),
|
||||
});
|
||||
|
||||
export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||
.get("/", async () => {
|
||||
return await prisma.menu.findMany({
|
||||
@@ -13,6 +240,20 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||
}
|
||||
});
|
||||
})
|
||||
.get("/footer/:name", async ({ params: { name }, query, set }: any) => {
|
||||
if (!isFooterMenuName(name)) {
|
||||
return errorResponse(set, 400, "INVALID_FOOTER_MENU", "Only footer-explore and footer-support are supported.");
|
||||
}
|
||||
const lang = query.lang ?? "en";
|
||||
if (lang !== "all" && !isMenuLanguage(lang)) {
|
||||
return errorResponse(set, 400, "INVALID_LOCALE", `Unsupported locale: ${lang}`);
|
||||
}
|
||||
const menu = await getFooterMenu(name);
|
||||
if (!menu) {
|
||||
return errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`);
|
||||
}
|
||||
return compactMenu(menu, lang, query.fallback !== "false");
|
||||
})
|
||||
.get("/:name", async ({ params: { name }, set }) => {
|
||||
const menu = await prisma.menu.findUnique({
|
||||
where: { name },
|
||||
@@ -29,6 +270,302 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||
return menu;
|
||||
})
|
||||
.use(adminOnly)
|
||||
.post("/footer/:name/items", async ({ params: { name }, body, set }: any) => {
|
||||
if (!isFooterMenuName(name)) {
|
||||
return errorResponse(set, 400, "INVALID_FOOTER_MENU", "Only footer-explore and footer-support are supported.");
|
||||
}
|
||||
const input = body as any;
|
||||
if (!isValidMenuUrl(input.url)) {
|
||||
return errorResponse(set, 400, "INVALID_MENU_URL", "URL must be a root-relative path or an HTTP(S) URL.");
|
||||
}
|
||||
const parsedTranslations = createMenuTranslations(input.translations);
|
||||
if (parsedTranslations.error) {
|
||||
return errorResponse(set, 400, "INVALID_LOCALE", parsedTranslations.error);
|
||||
}
|
||||
const menu = await getFooterMenu(name);
|
||||
if (!menu) return errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`);
|
||||
const versionCheck = checkVersion(menu, input.expectedVersion, set);
|
||||
if (versionCheck.conflict) return versionCheck.conflict;
|
||||
|
||||
const preview = {
|
||||
label: input.label,
|
||||
url: input.url,
|
||||
order: input.order ?? menu.items.length,
|
||||
columnImage: input.columnImage ?? null,
|
||||
hoverImage: input.hoverImage ?? null,
|
||||
};
|
||||
if (input.dryRun === true) {
|
||||
return {
|
||||
success: true,
|
||||
dryRun: true,
|
||||
operation: "add",
|
||||
version: versionCheck.currentVersion,
|
||||
item: { ...preview, translations: input.translations ?? {} },
|
||||
};
|
||||
}
|
||||
const transaction = unwrapTransaction(
|
||||
await runMenuTransaction(name, input.expectedVersion, (tx, currentMenu) =>
|
||||
tx.menuItem.create({
|
||||
data: {
|
||||
menuId: currentMenu.id,
|
||||
...preview,
|
||||
order: input.order ?? currentMenu.items.length,
|
||||
translations: parsedTranslations.translations.length
|
||||
? {
|
||||
create: parsedTranslations.translations,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: { translations: true },
|
||||
})),
|
||||
name,
|
||||
set,
|
||||
);
|
||||
if (transaction.error) return transaction.error;
|
||||
return {
|
||||
success: true,
|
||||
dryRun: false,
|
||||
item: transaction.value!.value,
|
||||
version: transaction.value!.version,
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
label: t.String({ minLength: 1 }),
|
||||
url: t.String({ minLength: 1 }),
|
||||
order: t.Optional(t.Integer({ minimum: 0 })),
|
||||
columnImage: t.Optional(t.Nullable(t.String())),
|
||||
hoverImage: t.Optional(t.Nullable(t.String())),
|
||||
translations: t.Optional(t.Record(t.String(), t.String({ minLength: 1 }))),
|
||||
dryRun: t.Optional(t.Boolean()),
|
||||
expectedVersion: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
.put("/footer/:name/items/reorder", async ({ params: { name }, body, set }: any) => {
|
||||
if (!isFooterMenuName(name)) {
|
||||
return errorResponse(set, 400, "INVALID_FOOTER_MENU", "Only footer-explore and footer-support are supported.");
|
||||
}
|
||||
const input = body as { itemIds: number[]; dryRun?: boolean; expectedVersion?: string };
|
||||
const menu = await getFooterMenu(name);
|
||||
if (!menu) return errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`);
|
||||
const versionCheck = checkVersion(menu, input.expectedVersion, set);
|
||||
if (versionCheck.conflict) return versionCheck.conflict;
|
||||
|
||||
const currentIds = menu.items.map((item) => item.id).sort((a, b) => a - b);
|
||||
const requestedIds = [...input.itemIds].sort((a, b) => a - b);
|
||||
const hasDuplicates = new Set(input.itemIds).size !== input.itemIds.length;
|
||||
const hasSameMembers = currentIds.length === requestedIds.length
|
||||
&& currentIds.every((id, index) => id === requestedIds[index]);
|
||||
if (hasDuplicates || !hasSameMembers) {
|
||||
return errorResponse(set, 400, "INVALID_REORDER_ITEMS", "itemIds must contain every menu item exactly once.");
|
||||
}
|
||||
if (input.dryRun === true) {
|
||||
return { success: true, dryRun: true, operation: "reorder", version: versionCheck.currentVersion, itemIds: input.itemIds };
|
||||
}
|
||||
const transaction = unwrapTransaction(
|
||||
await runMenuTransaction(name, input.expectedVersion, async (tx, currentMenu) => {
|
||||
const currentIdsInTransaction = currentMenu.items.map((item) => item.id).sort((a, b) => a - b);
|
||||
if (
|
||||
currentIdsInTransaction.length !== requestedIds.length
|
||||
|| !currentIdsInTransaction.every((id, index) => id === requestedIds[index])
|
||||
) {
|
||||
throw new Error("Menu membership changed during reorder transaction.");
|
||||
}
|
||||
await Promise.all(
|
||||
input.itemIds.map((id, order) => tx.menuItem.update({ where: { id }, data: { order } })),
|
||||
);
|
||||
return input.itemIds;
|
||||
}),
|
||||
name,
|
||||
set,
|
||||
);
|
||||
if (transaction.error) return transaction.error;
|
||||
return {
|
||||
success: true,
|
||||
dryRun: false,
|
||||
itemIds: transaction.value!.value,
|
||||
version: transaction.value!.version,
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
itemIds: t.Array(t.Integer({ minimum: 1 }), { minItems: 1 }),
|
||||
dryRun: t.Optional(t.Boolean()),
|
||||
expectedVersion: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
.patch("/footer/:name/items/:itemId/translations/:lang", async ({ params, body, set }: any) => {
|
||||
const { name, lang } = params;
|
||||
const itemId = Number(params.itemId);
|
||||
if (!isFooterMenuName(name)) {
|
||||
return errorResponse(set, 400, "INVALID_FOOTER_MENU", "Only footer-explore and footer-support are supported.");
|
||||
}
|
||||
if (!isMenuLanguage(lang)) {
|
||||
return errorResponse(set, 400, "INVALID_LOCALE", `Unsupported locale: ${lang}`);
|
||||
}
|
||||
if (!Number.isInteger(itemId) || itemId < 1) {
|
||||
return errorResponse(set, 400, "INVALID_MENU_ITEM_ID", "Menu item ID must be a positive integer.");
|
||||
}
|
||||
const input = body as { label: string; dryRun?: boolean; expectedVersion?: string };
|
||||
const menu = await getFooterMenu(name);
|
||||
if (!menu) return errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`);
|
||||
const item = menu.items.find((entry) => entry.id === itemId);
|
||||
if (!item) return errorResponse(set, 404, "MENU_ITEM_NOT_FOUND", "Menu item does not belong to this menu.");
|
||||
const versionCheck = checkVersion(menu, input.expectedVersion, set);
|
||||
if (versionCheck.conflict) return versionCheck.conflict;
|
||||
const previous = item.translations.find((entry) => entry.langCode === lang)?.label ?? null;
|
||||
if (input.dryRun === true) {
|
||||
return {
|
||||
success: true,
|
||||
dryRun: true,
|
||||
operation: "translate",
|
||||
version: versionCheck.currentVersion,
|
||||
itemId,
|
||||
lang,
|
||||
before: previous,
|
||||
after: input.label,
|
||||
};
|
||||
}
|
||||
const transaction = unwrapTransaction(
|
||||
await runMenuTransaction(name, input.expectedVersion, async (tx, currentMenu) => {
|
||||
if (!currentMenu.items.some((entry) => entry.id === itemId)) {
|
||||
throw new Error("Menu item membership changed during translation transaction.");
|
||||
}
|
||||
return tx.menuItemTranslation.upsert({
|
||||
where: { menuItemId_langCode: { menuItemId: itemId, langCode: lang } },
|
||||
update: { label: input.label },
|
||||
create: { menuItemId: itemId, langCode: lang, label: input.label },
|
||||
});
|
||||
}),
|
||||
name,
|
||||
set,
|
||||
);
|
||||
if (transaction.error) return transaction.error;
|
||||
return {
|
||||
success: true,
|
||||
dryRun: false,
|
||||
itemId,
|
||||
lang,
|
||||
label: input.label,
|
||||
version: transaction.value!.version,
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
label: t.String({ minLength: 1 }),
|
||||
dryRun: t.Optional(t.Boolean()),
|
||||
expectedVersion: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
.patch("/footer/:name/items/:itemId", async ({ params, body, set }: any) => {
|
||||
const { name } = params;
|
||||
const itemId = Number(params.itemId);
|
||||
if (!isFooterMenuName(name)) {
|
||||
return errorResponse(set, 400, "INVALID_FOOTER_MENU", "Only footer-explore and footer-support are supported.");
|
||||
}
|
||||
if (!Number.isInteger(itemId) || itemId < 1) {
|
||||
return errorResponse(set, 400, "INVALID_MENU_ITEM_ID", "Menu item ID must be a positive integer.");
|
||||
}
|
||||
const input = body as any;
|
||||
if (input.url !== undefined && !isValidMenuUrl(input.url)) {
|
||||
return errorResponse(set, 400, "INVALID_MENU_URL", "URL must be a root-relative path or an HTTP(S) URL.");
|
||||
}
|
||||
const menu = await getFooterMenu(name);
|
||||
if (!menu) return errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`);
|
||||
const item = menu.items.find((entry) => entry.id === itemId);
|
||||
if (!item) return errorResponse(set, 404, "MENU_ITEM_NOT_FOUND", "Menu item does not belong to this menu.");
|
||||
const versionCheck = checkVersion(menu, input.expectedVersion, set);
|
||||
if (versionCheck.conflict) return versionCheck.conflict;
|
||||
|
||||
const data = Object.fromEntries(
|
||||
["label", "url", "order", "columnImage", "hoverImage"]
|
||||
.filter((field) => input[field] !== undefined)
|
||||
.map((field) => [field, input[field]]),
|
||||
);
|
||||
if (Object.keys(data).length === 0) {
|
||||
return errorResponse(set, 400, "NO_MENU_ITEM_CHANGES", "Provide at least one item field to update.");
|
||||
}
|
||||
if (input.dryRun === true) {
|
||||
return {
|
||||
success: true,
|
||||
dryRun: true,
|
||||
operation: "update",
|
||||
version: versionCheck.currentVersion,
|
||||
itemId,
|
||||
changes: Object.fromEntries(Object.entries(data).map(([key, value]) => [
|
||||
key,
|
||||
{ before: item[key as keyof typeof item], after: value },
|
||||
])),
|
||||
};
|
||||
}
|
||||
const transaction = unwrapTransaction(
|
||||
await runMenuTransaction(name, input.expectedVersion, async (tx, currentMenu) => {
|
||||
if (!currentMenu.items.some((entry) => entry.id === itemId)) {
|
||||
throw new Error("Menu item membership changed during update transaction.");
|
||||
}
|
||||
return tx.menuItem.update({ where: { id: itemId }, data });
|
||||
}),
|
||||
name,
|
||||
set,
|
||||
);
|
||||
if (transaction.error) return transaction.error;
|
||||
return {
|
||||
success: true,
|
||||
dryRun: false,
|
||||
item: transaction.value!.value,
|
||||
version: transaction.value!.version,
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
label: t.Optional(t.String({ minLength: 1 })),
|
||||
url: t.Optional(t.String({ minLength: 1 })),
|
||||
order: t.Optional(t.Integer({ minimum: 0 })),
|
||||
columnImage: t.Optional(t.Nullable(t.String())),
|
||||
hoverImage: t.Optional(t.Nullable(t.String())),
|
||||
dryRun: t.Optional(t.Boolean()),
|
||||
expectedVersion: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
.delete("/footer/:name/items/:itemId", async ({ params, body, set }: any) => {
|
||||
const { name } = params;
|
||||
const itemId = Number(params.itemId);
|
||||
if (!isFooterMenuName(name)) {
|
||||
return errorResponse(set, 400, "INVALID_FOOTER_MENU", "Only footer-explore and footer-support are supported.");
|
||||
}
|
||||
if (!Number.isInteger(itemId) || itemId < 1) {
|
||||
return errorResponse(set, 400, "INVALID_MENU_ITEM_ID", "Menu item ID must be a positive integer.");
|
||||
}
|
||||
const input = (body ?? {}) as { dryRun?: boolean; expectedVersion?: string };
|
||||
const menu = await getFooterMenu(name);
|
||||
if (!menu) return errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`);
|
||||
const item = menu.items.find((entry) => entry.id === itemId);
|
||||
if (!item) return errorResponse(set, 404, "MENU_ITEM_NOT_FOUND", "Menu item does not belong to this menu.");
|
||||
const versionCheck = checkVersion(menu, input.expectedVersion, set);
|
||||
if (versionCheck.conflict) return versionCheck.conflict;
|
||||
if (input.dryRun === true) {
|
||||
return { success: true, dryRun: true, operation: "delete", version: versionCheck.currentVersion, item };
|
||||
}
|
||||
const transaction = unwrapTransaction(
|
||||
await runMenuTransaction(name, input.expectedVersion, async (tx, currentMenu) => {
|
||||
if (!currentMenu.items.some((entry) => entry.id === itemId)) {
|
||||
throw new Error("Menu item membership changed during delete transaction.");
|
||||
}
|
||||
await tx.menuItem.delete({ where: { id: itemId } });
|
||||
return itemId;
|
||||
}),
|
||||
name,
|
||||
set,
|
||||
);
|
||||
if (transaction.error) return transaction.error;
|
||||
return {
|
||||
success: true,
|
||||
dryRun: false,
|
||||
deletedItemId: transaction.value!.value,
|
||||
version: transaction.value!.version,
|
||||
};
|
||||
}, {
|
||||
body: t.Optional(t.Object({
|
||||
dryRun: t.Optional(t.Boolean()),
|
||||
expectedVersion: t.Optional(t.String()),
|
||||
})),
|
||||
})
|
||||
.post("/", async ({ body, set }: { body: any; set: any }) => {
|
||||
try {
|
||||
const { name, rootImage, items } = body as {
|
||||
@@ -104,6 +641,7 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx: any) => {
|
||||
await lockMenuForTransaction(tx, menu!.id);
|
||||
const existingItems = await tx.menuItem.findMany({ where: { menuId: menu!.id } });
|
||||
const existingItemIds = existingItems.map((i: any) => i.id);
|
||||
const payloadItemIds = items.map(i => i.id).filter(id => id != null);
|
||||
|
||||
Reference in new issue
Block a user