Files
homaroma/apps/backend/src/routes/menus.ts
T
2026-07-25 03:02:07 -04:00

949 lines
31 KiB
TypeScript

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 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<string, unknown>,
) => {
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<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>,
) => {
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<unknown>,
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<ReturnType<typeof runMenuTransaction>>,
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<string, string>;
},
) => ({
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 };
});