Files
homaroma/.agents/explorer_m2_3/analysis.md
T
2026-06-28 21:34:58 -04:00

476 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.