x
This commit is contained in:
1 parent
94cbb52348
commit
167d07f0c2
7 files changed
+1284
-21
No files matched your search
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) }] };
|
||||||
|
|||||||
Reference in new issue
Block a user