diff --git a/LLM_MCP_GUIDE.md b/LLM_MCP_GUIDE.md index 6ad353b..a6c0a02 100644 --- a/LLM_MCP_GUIDE.md +++ b/LLM_MCP_GUIDE.md @@ -178,6 +178,20 @@ Translate one language and one block at a time. This keeps the context compact a Use `get_page_section` with `lang=all` only when every stored language map is genuinely needed. A specific language returns resolved strings and uses fallback by default. +## Editing Footer Menus + +`footer-explore` and `footer-support` are flat menus shared by Admin, Backend, MCP, and Website. They are not Page Builder sections and do not use the Mega Menu tree tools. + +1. Call `get_footer_menu` with `menuName` and the target `lang`. +2. Keep the returned `version`. +3. Preview one item-level mutation with `dryRun=true` and `expectedVersion`. +4. Apply only after the preview is correct. +5. Read the menu again and verify the result. + +Use `update_footer_menu_item_translation` for one language at a time. Use `reorder_footer_menu_items` only with the complete list of current item IDs. If the backend returns `MENU_VERSION_CONFLICT`, discard the old version and read the menu again. + +Do not use legacy `update_footer_content` for menu arrays. It remains available only for the Footer brand description and social links. Website Collections come from Categories, not a Footer menu. + ## What to Expect - `PRODUCT_CAROUSEL` may return products even when you did not send them. diff --git a/apps/backend/src/routes/menus.footer.test.ts b/apps/backend/src/routes/menus.footer.test.ts new file mode 100644 index 0000000..382c305 --- /dev/null +++ b/apps/backend/src/routes/menus.footer.test.ts @@ -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, + }); + }); +}); diff --git a/apps/backend/src/routes/menus.ts b/apps/backend/src/routes/menus.ts index 7210010..73ed9aa 100644 --- a/apps/backend/src/routes/menus.ts +++ b/apps/backend/src/routes/menus.ts @@ -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, + 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 const lockMenuForTransaction = async (tx: any, menuId: number) => { + await tx.$queryRaw`SELECT pg_advisory_xact_lock(${menuId})`; +}; + +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, +) => + 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>, + 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); diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index ecdd183..3cb81c6 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -32,7 +32,37 @@ bun run dev - `update_brand_name` เปลี่ยนชื่อแบรนด์กลางที่ใช้ใน header/footer ของ `apps/spa` และ `apps/website` - `update_store_settings_translation` อัปเดตข้อความแปลของ store settings -- `update_footer_content` อัปเดตเนื้อหา footer และลิงก์ที่เกี่ยวข้อง +- `update_footer_content` อัปเดตคำอธิบายแบรนด์และ social links เท่านั้น + +## Footer Menu LLM Workflow + +Footer Explore and Footer Support are flat item menus, not Mega Menu payload trees. Supported menu names are: + +```text +footer-explore +footer-support +``` + +Use this workflow for every Footer menu edit: + +1. Call `get_footer_menu` with the target language. +2. Keep the returned `version`. +3. Call one item-level write tool with `dryRun: true` and `expectedVersion`. +4. Review the compact preview. +5. Apply the same operation with `dryRun: false` and the same version. +6. Call `get_footer_menu` again to verify and obtain the next version. + +Available item-level tools: + +- `add_footer_menu_item` +- `update_footer_menu_item` +- `delete_footer_menu_item` +- `reorder_footer_menu_items` +- `update_footer_menu_item_translation` + +Every reorder request must include every current item ID exactly once. A `MENU_VERSION_CONFLICT` response means Admin or another agent changed the menu after it was read; fetch it again before retrying. + +Do not pass `exploreLinks`, `collectionLinks`, or `supportLinks` to legacy `update_footer_content`. Full menu-array replacement is rejected because it can delete item IDs and translations. Website Collections are populated from Categories rather than a `footer-collections` menu. ## Mega Menu LLM Workflow diff --git a/apps/mcp-server/index.ts b/apps/mcp-server/index.ts index 505ae95..aec954d 100644 --- a/apps/mcp-server/index.ts +++ b/apps/mcp-server/index.ts @@ -6,7 +6,7 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import axios from "axios"; import FormData from "form-data"; -import { MENU_TOOL_DEFINITIONS, handleMenuTool } from "./menu-toolkit"; +import { MENU_TOOL_DEFINITIONS, getLegacyFooterMenuDeprecation, handleMenuTool } from "./menu-toolkit"; import { SECTION_TOOL_DEFINITIONS, buildCmsPreview, handleSectionTool } from "./section-toolkit"; const LOCAL_BACKEND_URL = process.env.MCP_BACKEND_URL_LOCAL || "http://localhost:3051/api"; const PRODUCTION_BACKEND_URL = process.env.MCP_BACKEND_URL_PRODUCTION || "https://api.homaroma.shop/api"; @@ -949,7 +949,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, { name: "update_footer_content", - description: "Update the content and links of the 4 Footer Blocks (Brand, Explore, Collections, Newsletter)", + description: "Legacy tool for Footer brand description and social links only. Footer menu arrays are rejected; use the Footer-specific item tools instead.", inputSchema: { type: "object", properties: { @@ -965,7 +965,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, exploreLinks: { type: "array", - description: "Array of { label, url, order } for Block 2 (Explore)", + description: "Deprecated and rejected. Use add_footer_menu_item, update_footer_menu_item, delete_footer_menu_item, or reorder_footer_menu_items.", items: { type: "object", properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } } @@ -973,7 +973,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, collectionLinks: { type: "array", - description: "Array of { label, url, order } for Block 3 (Collections)", + description: "Deprecated and rejected. Website Collections come from Categories, not a footer menu.", items: { type: "object", properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } } @@ -981,7 +981,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, supportLinks: { type: "array", - description: "Array of { label, url, order } for Block 4 (Support/Legal)", + description: "Deprecated and rejected. Use the Footer-specific item tools with menuName=footer-support.", items: { type: "object", properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } } @@ -1661,6 +1661,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return { content: [{ type: "text", text: JSON.stringify(normalized, null, 2) }] }; } case "update_footer_content": { + const deprecation = getLegacyFooterMenuDeprecation(params); + if (deprecation) { + return { + content: [{ + type: "text", + text: JSON.stringify(deprecation, null, 2), + }], + isError: true, + }; + } + const results = []; if (params.brandDescription !== undefined || params.socialLinks) { @@ -1687,21 +1698,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } } - if (params.exploreLinks) { - await api.put("/menus/footer-explore", { items: params.exploreLinks }); - results.push({ type: "explore_links", status: "success" }); - } - - if (params.collectionLinks) { - await api.put("/menus/footer-collections", { items: params.collectionLinks }); - results.push({ type: "collection_links", status: "success" }); - } - - if (params.supportLinks) { - await api.put("/menus/footer-support", { items: params.supportLinks }); - results.push({ type: "support_links", status: "success" }); - } - return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] }; } default: diff --git a/apps/mcp-server/menu-toolkit.test.ts b/apps/mcp-server/menu-toolkit.test.ts new file mode 100644 index 0000000..811cac7 --- /dev/null +++ b/apps/mcp-server/menu-toolkit.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, test } from "bun:test"; +import { + MENU_TOOL_DEFINITIONS, + getLegacyFooterMenuDeprecation, + handleMenuTool, +} from "./menu-toolkit"; + +type Call = { + method: string; + url: string; + body?: unknown; + config?: unknown; +}; + +function createApi(response: unknown = { success: true, version: "v2" }) { + const calls: Call[] = []; + const api = { + get: async (url: string) => { + calls.push({ method: "get", url }); + return { data: response }; + }, + post: async (url: string, body: unknown) => { + calls.push({ method: "post", url, body }); + return { data: response }; + }, + patch: async (url: string, body: unknown) => { + calls.push({ method: "patch", url, body }); + return { data: response }; + }, + put: async (url: string, body: unknown) => { + calls.push({ method: "put", url, body }); + return { data: response }; + }, + delete: async (url: string, config: unknown) => { + calls.push({ method: "delete", url, config }); + return { data: response }; + }, + }; + return { api: api as any, calls }; +} + +function output(result: any) { + return JSON.parse(result.content[0].text); +} + +describe("Footer MCP tool definitions", () => { + test("registers six Footer-specific tools with constrained menu names", () => { + const names = [ + "get_footer_menu", + "add_footer_menu_item", + "update_footer_menu_item", + "delete_footer_menu_item", + "reorder_footer_menu_items", + "update_footer_menu_item_translation", + ]; + + for (const name of names) { + const definition = MENU_TOOL_DEFINITIONS.find((tool) => tool.name === name) as any; + expect(definition).toBeDefined(); + expect(definition.inputSchema.properties.menuName.enum).toEqual([ + "footer-explore", + "footer-support", + ]); + } + }); + + test("all Footer writes expose dryRun and expectedVersion", () => { + const writes = MENU_TOOL_DEFINITIONS.filter((tool) => + [ + "add_footer_menu_item", + "update_footer_menu_item", + "delete_footer_menu_item", + "reorder_footer_menu_items", + "update_footer_menu_item_translation", + ].includes(tool.name), + ) as any[]; + + for (const tool of writes) { + expect(tool.inputSchema.properties.dryRun).toBeDefined(); + expect(tool.inputSchema.properties.expectedVersion).toBeDefined(); + } + }); + + test("update_footer_menu_item exposes only supported mutable item fields", () => { + const definition = MENU_TOOL_DEFINITIONS.find( + (tool) => tool.name === "update_footer_menu_item", + ) as any; + + expect(Object.keys(definition.inputSchema.properties).sort()).toEqual([ + "dryRun", + "expectedVersion", + "itemId", + "label", + "menuName", + "order", + "url", + ]); + expect(definition.inputSchema.properties.isActive).toBeUndefined(); + }); +}); + +describe("Footer MCP proxy handlers", () => { + test("reads a compact localized Footer menu", async () => { + const { api, calls } = createApi({ + success: true, + menuName: "footer-explore", + lang: "zh", + version: "v1", + items: [{ id: 1, label: "商店", url: "/shop", order: 1 }], + internalMetadata: "must not leak", + }); + const result = await handleMenuTool(api, "get_footer_menu", { + menuName: "footer-explore", + lang: "zh", + fallback: false, + }); + + expect(calls).toEqual([{ + method: "get", + url: "/menus/footer/footer-explore?lang=zh&fallback=false", + }]); + expect(output(result)).toEqual({ + success: true, + operation: "get_footer_menu", + menuName: "footer-explore", + version: "v1", + lang: "zh", + items: [{ id: 1, label: "商店", url: "/shop", order: 1 }], + }); + }); + + test("adds an item with write controls", async () => { + const { api, calls } = createApi(); + await handleMenuTool(api, "add_footer_menu_item", { + menuName: "footer-explore", + label: "Shop", + url: "/shop", + order: 2, + translations: { zh: "商店" }, + dryRun: true, + expectedVersion: "v1", + }); + + expect(calls[0]).toEqual({ + method: "post", + url: "/menus/footer/footer-explore/items", + body: { + label: "Shop", + url: "/shop", + order: 2, + translations: { zh: "商店" }, + dryRun: true, + expectedVersion: "v1", + }, + }); + }); + + test("partially updates an item without forwarding unrelated fields", async () => { + const { api, calls } = createApi(); + await handleMenuTool(api, "update_footer_menu_item", { + menuName: "footer-support", + itemId: 9, + label: "Contact", + dryRun: false, + expectedVersion: "v4", + translations: { zh: "ignored" }, + isActive: true, + }); + + expect(calls[0]).toEqual({ + method: "patch", + url: "/menus/footer/footer-support/items/9", + body: { label: "Contact", dryRun: false, expectedVersion: "v4" }, + }); + }); + + test("deletes an item with Axios DELETE body config", async () => { + const { api, calls } = createApi(); + await handleMenuTool(api, "delete_footer_menu_item", { + menuName: "footer-support", + itemId: 7, + dryRun: true, + expectedVersion: "v3", + }); + + expect(calls[0]).toEqual({ + method: "delete", + url: "/menus/footer/footer-support/items/7", + config: { data: { dryRun: true, expectedVersion: "v3" } }, + }); + }); + + test("reorders using the complete item ID list", async () => { + const { api, calls } = createApi(); + await handleMenuTool(api, "reorder_footer_menu_items", { + menuName: "footer-explore", + itemIds: [3, 1, 2], + expectedVersion: "v8", + }); + + expect(calls[0]).toEqual({ + method: "put", + url: "/menus/footer/footer-explore/items/reorder", + body: { itemIds: [3, 1, 2], dryRun: false, expectedVersion: "v8" }, + }); + }); + + test("updates one item translation through the language endpoint", async () => { + const { api, calls } = createApi(); + await handleMenuTool(api, "update_footer_menu_item_translation", { + menuName: "footer-explore", + itemId: 12, + lang: "zh", + label: "商店", + dryRun: true, + expectedVersion: "v5", + }); + + expect(calls[0]).toEqual({ + method: "patch", + url: "/menus/footer/footer-explore/items/12/translations/zh", + body: { label: "商店", dryRun: true, expectedVersion: "v5" }, + }); + }); + + test("preserves compact mutation, preview, and conflict response keys", async () => { + const { api } = createApi({ + success: false, + operation: "update_footer_menu_item", + menuName: "footer-support", + itemId: 18, + itemIds: [18, 20], + label: "Help", + before: { label: "Support" }, + after: { label: "Help" }, + code: "MENU_VERSION_CONFLICT", + error: "The menu changed after it was read.", + partialWrite: false, + version: "v9", + databaseRecord: { mustNotLeak: true }, + }); + const result = await handleMenuTool(api, "update_footer_menu_item", { + menuName: "footer-support", + itemId: 18, + label: "Help", + expectedVersion: "v8", + }); + + expect(output(result)).toEqual({ + success: false, + operation: "update_footer_menu_item", + menuName: "footer-support", + version: "v9", + itemId: 18, + itemIds: [18, 20], + label: "Help", + before: { label: "Support" }, + after: { label: "Help" }, + code: "MENU_VERSION_CONFLICT", + error: "The menu changed after it was read.", + partialWrite: false, + }); + }); +}); + +describe("Legacy Footer mutation guard", () => { + test("rejects every legacy menu array before any partial write", () => { + expect(getLegacyFooterMenuDeprecation({ + brandDescription: "Must not be written", + exploreLinks: [], + collectionLinks: [], + supportLinks: [], + })).toEqual({ + success: false, + code: "FOOTER_MENU_REPLACEMENT_DEPRECATED", + partialWrite: false, + rejectedFields: ["exploreLinks", "collectionLinks", "supportLinks"], + message: expect.any(String), + }); + }); + + test("retains legacy brand and social settings support", () => { + expect(getLegacyFooterMenuDeprecation({ + brandDescription: "Brand", + socialLinks: { instagramUrl: "https://instagram.com/example" }, + })).toBeNull(); + }); +}); diff --git a/apps/mcp-server/menu-toolkit.ts b/apps/mcp-server/menu-toolkit.ts index 55b58a7..432a8a2 100644 --- a/apps/mcp-server/menu-toolkit.ts +++ b/apps/mcp-server/menu-toolkit.ts @@ -25,6 +25,23 @@ const localizedTextSchema = { additionalProperties: false, } as const; +const footerMenuNameSchema = { + type: "string", + enum: ["footer-explore", "footer-support"], + description: "Footer menu managed by apps/admin Menu Manager.", +} as const; + +const footerWriteControlSchema = { + dryRun: { + type: "boolean", + description: "Validate and preview the operation without saving. Defaults to false.", + }, + expectedVersion: { + type: "string", + description: "Version returned by get_footer_menu. The backend rejects stale writes.", + }, +} as const; + const menuLinkNodeSchema = { type: "object", description: "Mega Menu Link node. Valid under a Column or another Link. Renders as a clickable link; child links are shown inline up to the desktop limit.", @@ -88,6 +105,119 @@ const rootMenuNodeSchema = { } as const; export const MENU_TOOL_DEFINITIONS = [ + { + name: "get_footer_menu", + description: "Read footer-explore or footer-support in a compact, language-specific format. Use lang=all only when every translation is required.", + inputSchema: { + type: "object", + properties: { + menuName: footerMenuNameSchema, + lang: { + type: "string", + enum: [...SUPPORTED_MENU_LOCALES, "all"], + description: "Language to resolve. Defaults to th; all returns all translations.", + }, + fallback: { + type: "boolean", + description: "Allow the backend to fall back to the base label when the requested translation is missing. Defaults to true.", + }, + }, + required: ["menuName"], + additionalProperties: false, + }, + }, + { + name: "add_footer_menu_item", + description: "Add one item to footer-explore or footer-support without replacing the menu. Use dryRun first and pass the version from get_footer_menu.", + inputSchema: { + type: "object", + properties: { + menuName: footerMenuNameSchema, + label: { type: "string", description: "Base label for the item." }, + url: { type: "string", description: "Internal path or supported external URL." }, + order: { type: "number", description: "Display order." }, + translations: { + type: "object", + properties: Object.fromEntries(SUPPORTED_MENU_LOCALES.map((locale) => [locale, { type: "string" }])), + additionalProperties: false, + description: "Optional translated labels keyed by locale.", + }, + ...footerWriteControlSchema, + }, + required: ["menuName", "label", "url"], + additionalProperties: false, + }, + }, + { + name: "update_footer_menu_item", + description: "Partially update one footer menu item while preserving omitted fields and translations.", + inputSchema: { + type: "object", + properties: { + menuName: footerMenuNameSchema, + itemId: { type: "number", description: "Existing footer menu item ID." }, + label: { type: "string" }, + url: { type: "string" }, + order: { type: "number" }, + ...footerWriteControlSchema, + }, + required: ["menuName", "itemId"], + additionalProperties: false, + }, + }, + { + name: "delete_footer_menu_item", + description: "Delete one item from footer-explore or footer-support. Use dryRun first and pass expectedVersion.", + inputSchema: { + type: "object", + properties: { + menuName: footerMenuNameSchema, + itemId: { type: "number", description: "Existing footer menu item ID." }, + ...footerWriteControlSchema, + }, + required: ["menuName", "itemId"], + additionalProperties: false, + }, + }, + { + name: "reorder_footer_menu_items", + description: "Reorder a footer menu using the complete ordered list of its item IDs.", + inputSchema: { + type: "object", + properties: { + menuName: footerMenuNameSchema, + itemIds: { + type: "array", + items: { type: "number" }, + minItems: 1, + description: "Every current item ID, exactly once, in the desired order.", + }, + ...footerWriteControlSchema, + }, + required: ["menuName", "itemIds"], + additionalProperties: false, + }, + }, + { + name: "update_footer_menu_item_translation", + description: "Update one translated label on a footer menu item without changing its base label, URL, order, or other languages.", + inputSchema: { + type: "object", + properties: { + menuName: footerMenuNameSchema, + itemId: { type: "number", description: "Existing footer menu item ID." }, + lang: { + type: "string", + enum: SUPPORTED_MENU_LOCALES, + description: "Translation locale.", + }, + label: { type: "string", description: "Translated label." }, + ...footerWriteControlSchema, + }, + required: ["menuName", "itemId", "lang", "label"], + additionalProperties: false, + }, + }, { name: "get_menu", description: "Fetch a specific menu and its items (including translations)", @@ -281,10 +411,108 @@ export const MENU_TOOL_DEFINITIONS = [ export const MENU_TOOL_NAMES = new Set(MENU_TOOL_DEFINITIONS.map((tool) => tool.name)); +export function getLegacyFooterMenuDeprecation(params: any) { + const rejectedFields = ["exploreLinks", "collectionLinks", "supportLinks"] + .filter((field) => params?.[field] !== undefined); + if (rejectedFields.length === 0) return null; + + return { + success: false, + code: "FOOTER_MENU_REPLACEMENT_DEPRECATED", + partialWrite: false, + rejectedFields, + message: "Footer menu arrays cannot be replaced through update_footer_content. Use get_footer_menu and the Footer-specific item tools. Website Collections are managed through Categories.", + }; +} + +function compactFooterResponse(data: any, operation: string, params: any) { + const source = data?.data ?? data ?? {}; + const compact: Record = { + success: source.success ?? true, + operation: source.operation ?? operation, + menuName: source.menuName ?? source.name ?? params.menuName, + }; + + for (const key of [ + "dryRun", + "version", + "lang", + "item", + "items", + "itemId", + "itemIds", + "itemCount", + "label", + "before", + "after", + "deletedItemId", + "translatedItemId", + "translatedPaths", + "changes", + "warnings", + "code", + "error", + "partialWrite", + ]) { + if (source[key] !== undefined) compact[key] = source[key]; + } + + if (compact.dryRun === undefined && params.dryRun !== undefined) { + compact.dryRun = Boolean(params.dryRun); + } + + return { content: [{ type: "text", text: JSON.stringify(compact, null, 2) }] }; +} + +function footerWriteBody(params: any, fields: string[]) { + const body: Record = {}; + for (const field of fields) { + if (params[field] !== undefined) body[field] = params[field]; + } + body.dryRun = Boolean(params.dryRun); + if (params.expectedVersion !== undefined) body.expectedVersion = params.expectedVersion; + return body; +} + export async function handleMenuTool(api: AxiosInstance, name: string, params: any) { if (!MENU_TOOL_NAMES.has(name)) return null; switch (name) { + case "get_footer_menu": { + const query = new URLSearchParams(); + query.set("lang", params.lang || "th"); + query.set("fallback", params.fallback === false ? "false" : "true"); + const res = await api.get(`/menus/footer/${params.menuName}?${query.toString()}`); + return compactFooterResponse(res.data, name, params); + } + case "add_footer_menu_item": { + const body = footerWriteBody(params, ["label", "url", "order", "translations"]); + const res = await api.post(`/menus/footer/${params.menuName}/items`, body); + return compactFooterResponse(res.data, name, params); + } + case "update_footer_menu_item": { + const body = footerWriteBody(params, ["label", "url", "order"]); + const res = await api.patch(`/menus/footer/${params.menuName}/items/${params.itemId}`, body); + return compactFooterResponse(res.data, name, params); + } + case "delete_footer_menu_item": { + const body = footerWriteBody(params, []); + const res = await api.delete(`/menus/footer/${params.menuName}/items/${params.itemId}`, { data: body }); + return compactFooterResponse(res.data, name, params); + } + case "reorder_footer_menu_items": { + const body = footerWriteBody(params, ["itemIds"]); + const res = await api.put(`/menus/footer/${params.menuName}/items/reorder`, body); + return compactFooterResponse(res.data, name, params); + } + case "update_footer_menu_item_translation": { + const body = footerWriteBody(params, ["label"]); + const res = await api.patch( + `/menus/footer/${params.menuName}/items/${params.itemId}/translations/${params.lang}`, + body, + ); + return compactFooterResponse(res.data, name, params); + } case "get_menu": { const res = await api.get(`/menus/${params.name}`); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };