476 lines
17 KiB
Markdown
476 lines
17 KiB
Markdown
# 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.
|