17 KiB
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,Menuholds a flat list ofMenuItemobjects via a relation, and a hierarchical JSONpayload. To support the layout requirements,MenurequiresrootImage(optionalString?), whileMenuItemrequirescolumnImageandhoverImage(both optionalString?). - TypeScript Types:
packages/types/index.tsexposes interface typesMenuandMenuItemdirectly from source files, so adding the optional image properties there is straightforward. - Backend Routes:
apps/backend/src/routes/menus.tsdefines Elysia routes. ThePOST /andPUT /:nameendpoints require updating their TypeBox validation schemas (t.Optional(t.Nullable(t.String()))) and mapping the new fields in Prisma transactions.GETendpoints automatically return these new database fields. - MCP Server Tools:
apps/mcp-server/menu-tools.tsvalidatesMenuItemlists viavalidateMenuItemsand requires checks for the new image fields.apps/mcp-server/menu-toolkit.tsexposesupsert_menuand 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)
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:
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
- Development Environment: To generate and apply the SQL migration locally, run:
Warning: Never use
bunx prisma migrate dev --name add_menu_image_fieldsprisma db pushin this project as it bypasses migrations and will lead to local/production schema drift. - Production/CI Environment: To apply the created migration to the production database during deployment, run:
bunx prisma migrate deploy
2. Shared Type Declarations
File: packages/types/index.ts
Existing Code (Lines 195–208)
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:
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
.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)
.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
.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)
.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:
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
-
Schema Update (
MENU_TOOL_DEFINITIONS): Underupsert_menu.inputSchema.properties, addrootImage:rootImage: { type: "string", description: "Optional image URL for the menu root (optional, nullable)" },Under
upsert_menu.inputSchema.properties.items.items.properties, addcolumnImageandhoverImage:columnImage: { type: "string", description: "Optional column image URL for this menu item" }, hoverImage: { type: "string", description: "Optional hover image URL for this menu item" }, -
Handler Update (
handleMenuTool): Incase "upsert_menu":, maprootImageinto the PUT request payloadbody: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:
preview: { name: params.name, itemCount: items.length, hasPayload: !!body.payload, isActive: body.isActive, rootImage: body.rootImage // Added preview }
5. Potential Risks & Mitigation Plans
- 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.
- Nullable Fields on Client Side:
- Risk: Since the new image fields are optional (
String?), they default tonullfor 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.
- Risk: Since the new image fields are optional (
- Elysia TypeScript Transpilation Sync:
- Risk: The backend routes are transpiled into
.jsand.d.tsinside the same source directory (apps/backend/src/routes/). Discrepancies between.tssource 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.
- Risk: The backend routes are transpiled into
- Seed Execution Safety:
- Risk: Database seeding in
seed.cjsupserts 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
nulland are not required duringcreate/upsertcalls. Thus,seed.cjswill not break, but documenting this compatibility is critical.
- Risk: Database seeding in