# Implementation Strategy for Menu Image Fields This report outlines the technical analysis and implementation strategy for adding new image fields to the `Menu` and `MenuItem` models in the backend database, shared types, backend API routes, and MCP tools. --- ## Summary of Findings - **Database Model**: In `apps/backend/prisma/schema.prisma`, `Menu` holds a flat list of `MenuItem` objects via a relation, and a hierarchical JSON `payload`. To support the layout requirements, `Menu` requires `rootImage` (optional `String?`), while `MenuItem` requires `columnImage` and `hoverImage` (both optional `String?`). - **TypeScript Types**: `packages/types/index.ts` exposes interface types `Menu` and `MenuItem` directly from source files, so adding the optional image properties there is straightforward. - **Backend Routes**: `apps/backend/src/routes/menus.ts` defines Elysia routes. The `POST /` and `PUT /:name` endpoints require updating their TypeBox validation schemas (`t.Optional(t.Nullable(t.String()))`) and mapping the new fields in Prisma transactions. `GET` endpoints automatically return these new database fields. - **MCP Server Tools**: `apps/mcp-server/menu-tools.ts` validates `MenuItem` lists via `validateMenuItems` and requires checks for the new image fields. `apps/mcp-server/menu-toolkit.ts` exposes `upsert_menu` and requires updating its input schema properties and input payload mapping. --- ## 1. Database Schema & Migration Strategy ### File: `apps/backend/prisma/schema.prisma` #### Existing Code (Lines 638–658) ```prisma model Menu { id Int @id @default(autoincrement()) name String @unique // e.g., "header", "footer" payload Json? isActive Boolean @default(true) items MenuItem[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model MenuItem { id Int @id @default(autoincrement()) menuId Int menu Menu @relation(fields: [menuId], references: [id], onDelete: Cascade) label String url String order Int @default(0) translations MenuItemTranslation[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } ``` #### Proposed Changes Add `rootImage` to the `Menu` model, and `columnImage`/`hoverImage` to the `MenuItem` model: ```prisma model Menu { id Int @id @default(autoincrement()) name String @unique // e.g., "header", "footer" payload Json? isActive Boolean @default(true) rootImage String? items MenuItem[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } model MenuItem { id Int @id @default(autoincrement()) menuId Int menu Menu @relation(fields: [menuId], references: [id], onDelete: Cascade) label String url String order Int @default(0) columnImage String? hoverImage String? translations MenuItemTranslation[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } ``` ### Migration Commands 1. **Development Environment**: To generate and apply the SQL migration locally, run: ```bash bunx prisma migrate dev --name add_menu_image_fields ``` *Warning*: Never use `prisma db push` in this project as it bypasses migrations and will lead to local/production schema drift. 2. **Production/CI Environment**: To apply the created migration to the production database during deployment, run: ```bash bunx prisma migrate deploy ``` --- ## 2. Shared Type Declarations ### File: `packages/types/index.ts` #### Existing Code (Lines 195–208) ```typescript export interface MenuItem { id: number label: string url: string order: number translations?: any[] } export interface Menu { id: number name: string items: MenuItem[] payload?: any } ``` #### Proposed Changes Update interfaces to expose the optional properties: ```typescript export interface MenuItem { id: number label: string url: string order: number columnImage?: string | null hoverImage?: string | null translations?: any[] } export interface Menu { id: number name: string rootImage?: string | null items: MenuItem[] payload?: any } ``` --- ## 3. Backend Route Changes ### File: `apps/backend/src/routes/menus.ts` Both `POST /` and `PUT /:name` routes must be modified. #### A. Route `POST /` (Lines 32–59) ##### Before ```typescript .post("/", async ({ body, set }) => { try { const { name, items } = body as { name: string; items?: { label: string; url: string; order: number }[] }; const menu = await prisma.menu.create({ data: { name, items: items ? { create: items } : undefined }, include: { items: true } }); return menu; } catch (err) { console.error(err); set.status = 500; return { error: "Failed to create menu" }; } }, { body: t.Object({ name: t.String(), items: t.Optional(t.Array(t.Object({ label: t.String(), url: t.String(), order: t.Number() }))) }) }) ``` ##### After (Proposed) ```typescript .post("/", async ({ body, set }) => { try { const { name, rootImage, items } = body as { name: string; rootImage?: string | null; items?: { label: string; url: string; order: number; columnImage?: string | null; hoverImage?: string | null }[] }; const menu = await prisma.menu.create({ data: { name, rootImage, items: items ? { create: items } : undefined }, include: { items: true } }); return menu; } catch (err) { console.error(err); set.status = 500; return { error: "Failed to create menu" }; } }, { body: t.Object({ name: t.String(), rootImage: t.Optional(t.Nullable(t.String())), items: t.Optional(t.Array(t.Object({ label: t.String(), url: t.String(), order: t.Number(), columnImage: t.Optional(t.Nullable(t.String())), hoverImage: t.Optional(t.Nullable(t.String())) }))) }) }) ``` #### B. Route `PUT /:name` (Lines 60–147) ##### Before ```typescript .put("/:name", async ({ params: { name }, body, set }) => { try { const { items, payload, isActive } = body as { payload?: any; isActive?: boolean; items: { id?: number; label: string; url: string; order: number; translations?: { langCode: string; label: string }[] }[] }; let menu = await prisma.menu.findUnique({ where: { name } }); const updateData: any = {}; if (payload !== undefined) updateData.payload = payload; if (isActive !== undefined) updateData.isActive = isActive; if (!menu) { menu = await prisma.menu.create({ data: { name, ...updateData } }); } else if (Object.keys(updateData).length > 0) { menu = await prisma.menu.update({ where: { name }, data: updateData }); } await prisma.$transaction(async (tx: any) => { const existingItems = await tx.menuItem.findMany({ where: { menuId: menu!.id } }); const existingItemIds = existingItems.map((i: any) => i.id); const payloadItemIds = items.map(i => i.id).filter(id => id != null); const itemsToDelete = existingItemIds.filter((id: number) => !payloadItemIds.includes(id)); if (itemsToDelete.length > 0) { await tx.menuItem.deleteMany({ where: { id: { in: itemsToDelete } } }); } for (const item of items) { if (item.id && existingItemIds.includes(item.id)) { await tx.menuItem.update({ where: { id: item.id }, data: { label: item.label, url: item.url, order: item.order } }); if (item.translations?.length) { for (const t of item.translations) { await tx.menuItemTranslation.upsert({ where: { menuItemId_langCode: { menuItemId: item.id, langCode: t.langCode } }, update: { label: t.label }, create: { menuItemId: item.id, langCode: t.langCode, label: t.label } }); } } } else { await tx.menuItem.create({ data: { menuId: menu!.id, label: item.label, url: item.url, order: item.order, translations: item.translations?.length ? { create: item.translations.map(t => ({ langCode: t.langCode, label: t.label })) } : undefined } }); } } }); // ... (retrieval and return logic) }, { body: t.Object({ payload: t.Optional(t.Any()), isActive: t.Optional(t.Boolean()), items: t.Array(t.Object({ id: t.Optional(t.Number()), label: t.String(), url: t.String(), order: t.Number(), translations: t.Optional(t.Array(t.Object({ langCode: t.String(), label: t.String() }))) })) }) }) ``` ##### After (Proposed) ```typescript .put("/:name", async ({ params: { name }, body, set }) => { try { const { items, payload, isActive, rootImage } = body as { payload?: any; isActive?: boolean; rootImage?: string | null; items: { id?: number; label: string; url: string; order: number; columnImage?: string | null; hoverImage?: string | null; translations?: { langCode: string; label: string }[] }[] }; let menu = await prisma.menu.findUnique({ where: { name } }); const updateData: any = {}; if (payload !== undefined) updateData.payload = payload; if (isActive !== undefined) updateData.isActive = isActive; if (rootImage !== undefined) updateData.rootImage = rootImage; if (!menu) { menu = await prisma.menu.create({ data: { name, ...updateData } }); } else if (Object.keys(updateData).length > 0) { menu = await prisma.menu.update({ where: { name }, data: updateData }); } await prisma.$transaction(async (tx: any) => { const existingItems = await tx.menuItem.findMany({ where: { menuId: menu!.id } }); const existingItemIds = existingItems.map((i: any) => i.id); const payloadItemIds = items.map(i => i.id).filter(id => id != null); const itemsToDelete = existingItemIds.filter((id: number) => !payloadItemIds.includes(id)); if (itemsToDelete.length > 0) { await tx.menuItem.deleteMany({ where: { id: { in: itemsToDelete } } }); } for (const item of items) { if (item.id && existingItemIds.includes(item.id)) { await tx.menuItem.update({ where: { id: item.id }, data: { label: item.label, url: item.url, order: item.order, columnImage: item.columnImage, hoverImage: item.hoverImage } }); if (item.translations?.length) { for (const t of item.translations) { await tx.menuItemTranslation.upsert({ where: { menuItemId_langCode: { menuItemId: item.id, langCode: t.langCode } }, update: { label: t.label }, create: { menuItemId: item.id, langCode: t.langCode, label: t.label } }); } } } else { await tx.menuItem.create({ data: { menuId: menu!.id, label: item.label, url: item.url, order: item.order, columnImage: item.columnImage, hoverImage: item.hoverImage, translations: item.translations?.length ? { create: item.translations.map(t => ({ langCode: t.langCode, label: t.label })) } : undefined } }); } } }); // ... (retrieval and return logic) }, { body: t.Object({ payload: t.Optional(t.Any()), isActive: t.Optional(t.Boolean()), rootImage: t.Optional(t.Nullable(t.String())), items: t.Array(t.Object({ id: t.Optional(t.Number()), label: t.String(), url: t.String(), order: t.Number(), columnImage: t.Optional(t.Nullable(t.String())), hoverImage: t.Optional(t.Nullable(t.String())), translations: t.Optional(t.Array(t.Object({ langCode: t.String(), label: t.String() }))) })) }) }) ``` --- ## 4. MCP Server Tools Adaptations ### A. File: `apps/mcp-server/menu-tools.ts` The `validateMenuItems` helper function needs to allow validating `columnImage` and `hoverImage` when present inside items. #### Proposed Change Add validation logic inside the `items.forEach` block: ```typescript if (item.columnImage !== undefined && item.columnImage !== null && typeof item.columnImage !== "string") { issues.push(`items[${index}].columnImage must be a string when provided`); } if (item.hoverImage !== undefined && item.hoverImage !== null && typeof item.hoverImage !== "string") { issues.push(`items[${index}].hoverImage must be a string when provided`); } ``` ### B. File: `apps/mcp-server/menu-toolkit.ts` The `upsert_menu` MCP tool schema and handler must be updated to accept and forward the new fields. #### Proposed Changes 1. **Schema Update (`MENU_TOOL_DEFINITIONS`)**: Under `upsert_menu.inputSchema.properties`, add `rootImage`: ```typescript rootImage: { type: "string", description: "Optional image URL for the menu root (optional, nullable)" }, ``` Under `upsert_menu.inputSchema.properties.items.items.properties`, add `columnImage` and `hoverImage`: ```typescript columnImage: { type: "string", description: "Optional column image URL for this menu item" }, hoverImage: { type: "string", description: "Optional hover image URL for this menu item" }, ``` 2. **Handler Update (`handleMenuTool`)**: In `case "upsert_menu":`, map `rootImage` into the PUT request payload `body`: ```typescript if (params.rootImage !== undefined) { body.rootImage = params.rootImage; } else if (existingMenu?.rootImage !== undefined) { body.rootImage = existingMenu.rootImage; } ``` Also update the dryRun response preview block to display the rootImage state: ```typescript preview: { name: params.name, itemCount: items.length, hasPayload: !!body.payload, isActive: body.isActive, rootImage: body.rootImage // Added preview } ``` --- ## 5. Potential Risks & Mitigation Plans 1. **Local and Production Environment Schema Mismatch**: - **Risk**: Directly deploying backend code without applying schema changes can lead to runtime crashes due to undefined columns. - **Mitigation**: Schema migrations (`prisma migrate dev`) must be run and verified in local/staging before merging changes to main. In production, migrations must run (`prisma migrate deploy`) prior to starting the new server container instances. 2. **Nullable Fields on Client Side**: - **Risk**: Since the new image fields are optional (`String?`), they default to `null` for all existing menus. Client applications (SPA, website storefront, admin dashboard) must render layouts gracefully when these fields are missing. - **Mitigation**: Code on client sides must fallback to default placeholders (like mock assets or empty spaces) when the image URLs are null or empty. 3. **Elysia TypeScript Transpilation Sync**: - **Risk**: The backend routes are transpiled into `.js` and `.d.ts` inside the same source directory (`apps/backend/src/routes/`). Discrepancies between `.ts` source edits and build outputs could cause runtime type mismatch or route resolution bugs. - **Mitigation**: A clean backend rebuild (e.g. compiling with TSC/Bun compiler) should be executed right after changes are made to ensure source code and build assets are in perfect alignment. 4. **Seed Execution Safety**: - **Risk**: Database seeding in `seed.cjs` upserts menus and menu items. If database constraints are strict, omitting the new columns might fail. - **Mitigation**: In Prisma, optional columns without default values automatically default to `null` and are not required during `create`/`upsert` calls. Thus, `seed.cjs` will not break, but documenting this compatibility is critical.