This commit is contained in:
Your Name committed 2026-07-25 00:54:35 -04:00
1 parent 94cbb52348
commit 167d07f0c2
7 files changed
+1284 -21

No files matched your search

+14
View File
@@ -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. 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 ## What to Expect
- `PRODUCT_CAROUSEL` may return products even when you did not send them. - `PRODUCT_CAROUSEL` may return products even when you did not send them.
@@ -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,
});
});
});
+538
View File
@@ -2,6 +2,233 @@ import { Elysia, t } from "elysia";
import prisma from "../lib/prisma.js"; import prisma from "../lib/prisma.js";
import { adminOnly } from "../middleware/auth.js"; import { adminOnly } from "../middleware/auth.js";
export const FOOTER_MENU_NAMES = ["footer-explore", "footer-support"] as const;
export const MENU_LANGUAGES = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"] as const;
type FooterMenuName = typeof FOOTER_MENU_NAMES[number];
type MenuLanguage = typeof MENU_LANGUAGES[number];
type MenuTranslation = { langCode: string; label: string };
type VersionedMenuItem = {
id: number;
label: string;
url: string;
order: number;
columnImage: string | null;
hoverImage: string | null;
translations: MenuTranslation[];
};
type VersionedMenu = {
id: number;
name: string;
items: VersionedMenuItem[];
};
const footerMenuInclude = {
items: {
orderBy: [{ order: "asc" as const }, { id: "asc" as const }],
include: { translations: true },
},
};
const isFooterMenuName = (name: string): name is FooterMenuName =>
FOOTER_MENU_NAMES.includes(name as FooterMenuName);
const isMenuLanguage = (lang: string): lang is MenuLanguage =>
MENU_LANGUAGES.includes(lang as MenuLanguage);
const normalizeMenuForVersion = (menu: VersionedMenu) => ({
name: menu.name,
items: [...menu.items]
.sort((a, b) => a.order - b.order || a.id - b.id)
.map((item) => ({
id: item.id,
label: item.label,
url: item.url,
order: item.order,
columnImage: item.columnImage,
hoverImage: item.hoverImage,
translations: [...item.translations]
.sort((a, b) => a.langCode.localeCompare(b.langCode))
.map(({ langCode, label }) => ({ langCode, label })),
})),
});
export function fingerprintMenu(menu: VersionedMenu): string {
const serialized = JSON.stringify(normalizeMenuForVersion(menu));
return `sha256:${new Bun.CryptoHasher("sha256").update(serialized).digest("hex")}`;
}
export function resolveMenuItemLabel(
item: Pick<VersionedMenuItem, "label" | "translations">,
lang: MenuLanguage,
fallback = true,
) {
const translation = item.translations.find((entry) => entry.langCode === lang);
if (translation) return { label: translation.label, usedFallback: false };
return { label: fallback ? item.label : null, usedFallback: true };
}
export function isValidMenuUrl(url: string): boolean {
if (!url || /\s/.test(url) || /%0a|%0d/i.test(url)) return false;
if (url.startsWith("/") && !url.startsWith("//")) return true;
try {
const parsed = new URL(url);
if (parsed.protocol === "https:" || parsed.protocol === "http:") return true;
if (parsed.protocol === "mailto:") {
return /^[^@/?#]+@[^@/?#]+$/.test(parsed.pathname);
}
if (parsed.protocol === "tel:") {
return /^\+?[0-9().-]+$/.test(parsed.pathname);
}
return false;
} catch {
return false;
}
}
export function createMenuTranslations(translations: Record<string, string> = {}) {
const entries = Object.entries(translations);
const invalidLocale = entries.find(([lang]) => !isMenuLanguage(lang));
if (invalidLocale) {
return { error: `Unsupported locale: ${invalidLocale[0]}`, translations: [] };
}
return {
translations: entries.map(([langCode, label]) => ({ langCode, label })),
};
}
const errorResponse = (set: { status?: number | string }, status: number, code: string, error: string) => {
set.status = status;
return { success: false, code, error, partialWrite: false };
};
const getFooterMenu = async (
name: FooterMenuName,
db: any = prisma,
): Promise<VersionedMenu | null> =>
db.menu.findUnique({
where: { name },
include: footerMenuInclude,
});
export const lockMenuForTransaction = async (tx: any, menuId: number) => {
await tx.$queryRaw`SELECT pg_advisory_xact_lock(${menuId})`;
};
export async function executeVersionedMenuWrite<T>(
menu: VersionedMenu,
expectedVersion: unknown,
write: () => Promise<T>,
) {
const currentVersion = fingerprintMenu(menu);
if (typeof expectedVersion === "string" && expectedVersion !== currentVersion) {
return {
written: false as const,
currentVersion,
};
}
return {
written: true as const,
currentVersion,
value: await write(),
};
}
const checkVersion = (
menu: VersionedMenu,
expectedVersion: unknown,
set: { status?: number | string },
) => {
const currentVersion = fingerprintMenu(menu);
if (typeof expectedVersion === "string" && expectedVersion !== currentVersion) {
return {
conflict: errorResponse(
set,
409,
"MENU_VERSION_CONFLICT",
"Menu changed after it was read. Fetch the menu again before writing.",
),
currentVersion,
};
}
return { currentVersion };
};
const runMenuTransaction = async <T>(
name: FooterMenuName,
expectedVersion: unknown,
write: (tx: any, menu: VersionedMenu) => Promise<T>,
) =>
prisma.$transaction(async (tx: any) => {
const identity = await tx.menu.findUnique({
where: { name },
select: { id: true },
});
if (!identity) return { missing: true as const };
await lockMenuForTransaction(tx, identity.id);
const menu = await getFooterMenu(name, tx);
if (!menu) return { missing: true as const };
return executeVersionedMenuWrite(menu, expectedVersion, async () => {
const value = await write(tx, menu);
const updated = await getFooterMenu(name, tx);
if (!updated) throw new Error(`Menu ${name} disappeared during transaction.`);
return { value, version: fingerprintMenu(updated) };
});
});
const unwrapTransaction = (
result: Awaited<ReturnType<typeof runMenuTransaction>>,
name: FooterMenuName,
set: { status?: number | string },
) => {
if ("missing" in result) {
return { error: errorResponse(set, 404, "MENU_NOT_FOUND", `Menu ${name} was not found.`) };
}
if (!result.written) {
return {
error: errorResponse(
set,
409,
"MENU_VERSION_CONFLICT",
"Menu changed after it was read. Fetch the menu again before writing.",
),
};
}
return { value: result.value };
};
const compactMenu = (
menu: VersionedMenu,
lang: MenuLanguage | "all",
fallback: boolean,
) => ({
name: menu.name,
lang,
version: fingerprintMenu(menu),
items: menu.items.map((item) => {
const base = {
id: item.id,
url: item.url,
order: item.order,
columnImage: item.columnImage,
hoverImage: item.hoverImage,
};
if (lang === "all") {
return {
...base,
label: item.label,
translations: Object.fromEntries(
[...item.translations]
.sort((a, b) => a.langCode.localeCompare(b.langCode))
.map((translation) => [translation.langCode, translation.label]),
),
};
}
return { ...base, ...resolveMenuItemLabel(item, lang, fallback) };
}),
});
export const menusRoute = new Elysia({ prefix: "/menus" }) export const menusRoute = new Elysia({ prefix: "/menus" })
.get("/", async () => { .get("/", async () => {
return await prisma.menu.findMany({ 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 }) => { .get("/:name", async ({ params: { name }, set }) => {
const menu = await prisma.menu.findUnique({ const menu = await prisma.menu.findUnique({
where: { name }, where: { name },
@@ -29,6 +270,302 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
return menu; return menu;
}) })
.use(adminOnly) .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 }) => { .post("/", async ({ body, set }: { body: any; set: any }) => {
try { try {
const { name, rootImage, items } = body as { const { name, rootImage, items } = body as {
@@ -104,6 +641,7 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
} }
await prisma.$transaction(async (tx: any) => { await prisma.$transaction(async (tx: any) => {
await lockMenuForTransaction(tx, menu!.id);
const existingItems = await tx.menuItem.findMany({ where: { menuId: menu!.id } }); const existingItems = await tx.menuItem.findMany({ where: { menuId: menu!.id } });
const existingItemIds = existingItems.map((i: any) => i.id); const existingItemIds = existingItems.map((i: any) => i.id);
const payloadItemIds = items.map(i => i.id).filter(id => id != null); const payloadItemIds = items.map(i => i.id).filter(id => id != null);
+31 -1
View File
@@ -32,7 +32,37 @@ bun run dev
- `update_brand_name` เปลี่ยนชื่อแบรนด์กลางที่ใช้ใน header/footer ของ `apps/spa` และ `apps/website` - `update_brand_name` เปลี่ยนชื่อแบรนด์กลางที่ใช้ใน header/footer ของ `apps/spa` และ `apps/website`
- `update_store_settings_translation` อัปเดตข้อความแปลของ store settings - `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 ## Mega Menu LLM Workflow
+16 -20
View File
@@ -6,7 +6,7 @@ import {
} from "@modelcontextprotocol/sdk/types.js"; } from "@modelcontextprotocol/sdk/types.js";
import axios from "axios"; import axios from "axios";
import FormData from "form-data"; 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"; import { SECTION_TOOL_DEFINITIONS, buildCmsPreview, handleSectionTool } from "./section-toolkit";
const LOCAL_BACKEND_URL = process.env.MCP_BACKEND_URL_LOCAL || "http://localhost:3051/api"; 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"; 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", 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: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@@ -965,7 +965,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
}, },
exploreLinks: { exploreLinks: {
type: "array", 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: { items: {
type: "object", type: "object",
properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } } properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } }
@@ -973,7 +973,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
}, },
collectionLinks: { collectionLinks: {
type: "array", 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: { items: {
type: "object", type: "object",
properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } } properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } }
@@ -981,7 +981,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
}, },
supportLinks: { supportLinks: {
type: "array", 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: { items: {
type: "object", type: "object",
properties: { label: { type: "string" }, url: { type: "string" }, order: { type: "number" } } 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) }] }; return { content: [{ type: "text", text: JSON.stringify(normalized, null, 2) }] };
} }
case "update_footer_content": { case "update_footer_content": {
const deprecation = getLegacyFooterMenuDeprecation(params);
if (deprecation) {
return {
content: [{
type: "text",
text: JSON.stringify(deprecation, null, 2),
}],
isError: true,
};
}
const results = []; const results = [];
if (params.brandDescription !== undefined || params.socialLinks) { 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) }] }; return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
} }
default: default:
+288
View File
@@ -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();
});
});
+228
View File
@@ -25,6 +25,23 @@ const localizedTextSchema = {
additionalProperties: false, additionalProperties: false,
} as const; } 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 = { const menuLinkNodeSchema = {
type: "object", 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.", 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; } as const;
export const MENU_TOOL_DEFINITIONS = [ 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", name: "get_menu",
description: "Fetch a specific menu and its items (including translations)", 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<string>(MENU_TOOL_DEFINITIONS.map((tool) => tool.name)); export const MENU_TOOL_NAMES = new Set<string>(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<string, unknown> = {
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<string, unknown> = {};
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) { export async function handleMenuTool(api: AxiosInstance, name: string, params: any) {
if (!MENU_TOOL_NAMES.has(name)) return null; if (!MENU_TOOL_NAMES.has(name)) return null;
switch (name) { 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": { case "get_menu": {
const res = await api.get(`/menus/${params.name}`); const res = await api.get(`/menus/${params.name}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };