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, 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 = {}) { 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 => db.menu.findUnique({ where: { name }, include: footerMenuInclude, }); export class MenuBusyError extends Error { constructor() { super("Menu is currently being edited."); this.name = "MenuBusyError"; } } const assertAdvisoryLock = (rows: unknown) => { if ( !Array.isArray(rows) || rows.length === 0 || (rows[0] as { locked?: unknown }).locked !== true ) { throw new MenuBusyError(); } }; export const lockMenuForTransaction = async (tx: any, menuId: number) => { const rows = await tx.$queryRaw`SELECT pg_try_advisory_xact_lock(${menuId}) AS locked`; assertAdvisoryLock(rows); return true; }; export const lockMenuNameForTransaction = async (tx: any, name: FooterMenuName | string) => { const rows = await tx.$queryRaw`SELECT pg_try_advisory_xact_lock(1213025877, hashtext(${name})) AS locked`; assertAdvisoryLock(rows); return true; }; export const prepareMenuForAdminWrite = async ( tx: any, name: string, updateData: Record, ) => { await lockMenuNameForTransaction(tx, name); let identity = await tx.menu.findUnique({ where: { name }, select: { id: true }, }); if (!identity) { identity = await tx.menu.create({ data: { name }, select: { id: true }, }); } await lockMenuForTransaction(tx, identity.id); let menu = await tx.menu.findUnique({ where: { name } }); if (!menu) throw new Error(`Menu ${name} disappeared before Admin write.`); if (Object.keys(updateData).length > 0) { menu = await tx.menu.update({ where: { name }, data: updateData, }); } return menu; }; export async function executeVersionedMenuWrite( menu: VersionedMenu, expectedVersion: unknown, write: () => Promise, ) { 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 ( name: FooterMenuName, expectedVersion: unknown, write: (tx: any, menu: VersionedMenu) => Promise, ) => { try { return await prisma.$transaction(async (tx: any) => { await lockMenuNameForTransaction(tx, name); 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) }; }); }); } catch (error) { if (error instanceof MenuBusyError) return { busy: true as const }; throw error; } }; export const runAddMenuTransaction = async ( name: FooterMenuName, expectedVersion: unknown, write: (tx: any, menu: VersionedMenu) => Promise, db: any = prisma, ) => { try { return await db.$transaction(async (tx: any) => { await lockMenuNameForTransaction(tx, name); let identity = await tx.menu.findUnique({ where: { name }, select: { id: true }, }); const createdMenu = !identity; if (!identity) { if (expectedVersion !== undefined && expectedVersion !== null) { return { written: false as const, currentVersion: null }; } identity = await tx.menu.create({ data: { name, isActive: true }, select: { id: true }, }); } await lockMenuForTransaction(tx, identity.id); const menu = await getFooterMenu(name, tx); if (!menu) throw new Error(`Menu ${name} disappeared during bootstrap transaction.`); return executeVersionedMenuWrite( menu, createdMenu ? undefined : expectedVersion, async () => { const value = await write(tx, menu); const updated = await getFooterMenu(name, tx); if (!updated) throw new Error(`Menu ${name} disappeared during add transaction.`); return { value, version: fingerprintMenu(updated), createdMenu, }; }, ); }); } catch (error) { if (error instanceof MenuBusyError) return { busy: true as const }; throw error; } }; const unwrapTransaction = ( result: Awaited>, name: FooterMenuName, set: { status?: number | string }, ) => { if ("busy" in result) { return { error: errorResponse( set, 409, "MENU_BUSY", "Menu is currently being edited. Read it again and retry.", ), }; } 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 compactMissingFooterMenu = ( name: FooterMenuName, lang: MenuLanguage | "all", ) => ({ name, lang, exists: false as const, version: null, items: [], }); export const canBootstrapFooterMenu = (expectedVersion: unknown) => expectedVersion === undefined || expectedVersion === null; export const buildFooterMenuAddDryRun = ( menu: VersionedMenu | null, input: { label: string; url: string; order?: number; columnImage?: string | null; hoverImage?: string | null; translations?: Record; }, ) => ({ success: true, dryRun: true, operation: "add", version: menu ? fingerprintMenu(menu) : null, willCreateMenu: !menu, item: { label: input.label, url: input.url, order: input.order ?? menu?.items.length ?? 0, columnImage: input.columnImage ?? null, hoverImage: input.hoverImage ?? null, translations: input.translations ?? {}, }, }); export const menusRoute = new Elysia({ prefix: "/menus" }) .get("/", async () => { return await prisma.menu.findMany({ include: { items: { orderBy: { order: "asc" }, include: { translations: true } } } }); }) .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 compactMissingFooterMenu(name, lang); } return { ...compactMenu(menu, lang, query.fallback !== "false"), exists: true }; }) .get("/:name", async ({ params: { name }, set }) => { const menu = await prisma.menu.findUnique({ where: { name }, include: { items: { orderBy: { order: "asc" }, include: { translations: true } } } }); if (!menu) { return { name, items: [], payload: null, rootImage: null, isActive: false }; } 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 && !canBootstrapFooterMenu(input.expectedVersion)) { return errorResponse( set, 409, "MENU_VERSION_CONFLICT", "Menu does not exist, so the supplied version is stale.", ); } const versionCheck = menu ? checkVersion(menu, input.expectedVersion, set) : { currentVersion: null }; if ("conflict" in versionCheck && versionCheck.conflict) return versionCheck.conflict; const preview = { label: input.label, url: input.url, order: input.order ?? menu?.items.length ?? 0, columnImage: input.columnImage ?? null, hoverImage: input.hoverImage ?? null, }; if (input.dryRun === true) { return buildFooterMenuAddDryRun(menu, input); } const transaction = await runAddMenuTransaction( 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 }, }), ); if ("busy" in transaction) { return errorResponse( set, 409, "MENU_BUSY", "Menu is currently being edited. Read it again and retry.", ); } if (!transaction.written) { return errorResponse( set, 409, "MENU_VERSION_CONFLICT", transaction.currentVersion === null ? "Menu does not exist, so the supplied version is stale." : "Menu changed after it was read. Fetch the menu again before writing.", ); } return { success: true, dryRun: false, createdMenu: transaction.value.createdMenu, 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.Nullable(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 { name: string; rootImage?: string | null; items?: { label: string; url: string; order: number; columnImage?: string | null; hoverImage?: string | null }[] }; const data: any = { name, rootImage, }; if (items) { data.items = { create: items.map(item => ({ label: item.label, url: item.url, order: item.order, columnImage: item.columnImage, hoverImage: item.hoverImage })) }; } const menu = await prisma.menu.create({ data, include: { items: true } }); return menu; } catch (err) { console.error(err); set.status = 500; return { error: "Failed to create menu" }; } }, { body: t.Object({ name: t.String(), rootImage: t.Optional(t.Nullable(t.String())), items: t.Optional(t.Array(t.Object({ label: t.String(), url: t.String(), order: t.Number(), columnImage: t.Optional(t.Nullable(t.String())), hoverImage: t.Optional(t.Nullable(t.String())) }))) }) }) .put("/:name", async ({ params: { name }, body, set }: { params: { name: string }; body: any; set: any }) => { try { const { items, payload, isActive, rootImage } = body as { payload?: any; isActive?: boolean; rootImage?: string | null; items: { id?: number; label: string; url: string; order: number; columnImage?: string | null; hoverImage?: string | null; translations?: { langCode: string; label: string }[] }[] }; const updateData: any = {}; if (payload !== undefined) updateData.payload = payload; if (isActive !== undefined) updateData.isActive = isActive; if (rootImage !== undefined) updateData.rootImage = rootImage; return await prisma.$transaction(async (tx: any) => { const menu = await prepareMenuForAdminWrite(tx, name, updateData); 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); const itemsToDelete = existingItemIds.filter((id: number) => !payloadItemIds.includes(id)); if (itemsToDelete.length > 0) { await tx.menuItem.deleteMany({ where: { id: { in: itemsToDelete } } }); } for (const item of items) { if (item.id && existingItemIds.includes(item.id)) { await tx.menuItem.update({ where: { id: item.id }, data: { label: item.label, url: item.url, order: item.order, columnImage: item.columnImage, hoverImage: item.hoverImage } }); if (item.translations?.length) { for (const t of item.translations) { await tx.menuItemTranslation.upsert({ where: { menuItemId_langCode: { menuItemId: item.id, langCode: t.langCode } }, update: { label: t.label }, create: { menuItemId: item.id, langCode: t.langCode, label: t.label } }); } } } else { await tx.menuItem.create({ data: { menuId: menu.id, label: item.label, url: item.url, order: item.order, columnImage: item.columnImage, hoverImage: item.hoverImage, translations: item.translations?.length ? { create: item.translations.map(t => ({ langCode: t.langCode, label: t.label })) } : undefined } }); } } return tx.menu.findUnique({ where: { name }, include: { items: { orderBy: { order: "asc" }, include: { translations: true } } } }); }); } catch (err) { if (err instanceof MenuBusyError) { return errorResponse( set, 409, "MENU_BUSY", "Menu is currently being edited. Read it again and retry.", ); } console.error(err); set.status = 500; return { error: "Failed to update menu" }; } }, { body: t.Object({ payload: t.Optional(t.Any()), isActive: t.Optional(t.Boolean()), rootImage: t.Optional(t.Nullable(t.String())), items: t.Array(t.Object({ id: t.Optional(t.Number()), label: t.String(), url: t.String(), order: t.Number(), columnImage: t.Optional(t.Nullable(t.String())), hoverImage: t.Optional(t.Nullable(t.String())), translations: t.Optional(t.Array(t.Object({ langCode: t.String(), label: t.String() }))) })) }) }) .patch("/items/:id/translations", async ({ params, body, set }) => { const id = Number(params.id); if (isNaN(id)) { set.status = 400; return { error: "Invalid menu item ID" }; } const menuItem = await prisma.menuItem.findUnique({ where: { id } }); if (!menuItem) { set.status = 404; return { error: "Menu item not found. Please ensure database is seeded or the item exists." }; } const payload = body as any; const results: any[] = []; const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it']; for (const lang of LANGS) { const label = typeof payload.label === 'string' ? (lang === 'th' ? payload.label : null) : payload.label?.[lang]; if (!label) continue; const existing = await prisma.menuItemTranslation.findUnique({ where: { menuItemId_langCode: { menuItemId: id, langCode: lang } } }); if (existing) { const updated = await prisma.menuItemTranslation.update({ where: { id: existing.id }, data: { label } }); results.push(updated); } else { const created = await prisma.menuItemTranslation.create({ data: { menuItemId: id, langCode: lang, label } }); results.push(created); } } const updated = await prisma.menuItem.findUnique({ where: { id }, include: { translations: true } }); return { success: true, translations: results, menuItem: updated }; });