Files
2026-06-28 21:34:58 -04:00

17 KiB
Raw Permalink Blame History

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)

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

  1. Development Environment: To generate and apply the SQL migration locally, run:
    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:
    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

  1. Schema Update (MENU_TOOL_DEFINITIONS): Under upsert_menu.inputSchema.properties, add rootImage:

    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:

    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:

    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

  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.