949 lines
31 KiB
TypeScript
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 };
|
|
});
|