diff --git a/apps/backend/prisma/migrations/20260723083445_add_category_soft_delete/migration.sql b/apps/backend/prisma/migrations/20260723083445_add_category_soft_delete/migration.sql new file mode 100644 index 0000000..4dd4f81 --- /dev/null +++ b/apps/backend/prisma/migrations/20260723083445_add_category_soft_delete/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Category" ADD COLUMN "deletedAt" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX "Category_deletedAt_idx" ON "Category"("deletedAt"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index e0487f5..2b82ed6 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -37,8 +37,11 @@ model Category { products Product[] attributes AttributeDefinition[] translations CategoryTranslation[] + deletedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@index([deletedAt]) } model CategoryTranslation { diff --git a/apps/backend/src/routes/categories.ts b/apps/backend/src/routes/categories.ts index 077c2c8..412cc9c 100644 --- a/apps/backend/src/routes/categories.ts +++ b/apps/backend/src/routes/categories.ts @@ -20,6 +20,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) .get("/", async ({ query }) => { const lang = (query as any).lang || 'th'; const categories = await prisma.category.findMany({ + where: { deletedAt: null }, orderBy: { name: "asc" }, include: { translations: true, @@ -45,7 +46,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) const [category, total] = await Promise.all([ prisma.category.findUnique({ - where: { id }, + where: { id, deletedAt: null }, include: { translations: true, products: { @@ -118,7 +119,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) if (isNaN(id)) return status(400, { message: "Invalid category ID" }); try { - const categoryExists = await prisma.category.findUnique({ where: { id } }); + const categoryExists = await prisma.category.findFirst({ where: { id, deletedAt: null } }); if (!categoryExists) return status(404, { message: "Category not found" }); const payload = body as any; @@ -170,7 +171,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) try { const category = await prisma.category.update({ - where: { id }, + where: { id, deletedAt: null }, data: { imageUrl: body.imageUrl, }, @@ -217,7 +218,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) try { const category = await prisma.category.update({ - where: { id }, + where: { id, deletedAt: null }, data: { name: body.name, imageUrl: body.imageUrl, @@ -246,7 +247,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) try { const category = await prisma.category.findUnique({ - where: { id }, + where: { id, deletedAt: null }, include: { _count: { select: { products: true } } } }); @@ -255,8 +256,12 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" }) return status(400, { message: "Cannot delete category with existing products" }); } - await prisma.category.delete({ where: { id } }); - return { success: true }; + const deletedAt = new Date(); + await prisma.category.update({ + where: { id }, + data: { deletedAt }, + }); + return { success: true, softDeleted: true, id, deletedAt }; } catch (err: any) { return status(500, { message: "Failed to delete category" }); } diff --git a/apps/backend/src/routes/pages.ts b/apps/backend/src/routes/pages.ts index b67f36d..73abe39 100644 --- a/apps/backend/src/routes/pages.ts +++ b/apps/backend/src/routes/pages.ts @@ -734,7 +734,7 @@ export async function hydrateSectionPayload(section: any, lang: string) { if (Array.isArray(payload.categoryIds) && payload.categoryIds.length > 0) { const ids = payload.categoryIds.map(Number); categories = await prisma.category.findMany({ - where: { id: { in: ids } }, + where: { id: { in: ids }, deletedAt: null }, include: { translations: true, _count: { @@ -745,6 +745,7 @@ export async function hydrateSectionPayload(section: any, lang: string) { categories.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); } else { categories = await prisma.category.findMany({ + where: { deletedAt: null }, include: { translations: true, _count: { diff --git a/apps/backend/src/routes/products.ts b/apps/backend/src/routes/products.ts index bdcd676..a64705f 100644 --- a/apps/backend/src/routes/products.ts +++ b/apps/backend/src/routes/products.ts @@ -21,6 +21,18 @@ function defaultProductDetailSections(productId: number) { ]; } +async function assertActiveCategory(categoryId: number, client: any = prisma) { + const category = await client.category.findFirst({ + where: { id: categoryId, deletedAt: null }, + select: { id: true }, + }); + if (!category) { + const error = new Error("Category not found"); + (error as any).code = "CATEGORY_NOT_FOUND"; + throw error; + } +} + export const productsRoute = new Elysia({ prefix: "/products" }) .decorate("validateVariants", (options: any[] = [], variants: any[] = []) => { if (options.length > 3) throw new Error("Options array length cannot exceed 3."); @@ -522,7 +534,10 @@ export const productsRoute = new Elysia({ prefix: "/products" }) const updatedProduct = await prisma.$transaction(async (tx) => { // 1. Update basic product info const updateData: any = {}; - if (categoryId !== undefined) updateData.categoryId = categoryId; + if (categoryId !== undefined) { + await assertActiveCategory(categoryId, tx); + updateData.categoryId = categoryId; + } if (body.options !== undefined) updateData.options = body.options; const prod = await tx.product.update({ @@ -632,6 +647,9 @@ export const productsRoute = new Elysia({ prefix: "/products" }) return { success: true, product: updatedProduct }; } catch (err: any) { + if (err?.code === "CATEGORY_NOT_FOUND") { + return status(400, { success: false, message: "Category not found" }); + } console.error("Update product error:", err); return status(500, { success: false, message: err?.message || "Failed to update product" }); } @@ -690,12 +708,15 @@ export const productsRoute = new Elysia({ prefix: "/products" }) const product = await prisma.$transaction(async (tx) => { let categoryId = payload.categoryId; if (!categoryId && payload.categoryName) { - let cat = await tx.category.findUnique({ where: { name: payload.categoryName } }); + let cat = await tx.category.findFirst({ + where: { name: payload.categoryName, deletedAt: null }, + }); if (!cat) { cat = await tx.category.create({ data: { name: payload.categoryName } }); } categoryId = cat.id; } + if (categoryId) await assertActiveCategory(categoryId, tx); let brandId = payload.brandId; if (!brandId && payload.brandName) { @@ -775,6 +796,9 @@ export const productsRoute = new Elysia({ prefix: "/products" }) return { success: true, product }; } catch (err: any) { + if (err?.code === "CATEGORY_NOT_FOUND") { + return status(400, { success: false, message: "Category not found" }); + } console.error("Create product error:", err); return status(500, { success: false, message: err?.message || "Failed to create product" }); } @@ -806,12 +830,15 @@ export const productsRoute = new Elysia({ prefix: "/products" }) let categoryId = payload.categoryId; if (!categoryId && payload.categoryName) { - let cat = await tx.category.findUnique({ where: { name: payload.categoryName } }); + let cat = await tx.category.findFirst({ + where: { name: payload.categoryName, deletedAt: null }, + }); if (!cat) { cat = await tx.category.create({ data: { name: payload.categoryName } }); } categoryId = cat.id; } + if (categoryId) await assertActiveCategory(categoryId, tx); let brandId = payload.brandId; if (!brandId && payload.brandName) { @@ -892,6 +919,9 @@ export const productsRoute = new Elysia({ prefix: "/products" }) return { success: true, products: createdProducts }; } catch (err: any) { + if (err?.code === "CATEGORY_NOT_FOUND") { + return status(400, { success: false, message: "Category not found" }); + } console.error("Bulk create products error:", err); return status(500, { success: false, message: err?.message || "Failed to bulk create products" }); } diff --git a/apps/mcp-server/README.md b/apps/mcp-server/README.md index 9d92c1d..ecdd183 100644 --- a/apps/mcp-server/README.md +++ b/apps/mcp-server/README.md @@ -34,6 +34,86 @@ bun run dev - `update_store_settings_translation` อัปเดตข้อความแปลของ store settings - `update_footer_content` อัปเดตเนื้อหา footer และลิงก์ที่เกี่ยวข้อง +## Mega Menu LLM Workflow + +Header Mega Menu uses the `main` menu payload tree. The website renders this structure: + +```text +Root -> Column -> Link -> Child Link +``` + +Supported localized text keys are: + +```text +th, en, ja, zh, vi, ko, lo, it +``` + +LLM agents should use this workflow for every menu edit: + +1. `capture_menu_snapshot` for `main` +2. `get_mega_menu_tree` +3. `validate_mega_menu_tree` +4. Plan operations from diagnostics and current `nodeId` values +5. `bulk_menu_operations` with `dryRun: true` +6. Review `preview.diff`, `preview.before`, `preview.after`, `issues`, and `warnings` +7. Apply only after approval +8. Run `validate_mega_menu_tree` again +9. Use `restore_menu_snapshot` if the applied result is wrong + +Do not add a Link directly under root. Add a Column under Root first, then add Links under that Column. + +Dry-run responses are expected to include path-based diffs such as: + +```json +{ + "path": "payload[0].columns[0].links[0].label.it", + "before": null, + "after": "Candele" +} +``` + +`validate_mega_menu_tree` reports: + +- missing locale keys, especially `lo` and `it` +- unsupported translation keys +- duplicate or missing `nodeId` +- invalid URL shape +- placeholder text +- empty columns +- featured card issues +- desktop render limits such as hidden columns or links + +## Category Management LLM Workflow + +Product categories are managed by backend `/categories` APIs through MCP. Supported localized text keys are: + +```text +th, en, ja, zh, vi, ko, lo, it +``` + +LLM agents should use this workflow for category edits: + +1. `list_categories` with `lang: "all"` to discover category IDs and current translations +2. `create_category`, `update_category`, `update_category_translations`, or `delete_category` with `dryRun: true` +3. Review the preview and product counts +4. Apply only after the intended change is clear +5. Run `list_categories` again to verify + +Backend category support currently covers: + +- base `name` +- `imageUrl` +- localized names +- product-count guarded soft delete (`deletedAt`) + +Backend category support does not currently cover: + +- slug +- nested parent/child hierarchy +- sort order +- active/inactive state +- restore/undelete workflow + ## Scripts - `bun run dev` diff --git a/apps/mcp-server/index.ts b/apps/mcp-server/index.ts index 48a5bb9..4fcf521 100644 --- a/apps/mcp-server/index.ts +++ b/apps/mcp-server/index.ts @@ -14,6 +14,7 @@ const PRODUCTION_BACKEND_URL = process.env.MCP_BACKEND_URL_PRODUCTION || "https: let BACKEND_URL = process.env.MCP_BACKEND_URL || LOCAL_BACKEND_URL; const ADMIN_USERNAME = process.env.MCP_ADMIN_USERNAME || process.env.ADMIN_USERNAME || "admin"; const ADMIN_PASSWORD = process.env.MCP_ADMIN_PASSWORD || process.env.ADMIN_PASSWORD || "homaroma123"; +const CATEGORY_LOCALES = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"] as const; let authToken = ""; @@ -49,6 +50,37 @@ async function performLogin() { } } +function validateCategoryLocaleMap(translations: any) { + if (!translations || typeof translations !== "object" || Array.isArray(translations)) { + throw new Error("translations must be an object keyed by locale codes."); + } + + const entries = Object.entries(translations); + if (entries.length === 0) { + throw new Error("translations must include at least one locale."); + } + + for (const [locale, value] of entries) { + if (!CATEGORY_LOCALES.includes(locale as any)) { + throw new Error(`Unsupported locale "${locale}". Supported locales: ${CATEGORY_LOCALES.join(", ")}`); + } + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`translations.${locale} must be a non-empty string.`); + } + } +} + +function compactCategory(category: any) { + if (!category) return null; + return { + id: category.id, + name: category.name, + imageUrl: category.imageUrl ?? null, + productCount: category._count?.products ?? category.productCount ?? undefined, + translations: category.translations ?? undefined, + }; +} + const server = new Server( { name: "homaroma-mcp", @@ -96,8 +128,74 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { }, { name: "list_categories", - description: "List all product categories with their translations", - inputSchema: { type: "object", properties: {} } + description: "List all product categories with translations and product counts. Use lang=all when you need every localized name.", + inputSchema: { + type: "object", + properties: { + lang: { type: "string", description: "Language code: th, en, ja, zh, vi, ko, lo, it, or all" } + } + } + }, + { + name: "get_category_products", + description: "Fetch one category plus its products. Use this before delete_category to see whether products block deletion.", + inputSchema: { + type: "object", + properties: { + id: { type: "number", description: "Category ID" }, + lang: { type: "string", description: "Language code: th, en, ja, zh, vi, ko, lo, it, or all" }, + page: { type: "number", description: "Page number, default 1" }, + limit: { type: "number", description: "Products per page, default 12" } + }, + required: ["id"] + } + }, + { + name: "create_category", + description: "Create a product category. Backend supports base name, imageUrl, and translations. It does not support slug, hierarchy, sort order, or active state for categories.", + inputSchema: { + type: "object", + properties: { + name: { type: "string", description: "Base category name. Use Thai or the primary display name." }, + imageUrl: { type: ["string", "null"], description: "Optional image URL." }, + translations: { + type: "object", + description: "Optional locale map for category names. Supported locales: th, en, ja, zh, vi, ko, lo, it." + }, + dryRun: { type: "boolean", description: "Preview only. No category is created." } + }, + required: ["name"] + } + }, + { + name: "update_category", + description: "Update category base name, image URL, and/or translations. Use dryRun first. Backend supports name and imageUrl only for base category fields.", + inputSchema: { + type: "object", + properties: { + id: { type: "number", description: "Category ID" }, + name: { type: "string", description: "Optional base category name." }, + imageUrl: { type: ["string", "null"], description: "Optional image URL. Pass null to remove." }, + translations: { + type: "object", + description: "Optional locale map for category names. Supported locales: th, en, ja, zh, vi, ko, lo, it." + }, + dryRun: { type: "boolean", description: "Preview only. No category is updated." } + }, + required: ["id"] + } + }, + { + name: "delete_category", + description: "Soft-delete a category by setting deletedAt. Backend refuses deletion when products still reference the category. Use dryRun first to check product count.", + inputSchema: { + type: "object", + properties: { + id: { type: "number", description: "Category ID" }, + dryRun: { type: "boolean", description: "Preview only. No category is deleted." } + }, + required: ["id"] + } }, { name: "search_products", @@ -131,12 +229,28 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { type: "object", properties: { id: { type: "number", description: "Category ID" }, - lang: { type: "string", description: "Language code (th, en, ja, zh, vi, ko)" }, + lang: { type: "string", enum: ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"], description: "Language code" }, name: { type: "string", description: "Translated category name" } }, required: ["id", "lang", "name"] } }, + { + name: "update_category_translations", + description: "Add or update multiple category translations in one call. Supported locales: th, en, ja, zh, vi, ko, lo, it.", + inputSchema: { + type: "object", + properties: { + id: { type: "number", description: "Category ID" }, + translations: { + type: "object", + description: "Locale map, for example { \"th\": \"ชุดโซ่สเตอร์\", \"en\": \"Chain & Sprocket Kits\" }" + }, + dryRun: { type: "boolean", description: "Preview only. No translations are updated." } + }, + required: ["id", "translations"] + } + }, { name: "update_category_image", description: "Update the image URL for a specific category.", @@ -855,9 +969,161 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } case "list_categories": { - const res = await api.get(`/categories`); + const lang = params.lang ? `?lang=${encodeURIComponent(String(params.lang))}` : ""; + const res = await api.get(`/categories${lang}`); return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } + case "get_category_products": { + const { id } = params as any; + const query = new URLSearchParams(); + if (params.lang) query.set("lang", String(params.lang)); + if (params.page) query.set("page", String(params.page)); + if (params.limit) query.set("limit", String(params.limit)); + const suffix = query.toString() ? `?${query.toString()}` : ""; + const res = await api.get(`/categories/${id}/products${suffix}`); + return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; + } + case "create_category": { + const { name, imageUrl = null, translations, dryRun } = params as any; + if (typeof name !== "string" || name.trim().length === 0) { + throw new Error("name must be a non-empty string."); + } + if (translations !== undefined) validateCategoryLocaleMap(translations); + + const payload = { name: name.trim(), imageUrl }; + if (dryRun) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + dryRun: true, + operation: "create_category", + preview: { + create: payload, + translations: translations ?? null, + backendSupport: { + fields: ["name", "imageUrl", "translations"], + unsupportedFields: ["slug", "parentId", "sortOrder", "isActive"], + }, + }, + }, null, 2) + }] + }; + } + + const created = await api.post(`/categories`, payload); + let translationResult: any = null; + if (translations) { + const mergedTranslations = { th: name.trim(), ...translations }; + const translationRes = await api.patch(`/categories/${created.data.id}/translations`, { name: mergedTranslations }); + translationResult = translationRes.data; + } + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + operation: "create_category", + category: translationResult?.category ?? created.data, + translations: translationResult?.translations ?? [], + }, null, 2) + }] + }; + } + case "update_category": { + const { id, name, imageUrl, translations, dryRun } = params as any; + if (name !== undefined && (typeof name !== "string" || name.trim().length === 0)) { + throw new Error("name must be a non-empty string when provided."); + } + if (translations !== undefined) validateCategoryLocaleMap(translations); + + const beforeList = await api.get(`/categories?lang=all`); + const before = Array.isArray(beforeList.data) + ? beforeList.data.find((cat: any) => cat.id === id) + : null; + if (!before) { + throw new Error(`Category with id ${id} not found.`); + } + + const baseUpdate: any = {}; + if (name !== undefined) baseUpdate.name = name.trim(); + if (imageUrl !== undefined) baseUpdate.imageUrl = imageUrl; + + if (dryRun) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + dryRun: true, + operation: "update_category", + categoryId: id, + preview: { + before: compactCategory(before), + baseUpdate, + translations: translations ?? null, + backendSupport: { + fields: ["name", "imageUrl", "translations"], + unsupportedFields: ["slug", "parentId", "sortOrder", "isActive"], + }, + }, + }, null, 2) + }] + }; + } + + let updated: any = before; + if (Object.keys(baseUpdate).length > 0) { + const updateRes = await api.put(`/categories/${id}`, baseUpdate); + updated = updateRes.data; + } + let translationResult: any = null; + if (translations) { + const translationRes = await api.patch(`/categories/${id}/translations`, { name: translations }); + translationResult = translationRes.data; + updated = translationResult?.category ?? updated; + } + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + operation: "update_category", + category: updated, + translations: translationResult?.translations ?? [], + }, null, 2) + }] + }; + } + case "delete_category": { + const { id, dryRun } = params as any; + const details = await api.get(`/categories/${id}/products?lang=all&limit=1`); + const productCount = details.data?.category?._count?.products ?? details.data?.meta?.total ?? 0; + const category = details.data?.category ?? { id }; + + if (dryRun) { + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: productCount === 0, + dryRun: true, + operation: "delete_category", + category, + productCount, + blocked: productCount > 0, + message: productCount > 0 + ? "Backend will refuse soft deletion because this category has products." + : "Category can be soft-deleted. The record will be hidden from category lists but kept in the database.", + }, null, 2) + }] + }; + } + + const res = await api.delete(`/categories/${id}`); + return { content: [{ type: "text", text: JSON.stringify({ ...res.data, operation: "soft_delete_category", categoryId: id }, null, 2) }] }; + } case "search_products": { const search = params.search ? `&search=${encodeURIComponent(params.search)}` : ""; const limit = params.limit ? `&limit=${params.limit}` : "&limit=10"; @@ -889,6 +1155,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } case "update_category_translation": { const { id, lang, name } = params as any; + validateCategoryLocaleMap({ [lang]: name }); const res = await api.patch(`/categories/${id}/translations`, { name: { [lang]: name } }); @@ -896,6 +1163,43 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] }; } + case "update_category_translations": { + const { id, translations, dryRun } = params as any; + validateCategoryLocaleMap(translations); + + if (dryRun) { + const beforeList = await api.get(`/categories?lang=all`); + const before = Array.isArray(beforeList.data) + ? beforeList.data.find((cat: any) => cat.id === id) + : null; + if (!before) { + throw new Error(`Category with id ${id} not found.`); + } + return { + content: [{ + type: "text", + text: JSON.stringify({ + success: true, + dryRun: true, + operation: "update_category_translations", + categoryId: id, + preview: { + before: compactCategory(before), + translations, + changedLocales: Object.keys(translations), + }, + }, null, 2) + }] + }; + } + + const res = await api.patch(`/categories/${id}/translations`, { + name: translations + }); + return { + content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] + }; + } case "update_category_image": { const { id, imageUrl } = params as any; const res = await api.patch(`/categories/${id}/image`, { diff --git a/apps/mcp-server/menu-toolkit.ts b/apps/mcp-server/menu-toolkit.ts index 4148016..55b58a7 100644 --- a/apps/mcp-server/menu-toolkit.ts +++ b/apps/mcp-server/menu-toolkit.ts @@ -5,19 +5,88 @@ import { applyMenuMoveNode, applyMenuRemoveNode, applyMenuUpdateTranslation, + buildMenuMutationPreview, collectMenuLinksFromTree, createMenuSnapshot, - describeMenuNode, normalizeMenuPayloadForWrite, restoreMenuSnapshot, validateMenuItems, validateMenuNodeData, validateMenuTranslations, validateMenuTreeSnapshot, - cloneMenuValue, isPlainObject, + SUPPORTED_MENU_LOCALES, } from "./menu-tools"; +const localizedTextSchema = { + type: "object", + description: `Localized text. Allowed locale keys: ${SUPPORTED_MENU_LOCALES.join(", ")}. Provide at least th or en; include lo and it when creating production-ready header menu content.`, + properties: Object.fromEntries(SUPPORTED_MENU_LOCALES.map((locale) => [locale, { type: "string" }])), + additionalProperties: false, +} as const; + +const menuLinkNodeSchema = { + type: "object", + description: "Mega Menu Link node. Valid under a Column or another Link. Renders as a clickable link; child links are shown inline up to the desktop limit.", + properties: { + nodeId: { type: "string", description: "Existing node UUID. Omit for new nodes; MCP will generate one." }, + label: localizedTextSchema, + url: { type: "string", description: "Internal path starting with /, or http(s)://, mailto:, tel:" }, + icon: { type: "string", description: "Optional icon registry key" }, + imageUrl: { type: "string", description: "Optional hover/preview image URL" }, + links: { type: "array", description: "Optional child links", items: { type: "object" } }, + }, + required: ["label", "url"], + additionalProperties: true, +} as const; + +const menuColumnNodeSchema = { + type: "object", + description: "Mega Menu Column node. Valid only under Root. Contains links; it is not directly clickable.", + properties: { + nodeId: { type: "string", description: "Existing node UUID. Omit for new nodes; MCP will generate one." }, + title: localizedTextSchema, + icon: { type: "string", description: "Optional icon registry key" }, + links: { type: "array", items: menuLinkNodeSchema }, + }, + required: ["title", "links"], + additionalProperties: true, +} as const; + +const rootMenuNodeSchema = { + type: "object", + description: "Mega Menu Root node. Valid only under root. Desktop renders root labels in the header; columns open in the mega panel on hover.", + properties: { + nodeId: { type: "string", description: "Existing node UUID. Omit for new nodes; MCP will generate one." }, + label: localizedTextSchema, + url: { type: "string", description: "Internal path starting with /, or http(s)://, mailto:, tel:" }, + rootImageUrl: { type: "string", description: "Optional fallback image for featured preview" }, + columns: { type: "array", items: menuColumnNodeSchema }, + featured: { + type: "object", + description: "Optional desktop featured card. If enabled, provide meaningful localized text and imageUrl/url where possible.", + properties: { + enabled: { type: "boolean" }, + sectionTitle: localizedTextSchema, + badge: localizedTextSchema, + title: localizedTextSchema, + subtitle: localizedTextSchema, + ctaLabel: localizedTextSchema, + price: { type: "string" }, + imageUrl: { type: "string" }, + url: { type: "string" }, + focalPoint: { + type: "object", + properties: { x: { type: "string" }, y: { type: "string" } }, + }, + }, + additionalProperties: true, + }, + }, + required: ["label", "url", "columns"], + additionalProperties: true, +} as const; + export const MENU_TOOL_DEFINITIONS = [ { name: "get_menu", @@ -82,9 +151,19 @@ export const MENU_TOOL_DEFINITIONS = [ }, { name: "get_mega_menu_tree", - description: "Read Mega Menu (main) payload, auto-generate nodeId for all nodes if missing, and return the tree.", + description: `Read Mega Menu (main) payload, auto-generate nodeId for all structural nodes if missing, and return the tree. Supported locales: ${SUPPORTED_MENU_LOCALES.join(", ")}.`, inputSchema: { type: "object", properties: {} } }, + { + name: "validate_mega_menu_tree", + description: "Validate the current Mega Menu (main) tree for LLM-safe editing. Reports missing locales, invalid node shape, duplicate nodeId, unsupported translation keys, placeholder text, URL issues, and desktop render limits.", + inputSchema: { + type: "object", + properties: { + includeTree: { type: "boolean", description: "Include the normalized menu tree in the response. Default false." } + } + } + }, { name: "capture_menu_snapshot", description: "Capture the current state of a menu so it can be restored later.", @@ -110,7 +189,7 @@ export const MENU_TOOL_DEFINITIONS = [ }, { name: "bulk_menu_operations", - description: "Apply multiple menu tree operations in a single transaction. Supports add_node, update_translation, move_node, and remove_node.", + description: `Apply multiple Mega Menu operations in a single transaction. Workflow: capture_menu_snapshot -> validate_mega_menu_tree -> bulk_menu_operations with dryRun true -> user approval -> apply. Supports add_node, update_translation, move_node, and remove_node. Supported locales: ${SUPPORTED_MENU_LOCALES.join(", ")}.`, inputSchema: { type: "object", properties: { @@ -127,8 +206,11 @@ export const MENU_TOOL_DEFINITIONS = [ nodeId: { type: "string", description: "Target node ID for update/move/remove" }, newParentId: { type: "string", description: "New parent node ID for move_node" }, orderIndex: { type: "number", description: "Insertion index for move_node" }, - translations: { type: "object", description: "Translation map for update_translation" }, - nodeData: { type: "object", description: "Node data for add_node" } + translations: { ...localizedTextSchema, description: "Translation map for update_translation. Only locale keys are accepted." }, + nodeData: { + oneOf: [rootMenuNodeSchema, menuColumnNodeSchema, menuLinkNodeSchema], + description: "Node data for add_node. Shape depends on parent: parentId=root => Root node; parent is Root => Column node; parent is Column/Link => Link node." + } }, required: ["type"] } @@ -139,28 +221,31 @@ export const MENU_TOOL_DEFINITIONS = [ }, { name: "add_menu_node", - description: "Add a new node to the Mega Menu tree. CRITICAL RULE: Mega Menu structure is Root -> Column -> Link. Do NOT add a Link directly under Root. If you want to add a Link, you MUST check if a Column exists first. If no Column exists, create a Column node first, then add the Link under that Column.", + description: `Add a new node to the Mega Menu tree. Structure is Root -> Column -> Link -> Child Link. Do not add a Link directly under root. Use dryRun first. Supported locales: ${SUPPORTED_MENU_LOCALES.join(", ")}.`, inputSchema: { type: "object", properties: { parentId: { type: "string", description: "UUID of parent node, or 'root'" }, dryRun: { type: "boolean", description: "Preview the mutation without saving it" }, - nodeData: { type: "object", description: "Node data including label, url, translations, etc." } + nodeData: { + oneOf: [rootMenuNodeSchema, menuColumnNodeSchema, menuLinkNodeSchema], + description: "Node data. If parentId is root/omitted, provide a Root node with label, url, and columns. If parent is a Root node, provide a Column node with title and links. If parent is a Column/Link, provide a Link node with label and url." + } }, required: ["nodeData"] } }, { name: "update_menu_translation", - description: "Update 6-language translations for a specific node in the Mega Menu", + description: `Update localized text for a specific node in the Mega Menu. Accepts only supported locale keys: ${SUPPORTED_MENU_LOCALES.join(", ")}. This updates label for root/link nodes or title for column nodes.`, inputSchema: { type: "object", properties: { nodeId: { type: "string", description: "UUID of the node" }, dryRun: { type: "boolean", description: "Preview the mutation without saving it" }, translations: { - type: "object", - description: "Object containing language codes as keys (th, en, etc.) and translated strings as values." + ...localizedTextSchema, + description: "Object containing locale codes as keys and translated strings as values. Unsupported keys are rejected." } }, required: ["nodeId", "translations"] @@ -359,6 +444,39 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a } return { content: [{ type: "text", text: JSON.stringify(normalized.payload, null, 2) }] }; } + case "validate_mega_menu_tree": { + const res = await api.get(`/menus/main`); + const menu = res.data; + const normalized = normalizeMenuPayloadForWrite(menu.payload); + if (normalized.modified) { + await api.put(`/menus/main`, { payload: normalized.payload, items: menu.items || [] }); + } + const validation = validateMenuTreeSnapshot(normalized.payload); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + success: validation.issues.length === 0, + operation: "validate_mega_menu_tree", + menuName: "main", + supportedLocales: validation.supportedLocales, + summary: validation.summary, + issueCount: validation.issues.length, + warningCount: validation.warnings.length, + issues: validation.issues, + warnings: validation.warnings, + diagnostics: validation.diagnostics, + tree: params.includeTree ? normalized.payload : undefined, + }, + null, + 2 + ) + } + ] + }; + } case "capture_menu_snapshot": { const res = await api.get(`/menus/${params.name}`); const snapshot = createMenuSnapshot(res.data); @@ -413,7 +531,9 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a const res = await api.get(`/menus/${params.name}`); const menu = res.data; - const result = applyBulkMenuOperations(Array.isArray(menu.payload) ? menu.payload : [], params.operations); + const beforePayload = Array.isArray(menu.payload) ? menu.payload : []; + const result = applyBulkMenuOperations(beforePayload, params.operations); + const validation = validateMenuTreeSnapshot(result.payload); if (dryRun) { return { @@ -421,7 +541,17 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a { type: "text", text: JSON.stringify( - { success: true, dryRun: true, operation: "bulk_menu_operations", menuName: params.name, warnings: result.warnings, applied: result.applied, preview: { nodeCount: result.payload.length } }, + { + success: validation.issues.length === 0, + dryRun: true, + operation: "bulk_menu_operations", + menuName: params.name, + warnings: [...result.warnings, ...validation.warnings], + issues: validation.issues, + diagnostics: validation.diagnostics, + applied: result.applied, + preview: buildMenuMutationPreview(beforePayload, result.payload), + }, null, 2 ) @@ -436,11 +566,23 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a writeBody.isActive = menu.isActive; } const writeRes = await api.put(`/menus/${params.name}`, writeBody); + const writtenCheck = validateMenuTreeSnapshot(normalized.payload); return { content: [ { type: "text", - text: JSON.stringify({ success: true, operation: "bulk_menu_operations", menuName: params.name, warnings: result.warnings, applied: result.applied, nodeCount: normalized.payload.length }, null, 2) + text: JSON.stringify( + { + success: true, + operation: "bulk_menu_operations", + menuName: params.name, + warnings: [...result.warnings, ...writtenCheck.warnings], + applied: result.applied, + summary: writtenCheck.summary, + }, + null, + 2 + ) } ] }; @@ -454,21 +596,36 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a if (validation.issues.length > 0) { throw new Error(`Invalid menu node: ${validation.issues.join("; ")}`); } - const result = applyMenuAddNode(Array.isArray(menu.payload) ? menu.payload : [], parentId, nodeData); + const beforePayload = Array.isArray(menu.payload) ? menu.payload : []; + const result = applyMenuAddNode(beforePayload, parentId, nodeData); + const treeCheck = validateMenuTreeSnapshot(result.payload); if (dryRun) { return { content: [ { type: "text", - text: JSON.stringify({ success: true, dryRun: true, operation: "add_menu_node", nodeId: result.nodeId, warnings: validation.warnings, preview: { nodeCount: result.payload.length } }, null, 2) + text: JSON.stringify( + { + success: treeCheck.issues.length === 0, + dryRun: true, + operation: "add_menu_node", + nodeId: result.nodeId, + warnings: [...validation.warnings, ...treeCheck.warnings], + issues: treeCheck.issues, + diagnostics: treeCheck.diagnostics, + preview: buildMenuMutationPreview(beforePayload, result.payload, result.nodeId), + }, + null, + 2 + ) } ] }; } await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] }); - return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeId: result.nodeId, nodeCount: result.payload.length }, null, 2) }] }; + return { content: [{ type: "text", text: JSON.stringify({ success: true, operation: "add_menu_node", nodeId: result.nodeId, summary: validateMenuTreeSnapshot(result.payload).summary }, null, 2) }] }; } case "update_menu_translation": { const { nodeId, translations } = params; @@ -479,63 +636,108 @@ export async function handleMenuTool(api: AxiosInstance, name: string, params: a } const res = await api.get(`/menus/main`); const menu = res.data; - const result = applyMenuUpdateTranslation(Array.isArray(menu.payload) ? menu.payload : [], nodeId, translations); + const beforePayload = Array.isArray(menu.payload) ? menu.payload : []; + const result = applyMenuUpdateTranslation(beforePayload, nodeId, translations); + const treeCheck = validateMenuTreeSnapshot(result.payload); if (dryRun) { return { content: [ { type: "text", - text: JSON.stringify({ success: true, dryRun: true, operation: "update_menu_translation", warnings: validation.warnings, preview: { nodeCount: result.payload.length } }, null, 2) + text: JSON.stringify( + { + success: treeCheck.issues.length === 0, + dryRun: true, + operation: "update_menu_translation", + nodeId, + warnings: [...validation.warnings, ...treeCheck.warnings], + issues: treeCheck.issues, + diagnostics: treeCheck.diagnostics, + preview: buildMenuMutationPreview(beforePayload, result.payload, nodeId), + }, + null, + 2 + ) } ] }; } await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] }); - return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeId, nodeCount: result.payload.length }, null, 2) }] }; + return { content: [{ type: "text", text: JSON.stringify({ success: true, operation: "update_menu_translation", nodeId, summary: validateMenuTreeSnapshot(result.payload).summary }, null, 2) }] }; } case "move_menu_node": { const { nodeId, newParentId, orderIndex } = params; const dryRun = !!params.dryRun; const res = await api.get(`/menus/main`); const menu = res.data; - const result = applyMenuMoveNode(Array.isArray(menu.payload) ? menu.payload : [], nodeId, newParentId, orderIndex); + const beforePayload = Array.isArray(menu.payload) ? menu.payload : []; + const result = applyMenuMoveNode(beforePayload, nodeId, newParentId, orderIndex); + const treeCheck = validateMenuTreeSnapshot(result.payload); if (dryRun) { return { content: [ { type: "text", - text: JSON.stringify({ success: true, dryRun: true, operation: "move_menu_node", preview: { nodeCount: result.payload.length } }, null, 2) + text: JSON.stringify( + { + success: treeCheck.issues.length === 0, + dryRun: true, + operation: "move_menu_node", + nodeId, + warnings: treeCheck.warnings, + issues: treeCheck.issues, + diagnostics: treeCheck.diagnostics, + preview: buildMenuMutationPreview(beforePayload, result.payload, nodeId), + }, + null, + 2 + ) } ] }; } await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] }); - return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeCount: result.payload.length }, null, 2) }] }; + return { content: [{ type: "text", text: JSON.stringify({ success: true, operation: "move_menu_node", nodeId, summary: validateMenuTreeSnapshot(result.payload).summary }, null, 2) }] }; } case "remove_menu_node": { const { nodeId } = params; const dryRun = !!params.dryRun; const res = await api.get(`/menus/main`); const menu = res.data; - const result = applyMenuRemoveNode(Array.isArray(menu.payload) ? menu.payload : [], nodeId); + const beforePayload = Array.isArray(menu.payload) ? menu.payload : []; + const result = applyMenuRemoveNode(beforePayload, nodeId); + const treeCheck = validateMenuTreeSnapshot(result.payload); if (dryRun) { return { content: [ { type: "text", - text: JSON.stringify({ success: true, dryRun: true, operation: "remove_menu_node", removedNode: result.removedNode, preview: { nodeCount: result.payload.length } }, null, 2) + text: JSON.stringify( + { + success: treeCheck.issues.length === 0, + dryRun: true, + operation: "remove_menu_node", + removedNode: result.removedNode, + warnings: treeCheck.warnings, + issues: treeCheck.issues, + diagnostics: treeCheck.diagnostics, + preview: buildMenuMutationPreview(beforePayload, result.payload, nodeId), + }, + null, + 2 + ) } ] }; } await api.put(`/menus/main`, { payload: result.payload, items: menu.items || [] }); - return { content: [{ type: "text", text: JSON.stringify({ success: true, nodeCount: result.payload.length }, null, 2) }] }; + return { content: [{ type: "text", text: JSON.stringify({ success: true, operation: "remove_menu_node", removedNode: result.removedNode, summary: validateMenuTreeSnapshot(result.payload).summary }, null, 2) }] }; } default: return null; diff --git a/apps/mcp-server/menu-tools.ts b/apps/mcp-server/menu-tools.ts index e41d099..626c830 100644 --- a/apps/mcp-server/menu-tools.ts +++ b/apps/mcp-server/menu-tools.ts @@ -2,6 +2,11 @@ import crypto from "crypto"; const MENU_NODE_ARRAY_KEYS = ["columns", "links", "items", "children"] as const; const MENU_NODE_VALUE_KEYS = ["label", "title", "url", ...MENU_NODE_ARRAY_KEYS] as const; +const SUPPORTED_MENU_LOCALES = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"] as const; +const PLACEHOLDER_TEXTS = new Set(["Badge", "Card title", "CTA label", "Section title", "Card subtitle", "New Menu", "New Column", "New Link"]); +const MAX_DESKTOP_COLUMNS = 2; +const MAX_DESKTOP_TOP_LEVEL_LINKS = 5; +const MAX_DESKTOP_INLINE_CHILD_LINKS = 3; function isPlainObject(value: any) { return !!value && typeof value === "object" && !Array.isArray(value); @@ -95,6 +100,196 @@ function describeMenuNode(node: any) { return "menu node"; } +function getMenuNodeType(node: any, parentType?: string) { + if (!isPlainObject(node)) return "invalid"; + if (Array.isArray(node.columns)) return "root"; + if (parentType === "root") return "column"; + if (parentType === "column" || parentType === "link") return "link"; + if (Array.isArray(node.links)) return "column"; + if (typeof node.url === "string") return "link"; + return "node"; +} + +function listLocalizedIssues(value: any, path: string, requiredBase = true) { + const issues: string[] = []; + const warnings: string[] = []; + + if (value === undefined) { + issues.push(`${path}: missing localized text`); + return { issues, warnings }; + } + + if (typeof value === "string") { + if (!value.trim()) issues.push(`${path}: text must not be empty`); + return { issues, warnings }; + } + + if (!isPlainObject(value)) { + issues.push(`${path}: text must be a string or localized object`); + return { issues, warnings }; + } + + for (const key of Object.keys(value)) { + if (!(SUPPORTED_MENU_LOCALES as readonly string[]).includes(key)) { + issues.push(`${path}.${key}: unsupported key; allowed locale keys are ${SUPPORTED_MENU_LOCALES.join(", ")}`); + } else if (typeof value[key] !== "string" || !value[key].trim()) { + issues.push(`${path}.${key}: translation must be a non-empty string`); + } else if (PLACEHOLDER_TEXTS.has(value[key].trim())) { + warnings.push(`${path}.${key}: placeholder text "${value[key]}" should be replaced`); + } + } + + if (requiredBase && !value.th && !value.en) { + issues.push(`${path}: provide at least th or en so website fallback has a stable label`); + } + + for (const locale of SUPPORTED_MENU_LOCALES) { + if (!value[locale]) { + warnings.push(`${path}: missing ${locale} translation`); + } + } + + return { issues, warnings }; +} + +function normalizeUrlIssue(url: any, path: string) { + if (typeof url !== "string" || !url.trim()) { + return `${path}: url must be a non-empty string`; + } + const trimmed = url.trim(); + if ( + !trimmed.startsWith("/") && + !trimmed.startsWith("https://") && + !trimmed.startsWith("http://") && + !trimmed.startsWith("mailto:") && + !trimmed.startsWith("tel:") + ) { + return `${path}: url must start with /, http(s)://, mailto:, or tel:`; + } + return null; +} + +function getNodePath(payload: any[], nodeId?: string) { + if (!nodeId) return null; + const found = findNodeAndParent(payload, nodeId); + if (!found) return null; + const segments: string[] = []; + let current: any = found.node; + + const findPath = (list: any[], target: any, base: string): boolean => { + for (let i = 0; i < list.length; i++) { + const item = list[i]; + const currentPath = `${base}[${i}]`; + if (item === target) { + segments.push(currentPath); + return true; + } + for (const key of MENU_NODE_ARRAY_KEYS) { + if (Array.isArray(item?.[key]) && findPath(item[key], target, `${currentPath}.${key}`)) return true; + } + } + return false; + }; + + findPath(payload, current, "payload"); + return segments[0] || null; +} + +function summarizeMenuTree(payload: any[]) { + let totalNodes = 0; + let rootCount = 0; + let columnCount = 0; + let linkCount = 0; + let featuredCount = 0; + let hiddenDesktopColumns = 0; + let hiddenDesktopLinks = 0; + let hiddenDesktopChildLinks = 0; + + const walk = (nodes: any[], parentType?: string) => { + nodes.forEach((node) => { + if (!isPlainObject(node)) return; + const type = getMenuNodeType(node, parentType); + totalNodes += 1; + if (type === "root") { + rootCount += 1; + if (isPlainObject(node.featured) && node.featured.enabled !== false) featuredCount += 1; + const columns = Array.isArray(node.columns) ? node.columns : []; + hiddenDesktopColumns += Math.max(columns.length - MAX_DESKTOP_COLUMNS, 0); + walk(columns, "root"); + } else if (type === "column") { + columnCount += 1; + const links = Array.isArray(node.links) ? node.links : []; + hiddenDesktopLinks += Math.max(links.length - MAX_DESKTOP_TOP_LEVEL_LINKS, 0); + walk(links, "column"); + } else if (type === "link") { + linkCount += 1; + const childLinks = Array.isArray(node.links) ? node.links : []; + hiddenDesktopChildLinks += Math.max(childLinks.length - MAX_DESKTOP_INLINE_CHILD_LINKS, 0); + walk(childLinks, "link"); + } + }); + }; + + walk(payload); + return { + rootCount, + columnCount, + linkCount, + totalNodes, + featuredCount, + desktopLimits: { + maxVisibleColumnsPerRoot: MAX_DESKTOP_COLUMNS, + maxVisibleLinksPerColumn: MAX_DESKTOP_TOP_LEVEL_LINKS, + maxVisibleChildLinksPerLink: MAX_DESKTOP_INLINE_CHILD_LINKS, + hiddenColumns: hiddenDesktopColumns, + hiddenLinks: hiddenDesktopLinks, + hiddenChildLinks: hiddenDesktopChildLinks, + }, + }; +} + +function buildMenuDiff(before: any, after: any, basePath = "payload", limit = 80) { + const diff: any[] = []; + + const walk = (a: any, b: any, path: string) => { + if (diff.length >= limit) return; + if (JSON.stringify(a) === JSON.stringify(b)) return; + + if (Array.isArray(a) || Array.isArray(b)) { + const aArr = Array.isArray(a) ? a : []; + const bArr = Array.isArray(b) ? b : []; + const max = Math.max(aArr.length, bArr.length); + for (let i = 0; i < max; i++) walk(aArr[i], bArr[i], `${path}[${i}]`); + return; + } + + if (isPlainObject(a) || isPlainObject(b)) { + const keys = new Set([...Object.keys(isPlainObject(a) ? a : {}), ...Object.keys(isPlainObject(b) ? b : {})]); + for (const key of keys) walk(a?.[key], b?.[key], `${path}.${key}`); + return; + } + + diff.push({ path, before: a ?? null, after: b ?? null }); + }; + + walk(before, after, basePath); + return { entries: diff, truncated: diff.length >= limit }; +} + +function buildMenuMutationPreview(before: any[], after: any[], changedNodeId?: string) { + const beforePayload = ensureMenuPayloadArray(before); + const afterPayload = ensureMenuPayloadArray(after); + const diff = buildMenuDiff(beforePayload, afterPayload); + const changedPath = getNodePath(afterPayload, changedNodeId) || getNodePath(beforePayload, changedNodeId); + return { + before: summarizeMenuTree(beforePayload), + after: summarizeMenuTree(afterPayload), + changedNodeId: changedNodeId || null, + changedPath, + diff, + }; +} + function validateMenuNodeData(nodeData: any, parentId?: string | null) { const issues: string[] = []; const warnings: string[] = []; @@ -110,13 +305,18 @@ function validateMenuNodeData(nodeData: any, parentId?: string | null) { for (const key of ["label", "title"] as const) { const value = nodeData[key]; - if (value !== undefined && !isPlainObject(value) && typeof value !== "string") { - issues.push(`${key} must be a string or localized object`); + if (value !== undefined) { + const textCheck = listLocalizedIssues(value, key, false); + issues.push(...textCheck.issues); + warnings.push(...textCheck.warnings); } } if (nodeData.url !== undefined && typeof nodeData.url !== "string") { issues.push("url must be a string when provided"); + } else if (nodeData.url !== undefined) { + const urlIssue = normalizeUrlIssue(nodeData.url, "url"); + if (urlIssue) issues.push(urlIssue); } for (const key of MENU_NODE_ARRAY_KEYS) { @@ -148,8 +348,14 @@ function validateMenuTranslations(translations: any) { return { issues, warnings }; } for (const [key, value] of Object.entries(translations)) { + if (!(SUPPORTED_MENU_LOCALES as readonly string[]).includes(key)) { + issues.push(`unsupported locale "${key}"; allowed locale keys are ${SUPPORTED_MENU_LOCALES.join(", ")}`); + continue; + } if (typeof value !== "string") { issues.push(`translation for "${key}" must be a string`); + } else if (!value.trim()) { + issues.push(`translation for "${key}" must not be empty`); } } return { issues, warnings }; @@ -229,44 +435,102 @@ function ensureMenuPayloadArray(value: any) { function validateMenuTreeSnapshot(payload: any[]) { const issues: string[] = []; const warnings: string[] = []; + const diagnostics: Array<{ severity: "critical" | "high" | "medium" | "low"; path: string; nodeId?: string; message: string; recommendedFix: string }> = []; const seenNodeIds = new Set(); - const walk = (value: any, path: string) => { + const addIssue = (severity: "critical" | "high" | "medium" | "low", path: string, nodeId: string | undefined, message: string, recommendedFix: string) => { + const text = `${path}: ${message}`; + if (severity === "critical" || severity === "high") issues.push(text); + else warnings.push(text); + diagnostics.push({ severity, path, nodeId, message, recommendedFix }); + }; + + const walk = (value: any, path: string, parentType?: string) => { if (Array.isArray(value)) { - value.forEach((child, index) => walk(child, `${path}[${index}]`)); + value.forEach((child, index) => walk(child, `${path}[${index}]`, parentType)); return; } if (!isPlainObject(value)) return; if (!isStructuralMenuNode(value)) { - warnings.push(`${path}: non-structural object present in menu tree`); + addIssue("low", path, undefined, "non-structural object present in menu tree", "Remove this object or convert it into a root, column, or link node."); return; } + const nodeId = typeof value.nodeId === "string" ? value.nodeId : undefined; + const nodeType = getMenuNodeType(value, parentType); + if (value.nodeId && typeof value.nodeId === "string") { if (seenNodeIds.has(value.nodeId)) { - issues.push(`${path}: duplicate nodeId ${value.nodeId}`); + addIssue("critical", path, value.nodeId, `duplicate nodeId ${value.nodeId}`, "Assign a unique nodeId or let get_mega_menu_tree normalize IDs."); } seenNodeIds.add(value.nodeId); } else { - warnings.push(`${path}: missing nodeId`); + addIssue("medium", path, undefined, "missing nodeId", "Run get_mega_menu_tree once or update this node through MCP so a nodeId is generated."); + } + + if (nodeType === "root") { + const labelCheck = listLocalizedIssues(value.label, `${path}.label`); + labelCheck.issues.forEach((message) => addIssue("high", path, nodeId, message.replace(`${path}.label: `, ""), "Provide a localized root label with at least th or en.")); + labelCheck.warnings.forEach((message) => addIssue("low", path, nodeId, message.replace(`${path}.label: `, ""), "Fill missing locale labels, especially lo and it.")); + + const urlIssue = normalizeUrlIssue(value.url, `${path}.url`); + if (urlIssue) addIssue("high", path, nodeId, urlIssue.replace(`${path}.url: `, ""), "Set a valid root URL such as /shop."); + + const columns = Array.isArray(value.columns) ? value.columns : []; + if (columns.length === 0 && isPlainObject(value.featured) && value.featured.enabled !== false) { + addIssue("medium", path, nodeId, "featured card is enabled on a root item with no columns", "Disable featured.enabled or add at least one column."); + } + if (columns.length > MAX_DESKTOP_COLUMNS) { + addIssue("medium", path, nodeId, `${columns.length - MAX_DESKTOP_COLUMNS} columns are hidden on desktop`, "Keep the most important columns first or reduce to two columns."); + } + if (isPlainObject(value.featured)) { + for (const key of ["sectionTitle", "badge", "title", "subtitle", "ctaLabel"] as const) { + if (value.featured[key] !== undefined) { + const check = listLocalizedIssues(value.featured[key], `${path}.featured.${key}`, false); + check.issues.forEach((message) => addIssue("medium", path, nodeId, message.replace(`${path}.featured.${key}: `, `featured.${key}: `), "Use only supported locale keys and non-empty strings.")); + check.warnings.forEach((message) => addIssue("low", path, nodeId, message.replace(`${path}.featured.${key}: `, `featured.${key}: `), "Fill missing featured-card locales or remove placeholder text.")); + } + } + } + } else if (nodeType === "column") { + const titleCheck = listLocalizedIssues(value.title, `${path}.title`); + titleCheck.issues.forEach((message) => addIssue("high", path, nodeId, message.replace(`${path}.title: `, ""), "Provide a localized column title with at least th or en.")); + titleCheck.warnings.forEach((message) => addIssue("low", path, nodeId, message.replace(`${path}.title: `, ""), "Fill missing locale titles, especially lo and it.")); + const links = Array.isArray(value.links) ? value.links : []; + if (links.length === 0) { + addIssue("medium", path, nodeId, "column has no links", "Add at least one link or remove the column."); + } + if (links.length > MAX_DESKTOP_TOP_LEVEL_LINKS) { + addIssue("medium", path, nodeId, `${links.length - MAX_DESKTOP_TOP_LEVEL_LINKS} links are hidden on desktop`, "Keep the most important links in the first five positions."); + } + } else if (nodeType === "link") { + const labelCheck = listLocalizedIssues(value.label, `${path}.label`); + labelCheck.issues.forEach((message) => addIssue("high", path, nodeId, message.replace(`${path}.label: `, ""), "Provide a localized link label with at least th or en.")); + labelCheck.warnings.forEach((message) => addIssue("low", path, nodeId, message.replace(`${path}.label: `, ""), "Fill missing locale labels, especially lo and it.")); + const urlIssue = normalizeUrlIssue(value.url, `${path}.url`); + if (urlIssue) addIssue("high", path, nodeId, urlIssue.replace(`${path}.url: `, ""), "Set a valid link URL."); + const childLinks = Array.isArray(value.links) ? value.links : []; + if (childLinks.length > MAX_DESKTOP_INLINE_CHILD_LINKS) { + addIssue("medium", path, nodeId, `${childLinks.length - MAX_DESKTOP_INLINE_CHILD_LINKS} child links are collapsed behind '+ more' on desktop`, "Keep the most important child links in the first three positions."); + } } const hasChildren = MENU_NODE_ARRAY_KEYS.some((key) => Array.isArray(value[key])); if (!hasChildren && !value.url) { - warnings.push(`${path}: container node has no children and no url`); + addIssue("medium", path, nodeId, "container node has no children and no url", "Add children, set a url, or remove the node."); } for (const key of MENU_NODE_ARRAY_KEYS) { if (Array.isArray(value[key])) { - walk(value[key], `${path}.${key}`); + walk(value[key], `${path}.${key}`, nodeType); } } }; walk(payload, "payload"); - return { issues, warnings }; + return { issues, warnings, diagnostics, summary: summarizeMenuTree(payload), supportedLocales: SUPPORTED_MENU_LOCALES }; } function applyMenuAddNode(tree: any[], parentId: string | undefined, nodeData: any): MenuTreeMutationResult { @@ -464,6 +728,7 @@ function applyBulkMenuOperations(tree: any[], operations: any[]) { export { MENU_NODE_ARRAY_KEYS, MENU_NODE_VALUE_KEYS, + SUPPORTED_MENU_LOCALES, type MenuTreeMutationResult, applyBulkMenuOperations, applyMenuAddNode, @@ -474,6 +739,7 @@ export { collectMenuLinksFromTree, createMenuSnapshot, describeMenuNode, + buildMenuMutationPreview, ensureMenuPayloadArray, findNodeAndParent, getChildrenArray,