Files
MOPC-Portal/docs/superpowers/plans/2026-04-29-pr6-lunch-event.md
Matt e16039142e docs: implementation plan for PR 6 — lunch event
Bite-sized TDD tasks covering schema migration, auto-create hook,
lunch router (admin CRUD + mixed-permission upsertPick + member reads
+ manifest + CSV export + recap), email templates, two cron endpoints,
five-card admin UI on Logistics → Lunch tab, applicant dashboard
banner + picker, project-page externals strip, and the edition-settings
cleanup. Cross-references the design spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:20:07 +02:00

122 KiB
Raw Blame History

PR 6: Lunch Event Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the placeholder Lunch tab on /admin/logistics with a working flow: admins configure a single per-edition lunch event (date, venue, dishes, change deadline, recipients), attendees pick a dish + log allergies, and the manifest is reviewable in-app, exportable to CSV, and emailed to admins at the change deadline.

Architecture: Four new Prisma models (LunchEvent 1:1 with Program, Dish per event, MemberLunchPick 1:1 with AttendingMember, ExternalAttendee per event with optional projectId) plus two enums (DietaryTag, Allergen). One new tRPC router (lunch) carrying admin CRUD, a mixed-permission upsertPick procedure (member-self / team-lead / admin), and member reads for the dashboard banner + team-wide visibility. Two cron endpoints (reminders + recap) reuse the /api/cron/* pattern. Email templates land inline in src/lib/email.ts. The five-card admin UI sits on the existing Logistics → Lunch tab; the member picker extends AttendingMembersCard on the applicant dashboard.

Tech Stack: Prisma 6 + PostgreSQL (additive migration), tRPC 11 with Zod, Vitest 4 sequential pool, NextAuth 5 RBAC via the existing procedure middleware, shadcn/ui for cards/tables/dialogs, nodemailer via the existing sendEmail helper.

Spec: docs/superpowers/specs/2026-04-29-pr6-lunch-event-design.md


File map

Create:

  • src/server/routers/lunch.ts — router with all admin + member procedures
  • src/server/services/lunch-pick-sync.tsensureLunchPickForAttendingMember helper
  • src/server/services/lunch-recap.ts — manifest aggregation + recap payload builder
  • src/app/api/cron/lunch-reminders/route.ts
  • src/app/api/cron/lunch-recap/route.ts
  • src/components/admin/logistics/lunch-tab.tsx — orchestrates the five cards
  • src/components/admin/logistics/lunch-event-config.tsx
  • src/components/admin/logistics/lunch-dishes.tsx
  • src/components/admin/logistics/lunch-manifest.tsx
  • src/components/admin/logistics/lunch-externals.tsx
  • src/components/admin/logistics/lunch-recap-actions.tsx
  • src/components/applicant/lunch-banner.tsx
  • src/components/applicant/lunch-pick-form.tsx — used inside AttendingMembersCard rows
  • src/components/applicant/external-attendees-strip.tsx — read-only strip on the project page
  • tests/unit/lunch-router.test.ts
  • tests/unit/lunch-upsert-pick.test.ts
  • tests/unit/lunch-recap.test.ts
  • tests/unit/lunch-cron.test.ts
  • tests/unit/lunch-pick-sync.test.ts

Modify:

  • prisma/schema.prisma — new models, enums, back-refs on Program / AttendingMember / Project
  • src/server/routers/_app.ts — mount lunch router
  • src/server/routers/finalist.ts — wire ensureLunchPickForAttendingMember into the attendee-write paths
  • src/lib/email.ts — append two new template functions
  • src/app/(admin)/admin/logistics/page.tsx — un-disable the Lunch tab trigger and mount <LunchTab>
  • src/components/applicant/attending-members-card.tsx — embed <LunchPickForm> per row
  • src/app/(applicant)/applicant/page.tsx — render <LunchBanner> above the attending-members card
  • src/app/(applicant)/applicant/projects/[projectId]/page.tsx (or equivalent) — render <ExternalAttendeesStrip> (verify exact path during Task 21)
  • src/components/admin/settings/edition-settings-tab.tsx — drop the Lunch line from the "Coming soon" card

Task 1: Schema migration — models, enums, back-refs

Files:

  • Modify: prisma/schema.prisma

  • Generate: a new migration via npx prisma migrate dev --name add_lunch_event

  • Step 1: Add the two enums near the other domain enums in schema.prisma (right after WaitlistEntryStatus is a reasonable home):

enum DietaryTag {
  VEGETARIAN
  VEGAN
  GLUTEN_FREE
  PESCATARIAN
}

enum Allergen {
  GLUTEN
  CRUSTACEANS
  EGGS
  FISH
  PEANUTS
  SOYBEANS
  MILK
  TREE_NUTS
  CELERY
  MUSTARD
  SESAME
  SULPHITES
  LUPIN
  MOLLUSCS
}
  • Step 2: Add the four models in the same logistics section (right after FlightDetail / VisaApplication):
model LunchEvent {
  id                          String    @id @default(cuid())
  programId                   String    @unique
  enabled                     Boolean   @default(false)
  eventAt                     DateTime?
  endAt                       DateTime?
  venue                       String?
  notes                       String?   @db.Text
  changeCutoffHours           Int       @default(48)
  reminderHoursBeforeDeadline Int?
  cronEnabled                 Boolean   @default(true)
  extraRecipients             String[]  @default([])
  reminderSentAt              DateTime?
  recapSentAt                 DateTime?
  createdAt                   DateTime  @default(now())
  updatedAt                   DateTime  @updatedAt

  program           Program            @relation(fields: [programId], references: [id], onDelete: Cascade)
  dishes            Dish[]
  externalAttendees ExternalAttendee[]
}

model Dish {
  id           String       @id @default(cuid())
  lunchEventId String
  name         String
  sortOrder    Int          @default(0)
  dietaryTags  DietaryTag[]
  createdAt    DateTime     @default(now())
  updatedAt    DateTime     @updatedAt

  lunchEvent  LunchEvent         @relation(fields: [lunchEventId], references: [id], onDelete: Cascade)
  memberPicks MemberLunchPick[]
  externals   ExternalAttendee[]

  @@index([lunchEventId])
}

model MemberLunchPick {
  id                String     @id @default(cuid())
  attendingMemberId String     @unique
  dishId            String?
  allergens         Allergen[] @default([])
  allergenOther     String?
  pickedAt          DateTime?
  createdAt         DateTime   @default(now())
  updatedAt         DateTime   @updatedAt

  attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
  dish            Dish?           @relation(fields: [dishId], references: [id], onDelete: SetNull)

  @@index([dishId])
}

model ExternalAttendee {
  id            String     @id @default(cuid())
  lunchEventId  String
  projectId     String?
  name          String
  email         String?
  roleNote      String?
  dishId        String?
  allergens     Allergen[] @default([])
  allergenOther String?
  createdAt     DateTime   @default(now())
  updatedAt     DateTime   @updatedAt

  lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade)
  project    Project?   @relation(fields: [projectId], references: [id], onDelete: SetNull)
  dish       Dish?      @relation(fields: [dishId], references: [id], onDelete: SetNull)

  @@index([lunchEventId])
  @@index([projectId])
}
  • Step 3: Add the three back-refs on existing models. Find them by searching schema.prisma for the existing relation lines:

In model Program { ... } (just below hotel Hotel? at the bottom of the relation block):

  lunchEvent LunchEvent?

In model AttendingMember { ... } (just below visaApplication VisaApplication?):

  lunchPick MemberLunchPick?

In model Project { ... } (just below finalistAttendances AttendingMember[]):

  externalLunchAttendees ExternalAttendee[]
  • Step 4: Generate the migration
npx prisma migrate dev --name add_lunch_event

Expected: a new folder under prisma/migrations/ with a migration.sql that creates the four new tables, the two enums, and adds no columns to existing tables (back-refs are Prisma-side only).

  • Step 5: Regenerate the client
npx prisma generate
  • Step 6: Typecheck
npm run typecheck

Expected: clean. (No code references the new models yet.)

  • Step 7: Commit
git add prisma/schema.prisma prisma/migrations
git commit -m "feat: schema for lunch event, dishes, picks, externals"

Task 2: Auto-create MemberLunchPick on attending-member writes (TDD)

Files:

  • Create: src/server/services/lunch-pick-sync.ts

  • Modify: src/server/routers/finalist.ts (the existing confirm, editAttendees, unconfirm paths that touch AttendingMember)

  • Create: tests/unit/lunch-pick-sync.test.ts

  • Step 1: Locate the existing attendee write paths

Run:

grep -n "AttendingMember" src/server/routers/finalist.ts | head -20

Confirm which functions create / delete AttendingMember rows. Expected hits: the confirm mutation and the editAttendees mutation. Note the file:line range you'll need to touch.

  • Step 2: Failing tests

Create tests/unit/lunch-pick-sync.test.ts:

import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma } from '../setup'
import {
  createTestUser,
  createTestProgram,
  createTestCompetition,
  createTestRound,
  cleanupTestData,
  uid,
} from '../helpers'
import { ensureLunchPickForAttendingMember } from '@/server/services/lunch-pick-sync'

describe('ensureLunchPickForAttendingMember', () => {
  let programId: string
  let userId: string
  let projectId: string
  let confirmationId: string

  beforeAll(async () => {
    const program = await createTestProgram()
    programId = program.id
    const competition = await createTestCompetition(programId)
    await createTestRound(competition.id, 'LIVE_FINAL')
    const user = await createTestUser('APPLICANT')
    userId = user.id
    const project = await prisma.project.create({
      data: { programId, name: `lunch-sync-${uid()}`, category: 'IMPACT' },
    })
    projectId = project.id
    const confirmation = await prisma.finalistConfirmation.create({
      data: {
        projectId,
        category: 'IMPACT',
        status: 'CONFIRMED',
        deadline: new Date(Date.now() + 86_400_000),
        token: `tok-${uid()}`,
      },
    })
    confirmationId = confirmation.id
  })

  afterAll(async () => {
    await cleanupTestData()
  })

  it('creates an empty MemberLunchPick when a LunchEvent exists', async () => {
    await prisma.lunchEvent.create({ data: { programId } })
    const member = await prisma.attendingMember.create({
      data: { confirmationId, userId },
    })
    await ensureLunchPickForAttendingMember(prisma, member.id)
    const pick = await prisma.memberLunchPick.findUnique({
      where: { attendingMemberId: member.id },
    })
    expect(pick).not.toBeNull()
    expect(pick?.dishId).toBeNull()
    expect(pick?.pickedAt).toBeNull()
  })

  it('is idempotent — calling twice does not create a second pick', async () => {
    const member = await prisma.attendingMember.findFirst({
      where: { confirmation: { projectId } },
    })
    if (!member) throw new Error('expected member from previous test')
    await ensureLunchPickForAttendingMember(prisma, member.id)
    const picks = await prisma.memberLunchPick.findMany({
      where: { attendingMemberId: member.id },
    })
    expect(picks).toHaveLength(1)
  })

  it('no-ops when no LunchEvent exists for the program', async () => {
    const program2 = await createTestProgram()
    const competition2 = await createTestCompetition(program2.id)
    await createTestRound(competition2.id, 'LIVE_FINAL')
    const project2 = await prisma.project.create({
      data: { programId: program2.id, name: `np-${uid()}`, category: 'IMPACT' },
    })
    const conf2 = await prisma.finalistConfirmation.create({
      data: {
        projectId: project2.id,
        category: 'IMPACT',
        status: 'CONFIRMED',
        deadline: new Date(Date.now() + 86_400_000),
        token: `tok-${uid()}`,
      },
    })
    const u2 = await createTestUser('APPLICANT')
    const member = await prisma.attendingMember.create({
      data: { confirmationId: conf2.id, userId: u2.id },
    })
    await ensureLunchPickForAttendingMember(prisma, member.id)
    const pick = await prisma.memberLunchPick.findUnique({
      where: { attendingMemberId: member.id },
    })
    expect(pick).toBeNull()
  })
})
  • Step 3: Run, expect failure (file does not exist):
npx vitest run tests/unit/lunch-pick-sync.test.ts

Expected: import error / module not found.

  • Step 4: Implement the helper

Create src/server/services/lunch-pick-sync.ts:

import type { PrismaClient } from '@prisma/client'

/**
 * Ensure a MemberLunchPick row exists for the given AttendingMember.
 * No-ops when the parent program has no LunchEvent.
 * Safe to call repeatedly — idempotent on the unique attendingMemberId.
 */
export async function ensureLunchPickForAttendingMember(
  prisma: PrismaClient,
  attendingMemberId: string,
): Promise<void> {
  const member = await prisma.attendingMember.findUnique({
    where: { id: attendingMemberId },
    select: {
      id: true,
      confirmation: { select: { project: { select: { programId: true } } } },
      lunchPick: { select: { id: true } },
    },
  })
  if (!member) return
  if (member.lunchPick) return
  const programId = member.confirmation.project.programId
  const lunchEvent = await prisma.lunchEvent.findUnique({
    where: { programId },
    select: { id: true },
  })
  if (!lunchEvent) return
  await prisma.memberLunchPick.create({
    data: { attendingMemberId: member.id },
  })
}
  • Step 5: Run, expect green
npx vitest run tests/unit/lunch-pick-sync.test.ts
  • Step 6: Wire into finalist.ts

In src/server/routers/finalist.ts, after every prisma.attendingMember.create({...}) call, call await ensureLunchPickForAttendingMember(ctx.prisma, member.id). Confirm the existing import block at the top of the file gets the new import:

import { ensureLunchPickForAttendingMember } from '@/server/services/lunch-pick-sync'

For each create site (typical example):

const member = await ctx.prisma.attendingMember.create({ data: { confirmationId, userId } })
await ensureLunchPickForAttendingMember(ctx.prisma, member.id)
  • Step 7: Run the full test suite
npx vitest run

Expected: all green (existing finalist tests should still pass — the helper is a no-op for programs without a LunchEvent).

  • Step 8: Commit
git add src/server/services/lunch-pick-sync.ts src/server/routers/finalist.ts tests/unit/lunch-pick-sync.test.ts
git commit -m "feat: auto-create MemberLunchPick on attendee writes"

Task 3: lunch.getEvent + lunch.updateEvent (TDD)

Files:

  • Create: src/server/routers/lunch.ts

  • Modify: src/server/routers/_app.ts

  • Create: tests/unit/lunch-router.test.ts

  • Step 1: Failing tests at the top of tests/unit/lunch-router.test.ts:

import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma } from '../setup'
import { createTestUser, createTestProgram, cleanupTestData, uid } from '../helpers'
import { createCaller } from '../helpers'
import * as lunchRouter from '@/server/routers/lunch'

describe('lunch.getEvent', () => {
  it('lazily creates a LunchEvent on first call', async () => {
    const program = await createTestProgram()
    const admin = await createTestUser('SUPER_ADMIN')
    const caller = createCaller(lunchRouter, admin)
    const result = await caller.getEvent({ programId: program.id })
    expect(result.programId).toBe(program.id)
    expect(result.enabled).toBe(false)
    expect(result.changeCutoffHours).toBe(48)
    const row = await prisma.lunchEvent.findUnique({ where: { programId: program.id } })
    expect(row).not.toBeNull()
  })

  it('returns the same row on subsequent calls', async () => {
    const program = await createTestProgram()
    const admin = await createTestUser('SUPER_ADMIN')
    const caller = createCaller(lunchRouter, admin)
    const a = await caller.getEvent({ programId: program.id })
    const b = await caller.getEvent({ programId: program.id })
    expect(a.id).toBe(b.id)
  })
})

describe('lunch.updateEvent', () => {
  it('patches an arbitrary subset of fields', async () => {
    const program = await createTestProgram()
    const admin = await createTestUser('SUPER_ADMIN')
    const caller = createCaller(lunchRouter, admin)
    await caller.getEvent({ programId: program.id })
    const updated = await caller.updateEvent({
      programId: program.id,
      enabled: true,
      eventAt: new Date('2026-06-28T12:30:00Z'),
      venue: 'Hôtel Hermitage',
      changeCutoffHours: 24,
      extraRecipients: ['caterer@example.com'],
    })
    expect(updated.enabled).toBe(true)
    expect(updated.venue).toBe('Hôtel Hermitage')
    expect(updated.changeCutoffHours).toBe(24)
    expect(updated.extraRecipients).toEqual(['caterer@example.com'])
  })

  it('rejects non-admin callers', async () => {
    const program = await createTestProgram()
    const member = await createTestUser('APPLICANT')
    const caller = createCaller(lunchRouter, member)
    await expect(caller.updateEvent({ programId: program.id, enabled: true })).rejects.toThrow()
  })
})

afterAll(async () => {
  await cleanupTestData()
})
  • Step 2: Run, expect failure — module not found.

  • Step 3: Create the router skeleton

src/server/routers/lunch.ts:

import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, protectedProcedure } from '@/server/trpc'

export const lunchRouter = router({
  getEvent: adminProcedure
    .input(z.object({ programId: z.string() }))
    .query(async ({ ctx, input }) => {
      const existing = await ctx.prisma.lunchEvent.findUnique({
        where: { programId: input.programId },
      })
      if (existing) return existing
      return ctx.prisma.lunchEvent.create({ data: { programId: input.programId } })
    }),

  updateEvent: adminProcedure
    .input(
      z.object({
        programId: z.string(),
        enabled: z.boolean().optional(),
        eventAt: z.date().nullable().optional(),
        endAt: z.date().nullable().optional(),
        venue: z.string().nullable().optional(),
        notes: z.string().nullable().optional(),
        changeCutoffHours: z.number().int().min(0).max(720).optional(),
        reminderHoursBeforeDeadline: z.number().int().min(0).max(720).nullable().optional(),
        cronEnabled: z.boolean().optional(),
        extraRecipients: z.array(z.string().email()).optional(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const { programId, ...patch } = input
      // Ensure the event exists (lazy create) before patching
      await ctx.prisma.lunchEvent.upsert({
        where: { programId },
        create: { programId },
        update: {},
      })
      const updated = await ctx.prisma.lunchEvent.update({
        where: { programId },
        data: patch,
      })
      await ctx.prisma.decisionAuditLog.create({
        data: {
          eventType: 'LUNCH_EVENT_UPDATED',
          entityType: 'LunchEvent',
          entityId: updated.id,
          actorId: ctx.session.user.id,
          detailsJson: patch,
        },
      })
      return updated
    }),
})
  • Step 4: Mount the router in src/server/routers/_app.ts:
import { lunchRouter } from './lunch'
// ...
export const appRouter = router({
  // ...existing routers...
  lunch: lunchRouter,
})
  • Step 5: Run tests, expect green
npx vitest run tests/unit/lunch-router.test.ts
  • Step 6: Commit
git add src/server/routers/lunch.ts src/server/routers/_app.ts tests/unit/lunch-router.test.ts
git commit -m "feat: lunch.getEvent + lunch.updateEvent procedures"

Task 4: Dish CRUD (TDD)

Files:

  • Modify: src/server/routers/lunch.ts

  • Modify: tests/unit/lunch-router.test.ts

  • Step 1: Failing tests appended to lunch-router.test.ts:

describe('dish CRUD', () => {
  it('createDish + listDishes returns dishes ordered by sortOrder', async () => {
    const program = await createTestProgram()
    const admin = await createTestUser('SUPER_ADMIN')
    const caller = createCaller(lunchRouter, admin)
    const event = await caller.getEvent({ programId: program.id })
    await caller.createDish({ lunchEventId: event.id, name: 'Sea bass', dietaryTags: ['PESCATARIAN'], sortOrder: 1 })
    await caller.createDish({ lunchEventId: event.id, name: 'Risotto', dietaryTags: ['VEGETARIAN'], sortOrder: 0 })
    const dishes = await caller.listDishes({ lunchEventId: event.id })
    expect(dishes.map(d => d.name)).toEqual(['Risotto', 'Sea bass'])
  })

  it('updateDish patches name + tags', async () => {
    const program = await createTestProgram()
    const admin = await createTestUser('SUPER_ADMIN')
    const caller = createCaller(lunchRouter, admin)
    const event = await caller.getEvent({ programId: program.id })
    const dish = await caller.createDish({ lunchEventId: event.id, name: 'A', dietaryTags: [] })
    const updated = await caller.updateDish({ dishId: dish.id, name: 'B', dietaryTags: ['VEGAN'] })
    expect(updated.name).toBe('B')
    expect(updated.dietaryTags).toEqual(['VEGAN'])
  })

  it('deleteDish sets dishId=null on existing picks', async () => {
    const program = await createTestProgram()
    const admin = await createTestUser('SUPER_ADMIN')
    const caller = createCaller(lunchRouter, admin)
    const event = await caller.getEvent({ programId: program.id })
    const dish = await caller.createDish({ lunchEventId: event.id, name: 'X', dietaryTags: [] })
    // Stand up an attending member with a pick referencing this dish
    const user = await createTestUser('APPLICANT')
    const project = await prisma.project.create({
      data: { programId: program.id, name: `proj-${uid()}`, category: 'IMPACT' },
    })
    const conf = await prisma.finalistConfirmation.create({
      data: { projectId: project.id, category: 'IMPACT', status: 'CONFIRMED',
              deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}` },
    })
    const member = await prisma.attendingMember.create({
      data: { confirmationId: conf.id, userId: user.id },
    })
    await prisma.memberLunchPick.create({
      data: { attendingMemberId: member.id, dishId: dish.id, pickedAt: new Date() },
    })
    await caller.deleteDish({ dishId: dish.id })
    const pick = await prisma.memberLunchPick.findUnique({
      where: { attendingMemberId: member.id },
    })
    expect(pick?.dishId).toBeNull()
  })

  it('reorderDishes commits new sortOrder values', async () => {
    const program = await createTestProgram()
    const admin = await createTestUser('SUPER_ADMIN')
    const caller = createCaller(lunchRouter, admin)
    const event = await caller.getEvent({ programId: program.id })
    const a = await caller.createDish({ lunchEventId: event.id, name: 'a', dietaryTags: [], sortOrder: 0 })
    const b = await caller.createDish({ lunchEventId: event.id, name: 'b', dietaryTags: [], sortOrder: 1 })
    await caller.reorderDishes({ ordered: [{ dishId: b.id, sortOrder: 0 }, { dishId: a.id, sortOrder: 1 }] })
    const dishes = await caller.listDishes({ lunchEventId: event.id })
    expect(dishes.map(d => d.name)).toEqual(['b', 'a'])
  })
})
  • Step 2: Run, expect failure.

  • Step 3: Add procedures to lunch.ts inside the existing router({ ... }):

const dietaryTags = z.array(z.enum(['VEGETARIAN', 'VEGAN', 'GLUTEN_FREE', 'PESCATARIAN']))

listDishes: adminProcedure
  .input(z.object({ lunchEventId: z.string() }))
  .query(({ ctx, input }) =>
    ctx.prisma.dish.findMany({
      where: { lunchEventId: input.lunchEventId },
      orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
    }),
  ),

createDish: adminProcedure
  .input(z.object({
    lunchEventId: z.string(),
    name: z.string().min(1).max(200),
    dietaryTags,
    sortOrder: z.number().int().optional(),
  }))
  .mutation(async ({ ctx, input }) => {
    const dish = await ctx.prisma.dish.create({
      data: {
        lunchEventId: input.lunchEventId,
        name: input.name,
        dietaryTags: input.dietaryTags,
        sortOrder: input.sortOrder ?? 0,
      },
    })
    await ctx.prisma.decisionAuditLog.create({
      data: {
        eventType: 'LUNCH_DISH_CREATED',
        entityType: 'Dish',
        entityId: dish.id,
        actorId: ctx.session.user.id,
        detailsJson: { name: dish.name, dietaryTags: dish.dietaryTags },
      },
    })
    return dish
  }),

updateDish: adminProcedure
  .input(z.object({
    dishId: z.string(),
    name: z.string().min(1).max(200).optional(),
    dietaryTags: dietaryTags.optional(),
    sortOrder: z.number().int().optional(),
  }))
  .mutation(async ({ ctx, input }) => {
    const { dishId, ...patch } = input
    const dish = await ctx.prisma.dish.update({ where: { id: dishId }, data: patch })
    await ctx.prisma.decisionAuditLog.create({
      data: {
        eventType: 'LUNCH_DISH_UPDATED',
        entityType: 'Dish',
        entityId: dish.id,
        actorId: ctx.session.user.id,
        detailsJson: patch,
      },
    })
    return dish
  }),

deleteDish: adminProcedure
  .input(z.object({ dishId: z.string() }))
  .mutation(async ({ ctx, input }) => {
    const dish = await ctx.prisma.dish.delete({ where: { id: input.dishId } })
    await ctx.prisma.decisionAuditLog.create({
      data: {
        eventType: 'LUNCH_DISH_DELETED',
        entityType: 'Dish',
        entityId: dish.id,
        actorId: ctx.session.user.id,
        detailsJson: { name: dish.name },
      },
    })
    return { ok: true as const }
  }),

reorderDishes: adminProcedure
  .input(z.object({
    ordered: z.array(z.object({ dishId: z.string(), sortOrder: z.number().int() })),
  }))
  .mutation(async ({ ctx, input }) => {
    await ctx.prisma.$transaction(
      input.ordered.map(({ dishId, sortOrder }) =>
        ctx.prisma.dish.update({ where: { id: dishId }, data: { sortOrder } }),
      ),
    )
    return { ok: true as const }
  }),
  • Step 4: Run, expect green.

  • Step 5: Commit

git add src/server/routers/lunch.ts tests/unit/lunch-router.test.ts
git commit -m "feat: dish CRUD on lunch router"

Task 5: External attendees CRUD (TDD)

Files:

  • Modify: src/server/routers/lunch.ts

  • Modify: tests/unit/lunch-router.test.ts

  • Step 1: Failing tests:

describe('external attendees CRUD', () => {
  it('listExternals returns standalone + project-attached entries', async () => {
    const program = await createTestProgram()
    const admin = await createTestUser('SUPER_ADMIN')
    const caller = createCaller(lunchRouter, admin)
    const event = await caller.getEvent({ programId: program.id })
    const project = await prisma.project.create({
      data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
    })
    await caller.createExternal({
      lunchEventId: event.id, name: 'Princess Albert',
      roleNote: 'Foundation rep',
    })
    await caller.createExternal({
      lunchEventId: event.id, name: 'Speaker Smith',
      projectId: project.id, email: 's@example.com',
    })
    const list = await caller.listExternals({ lunchEventId: event.id })
    expect(list).toHaveLength(2)
    expect(list.find(e => e.name === 'Princess Albert')?.projectId).toBeNull()
    expect(list.find(e => e.name === 'Speaker Smith')?.projectId).toBe(project.id)
  })

  it('updateExternal patches fields including dishId + allergens', async () => {
    const program = await createTestProgram()
    const admin = await createTestUser('SUPER_ADMIN')
    const caller = createCaller(lunchRouter, admin)
    const event = await caller.getEvent({ programId: program.id })
    const dish = await caller.createDish({ lunchEventId: event.id, name: 'Steak', dietaryTags: [] })
    const ext = await caller.createExternal({ lunchEventId: event.id, name: 'X' })
    const updated = await caller.updateExternal({
      externalId: ext.id, dishId: dish.id, allergens: ['GLUTEN', 'TREE_NUTS'],
      allergenOther: 'sulphites in red wine',
    })
    expect(updated.dishId).toBe(dish.id)
    expect(updated.allergens).toEqual(['GLUTEN', 'TREE_NUTS'])
  })

  it('deleteExternal removes the row', async () => {
    const program = await createTestProgram()
    const admin = await createTestUser('SUPER_ADMIN')
    const caller = createCaller(lunchRouter, admin)
    const event = await caller.getEvent({ programId: program.id })
    const ext = await caller.createExternal({ lunchEventId: event.id, name: 'tmp' })
    await caller.deleteExternal({ externalId: ext.id })
    const list = await caller.listExternals({ lunchEventId: event.id })
    expect(list.find(e => e.id === ext.id)).toBeUndefined()
  })

  it('rejects non-admin callers', async () => {
    const program = await createTestProgram()
    const admin = await createTestUser('SUPER_ADMIN')
    const adminCaller = createCaller(lunchRouter, admin)
    const event = await adminCaller.getEvent({ programId: program.id })
    const member = await createTestUser('APPLICANT')
    const memberCaller = createCaller(lunchRouter, member)
    await expect(
      memberCaller.createExternal({ lunchEventId: event.id, name: 'nope' }),
    ).rejects.toThrow()
  })
})
  • Step 2: Run, expect failure.

  • Step 3: Add procedures:

const allergens = z.array(z.enum([
  'GLUTEN', 'CRUSTACEANS', 'EGGS', 'FISH', 'PEANUTS', 'SOYBEANS', 'MILK',
  'TREE_NUTS', 'CELERY', 'MUSTARD', 'SESAME', 'SULPHITES', 'LUPIN', 'MOLLUSCS',
]))

listExternals: adminProcedure
  .input(z.object({ lunchEventId: z.string() }))
  .query(({ ctx, input }) =>
    ctx.prisma.externalAttendee.findMany({
      where: { lunchEventId: input.lunchEventId },
      orderBy: { createdAt: 'asc' },
      include: { project: { select: { id: true, name: true } } },
    }),
  ),

createExternal: adminProcedure
  .input(z.object({
    lunchEventId: z.string(),
    name: z.string().min(1).max(200),
    email: z.string().email().optional(),
    projectId: z.string().nullable().optional(),
    roleNote: z.string().max(500).optional(),
    dishId: z.string().nullable().optional(),
    allergens: allergens.optional(),
    allergenOther: z.string().max(500).optional(),
  }))
  .mutation(async ({ ctx, input }) => {
    const ext = await ctx.prisma.externalAttendee.create({ data: input })
    await ctx.prisma.decisionAuditLog.create({
      data: {
        eventType: 'LUNCH_EXTERNAL_CREATED', entityType: 'ExternalAttendee',
        entityId: ext.id, actorId: ctx.session.user.id,
        detailsJson: { name: ext.name, projectId: ext.projectId },
      },
    })
    return ext
  }),

updateExternal: adminProcedure
  .input(z.object({
    externalId: z.string(),
    name: z.string().min(1).max(200).optional(),
    email: z.string().email().nullable().optional(),
    projectId: z.string().nullable().optional(),
    roleNote: z.string().max(500).nullable().optional(),
    dishId: z.string().nullable().optional(),
    allergens: allergens.optional(),
    allergenOther: z.string().max(500).nullable().optional(),
  }))
  .mutation(async ({ ctx, input }) => {
    const { externalId, ...patch } = input
    const ext = await ctx.prisma.externalAttendee.update({
      where: { id: externalId }, data: patch,
    })
    await ctx.prisma.decisionAuditLog.create({
      data: {
        eventType: 'LUNCH_EXTERNAL_UPDATED', entityType: 'ExternalAttendee',
        entityId: ext.id, actorId: ctx.session.user.id, detailsJson: patch,
      },
    })
    return ext
  }),

deleteExternal: adminProcedure
  .input(z.object({ externalId: z.string() }))
  .mutation(async ({ ctx, input }) => {
    const ext = await ctx.prisma.externalAttendee.delete({ where: { id: input.externalId } })
    await ctx.prisma.decisionAuditLog.create({
      data: {
        eventType: 'LUNCH_EXTERNAL_DELETED', entityType: 'ExternalAttendee',
        entityId: ext.id, actorId: ctx.session.user.id,
        detailsJson: { name: ext.name },
      },
    })
    return { ok: true as const }
  }),
  • Step 4: Run, expect green.

  • Step 5: Commit

git add src/server/routers/lunch.ts tests/unit/lunch-router.test.ts
git commit -m "feat: external attendees CRUD"

Task 6: upsertPick mixed-permission procedure (TDD)

Files:

  • Modify: src/server/routers/lunch.ts

  • Create: tests/unit/lunch-upsert-pick.test.ts

  • Step 1: Failing tests at tests/unit/lunch-upsert-pick.test.ts:

import { describe, it, expect, beforeEach, afterAll } from 'vitest'
import { prisma } from '../setup'
import {
  createTestUser, createTestProgram, cleanupTestData, uid, createCaller,
} from '../helpers'
import * as lunchRouter from '@/server/routers/lunch'

async function setupTeam(opts: {
  cutoffHours?: number
  eventAt?: Date
  enabled?: boolean
}) {
  const program = await createTestProgram()
  const lead = await createTestUser('APPLICANT')
  const member = await createTestUser('APPLICANT')
  const admin = await createTestUser('SUPER_ADMIN')
  const project = await prisma.project.create({
    data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
  })
  await prisma.teamMember.createMany({
    data: [
      { projectId: project.id, userId: lead.id, role: 'LEAD' },
      { projectId: project.id, userId: member.id, role: 'MEMBER' },
    ],
  })
  const conf = await prisma.finalistConfirmation.create({
    data: {
      projectId: project.id, category: 'IMPACT', status: 'CONFIRMED',
      deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}`,
    },
  })
  const am = await prisma.attendingMember.create({
    data: { confirmationId: conf.id, userId: member.id },
  })
  const event = await prisma.lunchEvent.create({
    data: {
      programId: program.id,
      enabled: opts.enabled ?? true,
      eventAt: opts.eventAt ?? new Date(Date.now() + 7 * 86_400_000),
      changeCutoffHours: opts.cutoffHours ?? 48,
    },
  })
  const dish = await prisma.dish.create({
    data: { lunchEventId: event.id, name: 'Risotto', dietaryTags: ['VEGETARIAN'] },
  })
  await prisma.memberLunchPick.create({ data: { attendingMemberId: am.id } })
  return { program, lead, member, admin, project, attendingMember: am, dish, event }
}

afterAll(async () => { await cleanupTestData() })

describe('lunch.upsertPick', () => {
  it('member can edit their own pick before deadline', async () => {
    const t = await setupTeam({})
    const caller = createCaller(lunchRouter, t.member)
    const result = await caller.upsertPick({
      attendingMemberId: t.attendingMember.id, dishId: t.dish.id,
      allergens: ['GLUTEN'], allergenOther: null,
    })
    expect(result.dishId).toBe(t.dish.id)
    expect(result.pickedAt).not.toBeNull()
    const audit = await prisma.decisionAuditLog.findFirst({
      where: { eventType: 'LUNCH_PICK_UPDATED', entityId: result.id },
      orderBy: { createdAt: 'desc' },
    })
    expect(audit?.detailsJson).toMatchObject({ actorRole: 'SELF' })
  })

  it('team lead can edit a teammate pick before deadline', async () => {
    const t = await setupTeam({})
    const caller = createCaller(lunchRouter, t.lead)
    const result = await caller.upsertPick({
      attendingMemberId: t.attendingMember.id, dishId: t.dish.id,
      allergens: [], allergenOther: null,
    })
    expect(result.dishId).toBe(t.dish.id)
    const audit = await prisma.decisionAuditLog.findFirst({
      where: { eventType: 'LUNCH_PICK_UPDATED', entityId: result.id },
      orderBy: { createdAt: 'desc' },
    })
    expect(audit?.detailsJson).toMatchObject({ actorRole: 'TEAM_LEAD' })
  })

  it('member from a different team is forbidden', async () => {
    const t = await setupTeam({})
    const stranger = await createTestUser('APPLICANT')
    const caller = createCaller(lunchRouter, stranger)
    await expect(
      caller.upsertPick({ attendingMemberId: t.attendingMember.id, dishId: t.dish.id,
                         allergens: [], allergenOther: null }),
    ).rejects.toThrow(/FORBIDDEN/)
  })

  it('member cannot edit their own pick after deadline', async () => {
    // event is "now + 1h", cutoffHours = 24 -> deadline already passed
    const t = await setupTeam({
      eventAt: new Date(Date.now() + 60 * 60 * 1000), cutoffHours: 24,
    })
    const caller = createCaller(lunchRouter, t.member)
    await expect(
      caller.upsertPick({ attendingMemberId: t.attendingMember.id, dishId: t.dish.id,
                         allergens: [], allergenOther: null }),
    ).rejects.toThrow(/deadline/i)
  })

  it('admin can edit after deadline; audit records ADMIN role', async () => {
    const t = await setupTeam({
      eventAt: new Date(Date.now() + 60 * 60 * 1000), cutoffHours: 24,
    })
    const caller = createCaller(lunchRouter, t.admin)
    const result = await caller.upsertPick({
      attendingMemberId: t.attendingMember.id, dishId: t.dish.id,
      allergens: [], allergenOther: null,
    })
    expect(result.dishId).toBe(t.dish.id)
    const audit = await prisma.decisionAuditLog.findFirst({
      where: { eventType: 'LUNCH_PICK_UPDATED', entityId: result.id },
      orderBy: { createdAt: 'desc' },
    })
    expect(audit?.detailsJson).toMatchObject({ actorRole: 'ADMIN' })
  })
})
  • Step 2: Run, expect failure.

  • Step 3: Add the procedure

In lunch.ts:

upsertPick: protectedProcedure
  .input(z.object({
    attendingMemberId: z.string(),
    dishId: z.string().nullable(),
    allergens,
    allergenOther: z.string().max(500).nullable(),
  }))
  .mutation(async ({ ctx, input }) => {
    const am = await ctx.prisma.attendingMember.findUnique({
      where: { id: input.attendingMemberId },
      include: {
        confirmation: {
          select: {
            project: {
              select: {
                id: true,
                programId: true,
                teamMembers: { select: { userId: true, role: true } },
              },
            },
          },
        },
        lunchPick: true,
      },
    })
    if (!am) throw new TRPCError({ code: 'NOT_FOUND', message: 'Attending member not found' })

    const userId = ctx.session.user.id
    const userRole = ctx.session.user.role
    const isAdmin = userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN'
    const isSelf = am.userId === userId
    const isLead = am.confirmation.project.teamMembers.some(
      (tm) => tm.userId === userId && tm.role === 'LEAD',
    )
    if (!isAdmin && !isSelf && !isLead) {
      throw new TRPCError({ code: 'FORBIDDEN', message: 'Not allowed to edit this pick' })
    }

    // Cutoff check (admins skip)
    if (!isAdmin) {
      const event = await ctx.prisma.lunchEvent.findUnique({
        where: { programId: am.confirmation.project.programId },
        select: { eventAt: true, changeCutoffHours: true },
      })
      if (event?.eventAt) {
        const deadline = new Date(event.eventAt.getTime() - event.changeCutoffHours * 3600_000)
        if (new Date() > deadline) {
          throw new TRPCError({
            code: 'PRECONDITION_FAILED',
            message: 'Past lunch change deadline. Contact an admin.',
          })
        }
      }
    }

    const actorRole: 'SELF' | 'TEAM_LEAD' | 'ADMIN' = isAdmin ? 'ADMIN' : isLead && !isSelf ? 'TEAM_LEAD' : 'SELF'
    const pick = await ctx.prisma.memberLunchPick.upsert({
      where: { attendingMemberId: input.attendingMemberId },
      create: {
        attendingMemberId: input.attendingMemberId,
        dishId: input.dishId,
        allergens: input.allergens,
        allergenOther: input.allergenOther,
        pickedAt: input.dishId ? new Date() : null,
      },
      update: {
        dishId: input.dishId,
        allergens: input.allergens,
        allergenOther: input.allergenOther,
        pickedAt: input.dishId ? new Date() : null,
      },
    })
    await ctx.prisma.decisionAuditLog.create({
      data: {
        eventType: 'LUNCH_PICK_UPDATED', entityType: 'MemberLunchPick',
        entityId: pick.id, actorId: userId,
        detailsJson: {
          actorRole,
          dishId: input.dishId,
          allergenCount: input.allergens.length,
        },
      },
    })
    return pick
  }),
  • Step 4: Run, expect green.

  • Step 5: Commit

git add src/server/routers/lunch.ts tests/unit/lunch-upsert-pick.test.ts
git commit -m "feat: lunch.upsertPick with role-aware guard + cutoff"

Task 7: Member reads — getEventForMember + getTeamPicks (TDD)

Files:

  • Modify: src/server/routers/lunch.ts

  • Modify: tests/unit/lunch-router.test.ts

  • Step 1: Failing tests:

describe('lunch.getEventForMember', () => {
  it('returns event details when enabled', async () => {
    const program = await createTestProgram()
    const member = await createTestUser('APPLICANT')
    await prisma.lunchEvent.create({
      data: {
        programId: program.id, enabled: true,
        eventAt: new Date('2026-06-28T12:30:00Z'), venue: 'Hôtel',
      },
    })
    const caller = createCaller(lunchRouter, member)
    const result = await caller.getEventForMember({ programId: program.id })
    expect(result?.venue).toBe('Hôtel')
  })

  it('returns null when event is disabled', async () => {
    const program = await createTestProgram()
    const member = await createTestUser('APPLICANT')
    await prisma.lunchEvent.create({
      data: { programId: program.id, enabled: false },
    })
    const caller = createCaller(lunchRouter, member)
    const result = await caller.getEventForMember({ programId: program.id })
    expect(result).toBeNull()
  })
})

describe('lunch.getTeamPicks', () => {
  it('returns picks for all attending members of the caller team', async () => {
    // caller is a TeamMember of project P, P has two AttendingMembers with picks
    const program = await createTestProgram()
    const lead = await createTestUser('APPLICANT')
    const m1 = await createTestUser('APPLICANT')
    const m2 = await createTestUser('APPLICANT')
    const project = await prisma.project.create({
      data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
    })
    await prisma.teamMember.createMany({
      data: [
        { projectId: project.id, userId: lead.id, role: 'LEAD' },
        { projectId: project.id, userId: m1.id, role: 'MEMBER' },
        { projectId: project.id, userId: m2.id, role: 'MEMBER' },
      ],
    })
    const conf = await prisma.finalistConfirmation.create({
      data: {
        projectId: project.id, category: 'IMPACT', status: 'CONFIRMED',
        deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}`,
      },
    })
    const am1 = await prisma.attendingMember.create({
      data: { confirmationId: conf.id, userId: m1.id },
    })
    const am2 = await prisma.attendingMember.create({
      data: { confirmationId: conf.id, userId: m2.id },
    })
    const event = await prisma.lunchEvent.create({
      data: { programId: program.id, enabled: true },
    })
    const dish = await prisma.dish.create({
      data: { lunchEventId: event.id, name: 'X', dietaryTags: [] },
    })
    await prisma.memberLunchPick.create({
      data: { attendingMemberId: am1.id, dishId: dish.id, pickedAt: new Date() },
    })
    await prisma.memberLunchPick.create({ data: { attendingMemberId: am2.id } })

    const caller = createCaller(lunchRouter, lead)
    const picks = await caller.getTeamPicks({ projectId: project.id })
    expect(picks).toHaveLength(2)
    expect(picks.find(p => p.userId === m1.id)?.hasPicked).toBe(true)
    expect(picks.find(p => p.userId === m2.id)?.hasPicked).toBe(false)
  })

  it('rejects non-team-member callers', async () => {
    const program = await createTestProgram()
    const stranger = await createTestUser('APPLICANT')
    const project = await prisma.project.create({
      data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
    })
    const caller = createCaller(lunchRouter, stranger)
    await expect(
      caller.getTeamPicks({ projectId: project.id }),
    ).rejects.toThrow(/FORBIDDEN/)
  })
})
  • Step 2: Run, expect failure.

  • Step 3: Add procedures:

getEventForMember: protectedProcedure
  .input(z.object({ programId: z.string() }))
  .query(async ({ ctx, input }) => {
    const event = await ctx.prisma.lunchEvent.findUnique({
      where: { programId: input.programId },
      select: {
        id: true, enabled: true, eventAt: true, endAt: true,
        venue: true, notes: true, changeCutoffHours: true,
      },
    })
    if (!event || !event.enabled) return null
    const changeDeadline = event.eventAt
      ? new Date(event.eventAt.getTime() - event.changeCutoffHours * 3600_000)
      : null
    return { ...event, changeDeadline }
  }),

getTeamPicks: protectedProcedure
  .input(z.object({ projectId: z.string() }))
  .query(async ({ ctx, input }) => {
    const userId = ctx.session.user.id
    const role = ctx.session.user.role
    const isAdmin = role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN'
    if (!isAdmin) {
      const tm = await ctx.prisma.teamMember.findFirst({
        where: { projectId: input.projectId, userId },
      })
      if (!tm) throw new TRPCError({ code: 'FORBIDDEN' })
    }
    const ams = await ctx.prisma.attendingMember.findMany({
      where: { confirmation: { projectId: input.projectId } },
      include: {
        user: { select: { id: true, name: true, email: true } },
        lunchPick: { include: { dish: true } },
      },
    })
    return ams.map((am) => ({
      attendingMemberId: am.id,
      userId: am.user.id,
      memberName: am.user.name ?? am.user.email,
      dish: am.lunchPick?.dish ?? null,
      allergens: am.lunchPick?.allergens ?? [],
      allergenOther: am.lunchPick?.allergenOther ?? null,
      hasPicked: !!am.lunchPick?.pickedAt,
    }))
  }),
  • Step 4: Run, expect green.

  • Step 5: Commit

git add src/server/routers/lunch.ts tests/unit/lunch-router.test.ts
git commit -m "feat: member reads — getEventForMember + getTeamPicks"

Task 8: getManifest + exportManifestCsv (TDD)

Files:

  • Modify: src/server/routers/lunch.ts

  • Modify: tests/unit/lunch-router.test.ts

  • Step 1: Failing tests:

describe('lunch.getManifest', () => {
  it('returns confirmed attending members + externals with merged shape', async () => {
    const program = await createTestProgram()
    const admin = await createTestUser('SUPER_ADMIN')
    const m = await createTestUser('APPLICANT')
    const project = await prisma.project.create({
      data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
    })
    const conf = await prisma.finalistConfirmation.create({
      data: {
        projectId: project.id, category: 'IMPACT', status: 'CONFIRMED',
        deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}`,
      },
    })
    const am = await prisma.attendingMember.create({
      data: { confirmationId: conf.id, userId: m.id },
    })
    const event = await prisma.lunchEvent.create({
      data: { programId: program.id, enabled: true },
    })
    const dish = await prisma.dish.create({
      data: { lunchEventId: event.id, name: 'Risotto', dietaryTags: ['VEGETARIAN'] },
    })
    await prisma.memberLunchPick.create({
      data: { attendingMemberId: am.id, dishId: dish.id, pickedAt: new Date() },
    })
    await prisma.externalAttendee.create({
      data: { lunchEventId: event.id, name: 'External Bob', dishId: dish.id },
    })
    const caller = createCaller(lunchRouter, admin)
    const manifest = await caller.getManifest({ programId: program.id })
    expect(manifest.members).toHaveLength(1)
    expect(manifest.externals).toHaveLength(1)
    expect(manifest.summary.picked).toBe(1)
    expect(manifest.summary.missing).toBe(0)
  })

  it('excludes non-CONFIRMED confirmations', async () => {
    const program = await createTestProgram()
    const admin = await createTestUser('SUPER_ADMIN')
    const u = await createTestUser('APPLICANT')
    const project = await prisma.project.create({
      data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
    })
    const conf = await prisma.finalistConfirmation.create({
      data: {
        projectId: project.id, category: 'IMPACT', status: 'PENDING',
        deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}`,
      },
    })
    await prisma.attendingMember.create({
      data: { confirmationId: conf.id, userId: u.id },
    })
    await prisma.lunchEvent.create({ data: { programId: program.id, enabled: true } })
    const caller = createCaller(lunchRouter, admin)
    const manifest = await caller.getManifest({ programId: program.id })
    expect(manifest.members).toHaveLength(0)
  })
})

describe('lunch.exportManifestCsv', () => {
  it('returns a CSV string with header + one row per attendee', async () => {
    const program = await createTestProgram()
    const admin = await createTestUser('SUPER_ADMIN')
    const event = await prisma.lunchEvent.create({
      data: { programId: program.id, enabled: true },
    })
    const dish = await prisma.dish.create({
      data: { lunchEventId: event.id, name: 'Risotto', dietaryTags: [] },
    })
    await prisma.externalAttendee.create({
      data: { lunchEventId: event.id, name: 'X Y', dishId: dish.id, allergens: ['GLUTEN'] },
    })
    const caller = createCaller(lunchRouter, admin)
    const csv = await caller.exportManifestCsv({ programId: program.id })
    expect(csv.split('\n')[0]).toBe('Type,Team,Name,Email,Dish,Allergens,Allergen notes')
    expect(csv).toContain('External,,X Y,,Risotto,GLUTEN,')
  })
})
  • Step 2: Run, expect failure.

  • Step 3: Implement procedures

In lunch.ts:

getManifest: adminProcedure
  .input(z.object({ programId: z.string() }))
  .query(async ({ ctx, input }) => {
    const event = await ctx.prisma.lunchEvent.findUnique({
      where: { programId: input.programId },
      include: { dishes: true },
    })
    if (!event) return { members: [], externals: [], dishes: [], summary: { picked: 0, missing: 0, total: 0 } }
    const ams = await ctx.prisma.attendingMember.findMany({
      where: {
        confirmation: { project: { programId: input.programId }, status: 'CONFIRMED' },
      },
      include: {
        user: { select: { id: true, name: true, email: true } },
        confirmation: { include: { project: { select: { id: true, name: true } } } },
        lunchPick: { include: { dish: true } },
      },
    })
    const externals = await ctx.prisma.externalAttendee.findMany({
      where: { lunchEventId: event.id },
      include: { project: { select: { id: true, name: true } }, dish: true },
    })
    const members = ams.map((am) => ({
      kind: 'MEMBER' as const,
      attendingMemberId: am.id,
      userId: am.user.id,
      name: am.user.name ?? am.user.email,
      email: am.user.email,
      project: { id: am.confirmation.project.id, name: am.confirmation.project.name },
      dish: am.lunchPick?.dish ?? null,
      allergens: am.lunchPick?.allergens ?? [],
      allergenOther: am.lunchPick?.allergenOther ?? null,
      pickedAt: am.lunchPick?.pickedAt ?? null,
    }))
    const ext = externals.map((e) => ({
      kind: 'EXTERNAL' as const,
      externalId: e.id,
      name: e.name,
      email: e.email,
      project: e.project,
      roleNote: e.roleNote,
      dish: e.dish,
      allergens: e.allergens,
      allergenOther: e.allergenOther,
      pickedAt: e.dishId ? e.updatedAt : null,
    }))
    const total = members.length + ext.length
    const picked = members.filter(m => m.dish).length + ext.filter(e => e.dish).length
    return {
      event,
      dishes: event.dishes,
      members,
      externals: ext,
      summary: { total, picked, missing: total - picked },
    }
  }),

exportManifestCsv: adminProcedure
  .input(z.object({ programId: z.string() }))
  .query(async ({ ctx, input }) => {
    const m = await (lunchRouter as any).createCaller(ctx).getManifest({ programId: input.programId })
    const escape = (s: string | null | undefined) => {
      const v = s ?? ''
      return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v
    }
    const lines = [
      'Type,Team,Name,Email,Dish,Allergens,Allergen notes',
      ...m.members.map((row: any) => [
        'Member', escape(row.project?.name), escape(row.name), escape(row.email),
        escape(row.dish?.name), escape((row.allergens as string[]).join(';')),
        escape(row.allergenOther),
      ].join(',')),
      ...m.externals.map((row: any) => [
        'External', escape(row.project?.name), escape(row.name), escape(row.email),
        escape(row.dish?.name), escape((row.allergens as string[]).join(';')),
        escape(row.allergenOther),
      ].join(',')),
    ]
    return lines.join('\n')
  }),

Note: the (lunchRouter as any).createCaller(ctx) cycle is awkward. Cleaner: extract a buildManifest(prisma, programId) helper used by both. Refactor in the next step.

  • Step 4: Refactor — extract buildManifest

Move the body of getManifest into src/server/services/lunch-recap.ts (this file will grow more in Task 9):

import type { PrismaClient } from '@prisma/client'

export async function buildManifest(prisma: PrismaClient, programId: string) {
  const event = await prisma.lunchEvent.findUnique({
    where: { programId },
    include: { dishes: true },
  })
  if (!event) return { event: null, members: [], externals: [], dishes: [], summary: { picked: 0, missing: 0, total: 0 } }
  // ...rest of the body unchanged...
}

Then getManifest becomes:

getManifest: adminProcedure
  .input(z.object({ programId: z.string() }))
  .query(({ ctx, input }) => buildManifest(ctx.prisma, input.programId)),

And exportManifestCsv calls buildManifest(ctx.prisma, input.programId) directly.

  • Step 5: Run, expect green.

  • Step 6: Commit

git add src/server/routers/lunch.ts src/server/services/lunch-recap.ts tests/unit/lunch-router.test.ts
git commit -m "feat: lunch manifest query + CSV export"

Task 9: Recap aggregation + sendRecap + preview (TDD)

Files:

  • Modify: src/server/services/lunch-recap.ts

  • Modify: src/server/routers/lunch.ts

  • Create: tests/unit/lunch-recap.test.ts

  • Step 1: Failing tests at tests/unit/lunch-recap.test.ts:

import { describe, it, expect, afterAll, vi } from 'vitest'
import { prisma } from '../setup'
import { createTestUser, createTestProgram, cleanupTestData, uid, createCaller } from '../helpers'
import * as lunchRouter from '@/server/routers/lunch'
import { buildRecapPayload } from '@/server/services/lunch-recap'

vi.mock('@/lib/email', async () => {
  const actual = await vi.importActual<any>('@/lib/email')
  return { ...actual, sendLunchRecapEmail: vi.fn(async () => undefined) }
})

afterAll(async () => { await cleanupTestData() })

describe('buildRecapPayload', () => {
  it('aggregates dish + dietary + allergen counts', async () => {
    const program = await createTestProgram()
    const event = await prisma.lunchEvent.create({
      data: { programId: program.id, enabled: true },
    })
    const veg = await prisma.dish.create({
      data: { lunchEventId: event.id, name: 'Risotto', dietaryTags: ['VEGETARIAN'] },
    })
    const fish = await prisma.dish.create({
      data: { lunchEventId: event.id, name: 'Sea bass', dietaryTags: ['PESCATARIAN'] },
    })
    await prisma.externalAttendee.create({
      data: { lunchEventId: event.id, name: 'A', dishId: veg.id, allergens: ['GLUTEN'] },
    })
    await prisma.externalAttendee.create({
      data: { lunchEventId: event.id, name: 'B', dishId: fish.id, allergens: ['GLUTEN', 'FISH'] },
    })
    const payload = await buildRecapPayload(prisma, program.id)
    expect(payload.dishCounts['Risotto']).toBe(1)
    expect(payload.dishCounts['Sea bass']).toBe(1)
    expect(payload.dietaryCounts['VEGETARIAN']).toBe(1)
    expect(payload.allergenCounts['GLUTEN']).toBe(2)
  })
})

describe('lunch.sendRecap', () => {
  it('sends and stamps recapSentAt', async () => {
    const program = await createTestProgram()
    const admin = await createTestUser('SUPER_ADMIN')
    await prisma.lunchEvent.create({
      data: { programId: program.id, enabled: true },
    })
    const caller = createCaller(lunchRouter, admin)
    await caller.sendRecap({ programId: program.id })
    const row = await prisma.lunchEvent.findUnique({ where: { programId: program.id } })
    expect(row?.recapSentAt).not.toBeNull()
  })

  it('throws PRECONDITION_FAILED on second send unless forceUpdate', async () => {
    const program = await createTestProgram()
    const admin = await createTestUser('SUPER_ADMIN')
    await prisma.lunchEvent.create({
      data: { programId: program.id, enabled: true, recapSentAt: new Date() },
    })
    const caller = createCaller(lunchRouter, admin)
    await expect(caller.sendRecap({ programId: program.id })).rejects.toThrow(/PRECONDITION_FAILED/)
    await expect(caller.sendRecap({ programId: program.id, forceUpdate: true })).resolves.toBeTruthy()
  })

  it('writes a LUNCH_RECAP_SENT audit row', async () => {
    const program = await createTestProgram()
    const admin = await createTestUser('SUPER_ADMIN')
    await prisma.lunchEvent.create({
      data: { programId: program.id, enabled: true },
    })
    const caller = createCaller(lunchRouter, admin)
    await caller.sendRecap({ programId: program.id })
    const audit = await prisma.decisionAuditLog.findFirst({
      where: { eventType: 'LUNCH_RECAP_SENT' },
      orderBy: { createdAt: 'desc' },
    })
    expect(audit).not.toBeNull()
  })
})
  • Step 2: Run, expect failure.

  • Step 3: Add buildRecapPayload to src/server/services/lunch-recap.ts:

export async function buildRecapPayload(prisma: PrismaClient, programId: string) {
  const m = await buildManifest(prisma, programId)
  const dishCounts: Record<string, number> = {}
  const dietaryCounts: Record<string, number> = {}
  const allergenCounts: Record<string, number> = {}
  const allRows: Array<{ dish: { name: string; dietaryTags: string[] } | null; allergens: string[] }> = [
    ...m.members.map((r: any) => ({ dish: r.dish, allergens: r.allergens })),
    ...m.externals.map((r: any) => ({ dish: r.dish, allergens: r.allergens })),
  ]
  for (const row of allRows) {
    if (row.dish) {
      dishCounts[row.dish.name] = (dishCounts[row.dish.name] ?? 0) + 1
      for (const tag of row.dish.dietaryTags) {
        dietaryCounts[tag] = (dietaryCounts[tag] ?? 0) + 1
      }
    }
    for (const a of row.allergens) {
      allergenCounts[a] = (allergenCounts[a] ?? 0) + 1
    }
  }
  return { event: m.event, members: m.members, externals: m.externals, dishCounts, dietaryCounts, allergenCounts, summary: m.summary }
}
  • Step 4: Add procedures

In lunch.ts:

import { buildManifest, buildRecapPayload } from '@/server/services/lunch-recap'
import { sendLunchRecapEmail } from '@/lib/email'

// ...

getRecapPreview: adminProcedure
  .input(z.object({ programId: z.string() }))
  .query(({ ctx, input }) => buildRecapPayload(ctx.prisma, input.programId)),

sendRecap: adminProcedure
  .input(z.object({ programId: z.string(), forceUpdate: z.boolean().optional() }))
  .mutation(async ({ ctx, input }) => {
    const event = await ctx.prisma.lunchEvent.findUnique({
      where: { programId: input.programId },
    })
    if (!event) throw new TRPCError({ code: 'NOT_FOUND' })
    if (event.recapSentAt && !input.forceUpdate) {
      throw new TRPCError({
        code: 'PRECONDITION_FAILED',
        message: 'Recap already sent. Pass forceUpdate=true to resend.',
      })
    }
    const payload = await buildRecapPayload(ctx.prisma, input.programId)
    const adminUsers = await ctx.prisma.user.findMany({
      where: { role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] }, email: { not: null } },
      select: { email: true },
    })
    const recipients = [
      ...adminUsers.map(u => u.email!).filter(Boolean),
      ...event.extraRecipients,
    ]
    await sendLunchRecapEmail(recipients, payload)
    const updated = await ctx.prisma.lunchEvent.update({
      where: { programId: input.programId },
      data: { recapSentAt: new Date() },
    })
    await ctx.prisma.decisionAuditLog.create({
      data: {
        eventType: 'LUNCH_RECAP_SENT', entityType: 'LunchEvent',
        entityId: event.id, actorId: ctx.session.user.id,
        detailsJson: { recipientCount: recipients.length, forceUpdate: !!input.forceUpdate },
      },
    })
    return updated
  }),
  • Step 5: Run, expect green (this depends on Task 10 stubbing sendLunchRecapEmail; if Task 10 is not yet done, add a stub function in src/lib/email.ts: export async function sendLunchRecapEmail() {}).

  • Step 6: Commit

git add src/server/services/lunch-recap.ts src/server/routers/lunch.ts tests/unit/lunch-recap.test.ts src/lib/email.ts
git commit -m "feat: lunch recap aggregation + sendRecap with forceUpdate gate"

Task 10: Email templates in src/lib/email.ts

Files:

  • Modify: src/lib/email.ts

  • Step 1: Add the reminder template

Append at the bottom of email.ts (above the closing braces / exports):

export async function sendLunchReminderEmail(opts: {
  to: string
  memberName: string
  eventAt: Date
  venue: string | null
  changeDeadline: Date
  pickUrl: string
}): Promise<void> {
  const fmt = new Intl.DateTimeFormat('en-GB', {
    timeZone: 'Europe/Monaco', dateStyle: 'long', timeStyle: 'short',
  })
  const subject = `Pick your lunch dish — deadline ${fmt.format(opts.changeDeadline)} (Monaco)`
  const html = `
    <p>Hi ${opts.memberName ?? 'there'},</p>
    <p>You haven't picked your lunch dish for the Monaco Ocean Protection Challenge grand finale yet.</p>
    <p><strong>Event:</strong> ${fmt.format(opts.eventAt)} (Europe/Monaco)<br/>
    ${opts.venue ? `<strong>Venue:</strong> ${opts.venue}<br/>` : ''}
    <strong>Deadline to pick:</strong> ${fmt.format(opts.changeDeadline)}</p>
    <p><a href="${opts.pickUrl}">Open the picker</a></p>
  `
  const text = `Pick your lunch dish.\nEvent: ${opts.eventAt.toISOString()}\nDeadline: ${opts.changeDeadline.toISOString()}\n${opts.pickUrl}`
  await sendEmail({ to: opts.to, subject, text, html })
}
  • Step 2: Add the recap template
type LunchRecapPayload = {
  event: { eventAt: Date | null; venue: string | null }
  members: Array<{ name: string; project?: { name: string } | null; dish: { name: string } | null; allergens: string[]; allergenOther: string | null }>
  externals: Array<{ name: string; project?: { name: string } | null; dish: { name: string } | null; allergens: string[]; allergenOther: string | null; roleNote?: string | null }>
  dishCounts: Record<string, number>
  dietaryCounts: Record<string, number>
  allergenCounts: Record<string, number>
  summary: { total: number; picked: number; missing: number }
}

export async function sendLunchRecapEmail(
  recipients: string[],
  payload: LunchRecapPayload,
): Promise<void> {
  if (recipients.length === 0) return
  const fmt = new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Monaco', dateStyle: 'long', timeStyle: 'short' })
  const subject = `Lunch manifest — ${payload.event.eventAt ? fmt.format(payload.event.eventAt) : 'TBD'}`
  const dishLines = Object.entries(payload.dishCounts)
    .map(([name, n]) => `<li>${n}× ${name}</li>`).join('')
  const allergyLines = Object.entries(payload.allergenCounts)
    .map(([name, n]) => `<li>${n}× ${name}</li>`).join('')
  const memberRows = payload.members.map((r) => `
    <tr>
      <td>${r.project?.name ?? ''}</td>
      <td>${r.name}</td>
      <td>${r.dish?.name ?? '—'}</td>
      <td>${[...r.allergens, r.allergenOther].filter(Boolean).join(', ')}</td>
    </tr>
  `).join('')
  const externalRows = payload.externals.map((r) => `
    <tr>
      <td>External${r.project?.name ? ` (with ${r.project.name})` : ''}</td>
      <td>${r.name}${r.roleNote ? ` — ${r.roleNote}` : ''}</td>
      <td>${r.dish?.name ?? '—'}</td>
      <td>${[...r.allergens, r.allergenOther].filter(Boolean).join(', ')}</td>
    </tr>
  `).join('')
  const html = `
    <h2>Lunch manifest</h2>
    <p>${payload.summary.picked}/${payload.summary.total} picked${payload.summary.missing ? ` (${payload.summary.missing} missing)` : ''}</p>
    <h3>Dishes</h3><ul>${dishLines}</ul>
    <h3>Allergens</h3><ul>${allergyLines || '<li>None reported</li>'}</ul>
    <table border="1" cellpadding="6" cellspacing="0">
      <thead><tr><th>Team</th><th>Name</th><th>Dish</th><th>Allergies</th></tr></thead>
      <tbody>${memberRows}${externalRows}</tbody>
    </table>
  `
  const text = `${payload.summary.picked}/${payload.summary.total} picked. See HTML version for details.`
  for (const to of recipients) {
    await sendEmail({ to, subject, text, html })
  }
}
  • Step 3: Typecheck
npm run typecheck
  • Step 4: Commit
git add src/lib/email.ts
git commit -m "feat: lunch reminder + recap email templates"

Task 11: /api/cron/lunch-reminders endpoint (TDD)

Files:

  • Create: src/app/api/cron/lunch-reminders/route.ts

  • Create: tests/unit/lunch-cron.test.ts

  • Step 1: Failing tests at tests/unit/lunch-cron.test.ts:

import { describe, it, expect, afterAll, vi, beforeEach } from 'vitest'
import { prisma } from '../setup'
import { createTestUser, createTestProgram, cleanupTestData, uid } from '../helpers'

vi.mock('@/lib/email', async () => {
  const actual = await vi.importActual<any>('@/lib/email')
  return {
    ...actual,
    sendLunchReminderEmail: vi.fn(async () => undefined),
    sendLunchRecapEmail: vi.fn(async () => undefined),
  }
})

afterAll(async () => { await cleanupTestData() })

async function callRoute(path: 'lunch-reminders' | 'lunch-recap') {
  const mod = await import(`@/app/api/cron/${path}/route`)
  const req = new Request(`http://test/${path}`, {
    method: 'POST',
    headers: { 'x-cron-secret': process.env.CRON_SECRET ?? 'test-secret' },
  })
  return mod.POST(req)
}

describe('POST /api/cron/lunch-reminders', () => {
  beforeEach(() => { vi.clearAllMocks() })

  it('skips events outside the reminder window', async () => {
    const program = await createTestProgram()
    await prisma.lunchEvent.create({
      data: {
        programId: program.id, enabled: true,
        eventAt: new Date(Date.now() + 30 * 86_400_000),
        changeCutoffHours: 48, reminderHoursBeforeDeadline: 24,
      },
    })
    const res = await callRoute('lunch-reminders')
    expect(res.status).toBe(200)
    const { sendLunchReminderEmail } = await import('@/lib/email')
    expect(sendLunchReminderEmail).not.toHaveBeenCalled()
  })

  it('sends reminders for unpicked attendees inside the window', async () => {
    const program = await createTestProgram()
    const u = await createTestUser('APPLICANT')
    const project = await prisma.project.create({
      data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
    })
    const conf = await prisma.finalistConfirmation.create({
      data: {
        projectId: project.id, category: 'IMPACT', status: 'CONFIRMED',
        deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}`,
      },
    })
    const am = await prisma.attendingMember.create({
      data: { confirmationId: conf.id, userId: u.id },
    })
    await prisma.memberLunchPick.create({ data: { attendingMemberId: am.id } })
    const eventAt = new Date(Date.now() + 25 * 3600_000)
    await prisma.lunchEvent.create({
      data: {
        programId: program.id, enabled: true,
        eventAt, changeCutoffHours: 24, reminderHoursBeforeDeadline: 4,
      },
    })
    const res = await callRoute('lunch-reminders')
    expect(res.status).toBe(200)
    const { sendLunchReminderEmail } = await import('@/lib/email')
    expect(sendLunchReminderEmail).toHaveBeenCalledTimes(1)
    const row = await prisma.lunchEvent.findUnique({ where: { programId: program.id } })
    expect(row?.reminderSentAt).not.toBeNull()
  })

  it('is idempotent — second invocation does not resend', async () => {
    // Same event from prior test: reminderSentAt now non-null.
    // Re-running should not send again. Using the same DB state.
    const { sendLunchReminderEmail } = await import('@/lib/email')
    vi.clearAllMocks()
    const res = await callRoute('lunch-reminders')
    expect(res.status).toBe(200)
    expect(sendLunchReminderEmail).not.toHaveBeenCalled()
  })

  it('rejects without CRON_SECRET', async () => {
    const mod = await import('@/app/api/cron/lunch-reminders/route')
    const req = new Request('http://test/lunch-reminders', { method: 'POST' })
    const res = await mod.POST(req)
    expect(res.status).toBe(401)
  })
})
  • Step 2: Run, expect failure.

  • Step 3: Implement the endpoint

src/app/api/cron/lunch-reminders/route.ts:

import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { sendLunchReminderEmail } from '@/lib/email'

export async function POST(req: Request) {
  if (req.headers.get('x-cron-secret') !== process.env.CRON_SECRET) {
    return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
  }
  const now = new Date()
  const events = await prisma.lunchEvent.findMany({
    where: {
      enabled: true,
      reminderSentAt: null,
      reminderHoursBeforeDeadline: { not: null },
      eventAt: { not: null },
    },
  })
  let sent = 0
  for (const event of events) {
    try {
      if (!event.eventAt || event.reminderHoursBeforeDeadline == null) continue
      const deadline = new Date(event.eventAt.getTime() - event.changeCutoffHours * 3600_000)
      const reminderAt = new Date(deadline.getTime() - event.reminderHoursBeforeDeadline * 3600_000)
      if (now < reminderAt || now >= deadline) continue

      const ams = await prisma.attendingMember.findMany({
        where: {
          confirmation: { project: { programId: event.programId }, status: 'CONFIRMED' },
          lunchPick: { is: { pickedAt: null } },
        },
        include: { user: true },
      })
      for (const am of ams) {
        if (!am.user.email) continue
        await sendLunchReminderEmail({
          to: am.user.email,
          memberName: am.user.name ?? am.user.email,
          eventAt: event.eventAt,
          venue: event.venue,
          changeDeadline: deadline,
          pickUrl: `${process.env.NEXTAUTH_URL}/applicant`,
        })
        sent++
      }
      await prisma.lunchEvent.update({
        where: { id: event.id }, data: { reminderSentAt: new Date() },
      })
    } catch (e) {
      console.error('[lunch-reminders] event failed', event.id, e)
    }
  }
  return NextResponse.json({ ok: true, sent })
}
  • Step 4: Run, expect green.

  • Step 5: Commit

git add src/app/api/cron/lunch-reminders tests/unit/lunch-cron.test.ts
git commit -m "feat: cron endpoint — lunch reminders"

Task 12: /api/cron/lunch-recap endpoint (TDD)

Files:

  • Create: src/app/api/cron/lunch-recap/route.ts

  • Modify: tests/unit/lunch-cron.test.ts

  • Step 1: Failing tests appended:

describe('POST /api/cron/lunch-recap', () => {
  beforeEach(() => { vi.clearAllMocks() })

  it('skips events with cronEnabled=false', async () => {
    const program = await createTestProgram()
    await prisma.lunchEvent.create({
      data: {
        programId: program.id, enabled: true, cronEnabled: false,
        eventAt: new Date(Date.now() - 86_400_000), // already past
        changeCutoffHours: 24,
      },
    })
    const res = await callRoute('lunch-recap')
    expect(res.status).toBe(200)
    const { sendLunchRecapEmail } = await import('@/lib/email')
    expect(sendLunchRecapEmail).not.toHaveBeenCalled()
  })

  it('skips events with recapSentAt already set', async () => {
    const program = await createTestProgram()
    await prisma.lunchEvent.create({
      data: {
        programId: program.id, enabled: true, cronEnabled: true,
        eventAt: new Date(Date.now() - 86_400_000),
        changeCutoffHours: 24, recapSentAt: new Date(),
      },
    })
    const res = await callRoute('lunch-recap')
    expect(res.status).toBe(200)
    const { sendLunchRecapEmail } = await import('@/lib/email')
    expect(sendLunchRecapEmail).not.toHaveBeenCalled()
  })

  it('sends recap once and stamps recapSentAt', async () => {
    const program = await createTestProgram()
    await createTestUser('SUPER_ADMIN')  // recipient
    await prisma.lunchEvent.create({
      data: {
        programId: program.id, enabled: true, cronEnabled: true,
        eventAt: new Date(Date.now() - 86_400_000),
        changeCutoffHours: 24,
      },
    })
    const res = await callRoute('lunch-recap')
    expect(res.status).toBe(200)
    const { sendLunchRecapEmail } = await import('@/lib/email')
    expect(sendLunchRecapEmail).toHaveBeenCalledTimes(1)
    const row = await prisma.lunchEvent.findUnique({ where: { programId: program.id } })
    expect(row?.recapSentAt).not.toBeNull()
  })
})
  • Step 2: Run, expect failure.

  • Step 3: Implement the endpoint

src/app/api/cron/lunch-recap/route.ts:

import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { sendLunchRecapEmail } from '@/lib/email'
import { buildRecapPayload } from '@/server/services/lunch-recap'

export async function POST(req: Request) {
  if (req.headers.get('x-cron-secret') !== process.env.CRON_SECRET) {
    return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
  }
  const now = new Date()
  const events = await prisma.lunchEvent.findMany({
    where: {
      enabled: true, cronEnabled: true, recapSentAt: null, eventAt: { not: null },
    },
  })
  let sent = 0
  for (const event of events) {
    try {
      if (!event.eventAt) continue
      const deadline = new Date(event.eventAt.getTime() - event.changeCutoffHours * 3600_000)
      if (now < deadline) continue
      const payload = await buildRecapPayload(prisma, event.programId)
      const adminUsers = await prisma.user.findMany({
        where: { role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] }, email: { not: null } },
        select: { email: true },
      })
      const recipients = [
        ...adminUsers.map(u => u.email!).filter(Boolean),
        ...event.extraRecipients,
      ]
      await sendLunchRecapEmail(recipients, payload)
      await prisma.lunchEvent.update({
        where: { id: event.id }, data: { recapSentAt: new Date() },
      })
      await prisma.decisionAuditLog.create({
        data: {
          eventType: 'LUNCH_RECAP_SENT', entityType: 'LunchEvent',
          entityId: event.id, actorId: null,
          detailsJson: { recipientCount: recipients.length, source: 'cron' },
        },
      })
      sent++
    } catch (e) {
      console.error('[lunch-recap] event failed', event.id, e)
    }
  }
  return NextResponse.json({ ok: true, sent })
}
  • Step 4: Run, expect green.

  • Step 5: Commit

git add src/app/api/cron/lunch-recap tests/unit/lunch-cron.test.ts
git commit -m "feat: cron endpoint — lunch recap"

Task 13: Lunch tab scaffold + un-disable trigger

Files:

  • Create: src/components/admin/logistics/lunch-tab.tsx

  • Modify: src/app/(admin)/admin/logistics/page.tsx

  • Step 1: Create the empty tab component

src/components/admin/logistics/lunch-tab.tsx:

'use client'

import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'

export function LunchTab({ programId }: { programId: string }) {
  const { data: event, isLoading } = trpc.lunch.getEvent.useQuery({ programId })
  if (isLoading || !event) {
    return <Skeleton className="h-48 w-full" />
  }
  if (!event.enabled) {
    return (
      <Card>
        <CardHeader>
          <CardTitle>Lunch is disabled</CardTitle>
        </CardHeader>
        <CardContent>
          <p className="text-muted-foreground text-sm">
            Toggle Lunch on from the Event configuration card to begin setup.
          </p>
          {/* Event config card mounts in Task 14, replacing this stub. */}
        </CardContent>
      </Card>
    )
  }
  return (
    <div className="space-y-6">
      {/* Cards mount in Tasks 14-18. */}
      <p className="text-muted-foreground text-sm">Lunch tab  cards land in upcoming tasks.</p>
    </div>
  )
}
  • Step 2: Mount it in the logistics page

In src/app/(admin)/admin/logistics/page.tsx:

  • Remove disabled from the Lunch <TabsTrigger> (line ~55-58).
  • Drop the "(soon)" span.
  • Add a new <TabsContent value="lunch"><LunchTab programId={programId} /></TabsContent> block.
  • Import LunchTab.
import { LunchTab } from '@/components/admin/logistics/lunch-tab'
// ...
<TabsTrigger value="lunch">
  <Salad className="mr-2 h-4 w-4" /> Lunch
</TabsTrigger>
// ...
<TabsContent value="lunch">
  <LunchTab programId={programId} />
</TabsContent>
  • Step 3: Live smokenpm run dev, open /admin/logistics, click Lunch. Expected: empty-state card.

  • Step 4: Commit

git add src/components/admin/logistics/lunch-tab.tsx src/app/\(admin\)/admin/logistics/page.tsx
git commit -m "feat: lunch tab scaffold + un-disable trigger"

Task 14: Event configuration card

Files:

  • Create: src/components/admin/logistics/lunch-event-config.tsx

  • Modify: src/components/admin/logistics/lunch-tab.tsx

  • Step 1: Build the config card

src/components/admin/logistics/lunch-event-config.tsx — uses the same blur-to-commit pattern as edition-settings-tab.tsx:20-67. Surface fields:

  • enabled (Switch — master)
  • eventAt, endAt (datetime-local inputs)
  • venue (text)
  • notes (textarea)
  • changeCutoffHours, reminderHoursBeforeDeadline, cronEnabled
  • extraRecipients[] — chip-input (small custom component using Input + Badge; press Enter to add, click to remove)
'use client'

import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
  Card, CardContent, CardDescription, CardHeader, CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { X } from 'lucide-react'
import { toast } from 'sonner'

export function LunchEventConfig({ programId, event }: {
  programId: string
  event: NonNullable<ReturnType<typeof trpc.lunch.getEvent.useQuery>['data']>
}) {
  const utils = trpc.useUtils()
  const update = trpc.lunch.updateEvent.useMutation({
    onSuccess: () => utils.lunch.getEvent.invalidate({ programId }),
    onError: (e) => toast.error(e.message),
  })
  // Local state for chip input
  const [extraInput, setExtraInput] = useState('')

  return (
    <Card>
      <CardHeader>
        <CardTitle>Event configuration</CardTitle>
        <CardDescription>Per-edition lunch event settings.</CardDescription>
      </CardHeader>
      <CardContent className="space-y-6">
        {/* enabled */}
        <div className="flex items-center justify-between gap-4 rounded-md border p-4">
          <Label htmlFor="lunch-enabled">Enable lunch event</Label>
          <Switch
            id="lunch-enabled"
            checked={event.enabled}
            onCheckedChange={(v) => update.mutate({ programId, enabled: v })}
            disabled={update.isPending}
          />
        </div>

        {/* eventAt */}
        <div className="space-y-1.5">
          <Label htmlFor="event-at">Event start</Label>
          <Input
            id="event-at" type="datetime-local"
            defaultValue={event.eventAt ? event.eventAt.toISOString().slice(0, 16) : ''}
            onBlur={(e) => {
              const v = e.target.value
              update.mutate({
                programId,
                eventAt: v ? new Date(v) : null,
              })
            }}
            disabled={update.isPending}
          />
        </div>

        {/* endAt */}
        <div className="space-y-1.5">
          <Label htmlFor="end-at">Event end</Label>
          <Input
            id="end-at" type="datetime-local"
            defaultValue={event.endAt ? event.endAt.toISOString().slice(0, 16) : ''}
            onBlur={(e) => {
              const v = e.target.value
              update.mutate({ programId, endAt: v ? new Date(v) : null })
            }}
            disabled={update.isPending}
          />
        </div>

        {/* venue */}
        <div className="space-y-1.5">
          <Label htmlFor="venue">Venue</Label>
          <Input
            id="venue"
            defaultValue={event.venue ?? ''}
            onBlur={(e) => update.mutate({ programId, venue: e.target.value || null })}
            disabled={update.isPending}
          />
        </div>

        {/* notes */}
        <div className="space-y-1.5">
          <Label htmlFor="notes">Notes for attendees (optional)</Label>
          <Textarea
            id="notes"
            defaultValue={event.notes ?? ''}
            onBlur={(e) => update.mutate({ programId, notes: e.target.value || null })}
            disabled={update.isPending}
          />
        </div>

        {/* changeCutoffHours */}
        <div className="space-y-1.5">
          <Label htmlFor="cutoff">Change cutoff (hours before event)</Label>
          <Input
            id="cutoff" type="number" min={0} max={720}
            defaultValue={event.changeCutoffHours}
            onBlur={(e) => {
              const n = Number(e.target.value)
              if (Number.isFinite(n) && n !== event.changeCutoffHours) {
                update.mutate({ programId, changeCutoffHours: n })
              }
            }}
            disabled={update.isPending}
            className="max-w-[12rem]"
          />
        </div>

        {/* reminderHoursBeforeDeadline */}
        <div className="space-y-1.5">
          <Label htmlFor="reminder">Reminder (hours before deadline; blank = off)</Label>
          <Input
            id="reminder" type="number" min={0} max={720}
            defaultValue={event.reminderHoursBeforeDeadline ?? ''}
            onBlur={(e) => {
              const v = e.target.value
              update.mutate({
                programId,
                reminderHoursBeforeDeadline: v === '' ? null : Number(v),
              })
            }}
            disabled={update.isPending}
            className="max-w-[12rem]"
          />
        </div>

        {/* cronEnabled */}
        <div className="flex items-center justify-between gap-4 rounded-md border p-4">
          <Label htmlFor="cron-enabled">Auto-send recap at deadline</Label>
          <Switch
            id="cron-enabled"
            checked={event.cronEnabled}
            onCheckedChange={(v) => update.mutate({ programId, cronEnabled: v })}
            disabled={update.isPending}
          />
        </div>

        {/* extraRecipients chip input */}
        <div className="space-y-1.5">
          <Label>Extra recap recipients</Label>
          <div className="flex flex-wrap gap-2">
            {event.extraRecipients.map((email) => (
              <Badge key={email} variant="secondary" className="gap-1">
                {email}
                <button
                  className="ml-1"
                  onClick={() =>
                    update.mutate({
                      programId,
                      extraRecipients: event.extraRecipients.filter(e => e !== email),
                    })
                  }
                >
                  <X className="h-3 w-3" />
                </button>
              </Badge>
            ))}
          </div>
          <Input
            placeholder="email@example.com — press Enter to add"
            value={extraInput}
            onChange={(e) => setExtraInput(e.target.value)}
            onKeyDown={(e) => {
              if (e.key === 'Enter' && extraInput.trim()) {
                update.mutate({
                  programId,
                  extraRecipients: [...event.extraRecipients, extraInput.trim()],
                })
                setExtraInput('')
              }
            }}
          />
        </div>
      </CardContent>
    </Card>
  )
}
  • Step 2: Mount it in lunch-tab.tsx — replace the empty-state and the placeholder paragraph with <LunchEventConfig programId={programId} event={event} /> (always render — the config is needed even when enabled=false to flip it on).

  • Step 3: Live smoke — open Lunch tab, toggle enabled, set a date, set venue, set extra recipients, refresh — values persist.

  • Step 4: Commit

git add src/components/admin/logistics/lunch-event-config.tsx src/components/admin/logistics/lunch-tab.tsx
git commit -m "feat: lunch event configuration card"

Task 15: Dishes card

Files:

  • Create: src/components/admin/logistics/lunch-dishes.tsx

  • Modify: src/components/admin/logistics/lunch-tab.tsx

  • Step 1: Build the card

src/components/admin/logistics/lunch-dishes.tsx:

'use client'

import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { toast } from 'sonner'

const DIETARY_TAGS = ['VEGETARIAN', 'VEGAN', 'GLUTEN_FREE', 'PESCATARIAN'] as const
type DietaryTag = (typeof DIETARY_TAGS)[number]

export function LunchDishes({ lunchEventId }: { lunchEventId: string }) {
  const utils = trpc.useUtils()
  const { data: dishes } = trpc.lunch.listDishes.useQuery({ lunchEventId })
  const create = trpc.lunch.createDish.useMutation({
    onSuccess: () => utils.lunch.listDishes.invalidate({ lunchEventId }),
    onError: (e) => toast.error(e.message),
  })
  const update = trpc.lunch.updateDish.useMutation({
    onSuccess: () => utils.lunch.listDishes.invalidate({ lunchEventId }),
  })
  const del = trpc.lunch.deleteDish.useMutation({
    onSuccess: () => utils.lunch.listDishes.invalidate({ lunchEventId }),
  })
  const [newName, setNewName] = useState('')
  const [newTags, setNewTags] = useState<DietaryTag[]>([])

  return (
    <Card>
      <CardHeader>
        <CardTitle>Dishes</CardTitle>
      </CardHeader>
      <CardContent className="space-y-4">
        {dishes?.length === 0 && (
          <p className="text-muted-foreground text-sm">Add at least one dish to open picks.</p>
        )}
        <ul className="space-y-2">
          {dishes?.map((d) => (
            <li key={d.id} className="flex items-center gap-3 rounded-md border p-3">
              <span className="font-medium">{d.name}</span>
              <div className="flex gap-1">
                {d.dietaryTags.map((t) => (
                  <Badge key={t} variant="outline">{t.replace('_', ' ').toLowerCase()}</Badge>
                ))}
              </div>
              <div className="ml-auto flex gap-2">
                <Button size="sm" variant="ghost"
                  onClick={() => {
                    const name = prompt('Edit name', d.name)
                    if (name && name !== d.name) update.mutate({ dishId: d.id, name })
                  }}
                ><Pencil className="h-4 w-4" /></Button>
                <Button size="sm" variant="ghost"
                  onClick={() => {
                    if (confirm(`Delete "${d.name}"? Existing picks will go back to "not picked".`)) {
                      del.mutate({ dishId: d.id })
                    }
                  }}
                ><Trash2 className="h-4 w-4" /></Button>
              </div>
            </li>
          ))}
        </ul>

        <div className="flex gap-2 border-t pt-4">
          <Input placeholder="New dish name" value={newName}
            onChange={(e) => setNewName(e.target.value)} />
          <div className="flex gap-1">
            {DIETARY_TAGS.map((t) => (
              <Button key={t} size="sm"
                variant={newTags.includes(t) ? 'default' : 'outline'}
                onClick={() => setNewTags(
                  newTags.includes(t) ? newTags.filter(x => x !== t) : [...newTags, t]
                )}
              >{t.replace('_', ' ').toLowerCase()}</Button>
            ))}
          </div>
          <Button onClick={() => {
            if (!newName.trim()) return
            create.mutate(
              { lunchEventId, name: newName.trim(), dietaryTags: newTags, sortOrder: dishes?.length ?? 0 },
              { onSuccess: () => { setNewName(''); setNewTags([]) } },
            )
          }}><Plus className="mr-1 h-4 w-4" /> Add</Button>
        </div>
      </CardContent>
    </Card>
  )
}
  • Step 2: Mount in lunch-tab.tsx under the config card.

  • Step 3: Live smoke — add 3 dishes with mixed dietary tags, edit one's name, delete one. Refresh.

  • Step 4: Commit

git add src/components/admin/logistics/lunch-dishes.tsx src/components/admin/logistics/lunch-tab.tsx
git commit -m "feat: lunch dishes card with create/edit/delete"

Task 16: Manifest card

Files:

  • Create: src/components/admin/logistics/lunch-manifest.tsx

  • Modify: src/components/admin/logistics/lunch-tab.tsx

  • Step 1: Build the card

src/components/admin/logistics/lunch-manifest.tsx — table backed by trpc.lunch.getManifest.useQuery({ programId }). Columns: Team / Attendee / Type / Dish / Allergens / Picked at. Filters above the table:

  • <Input> to filter by team name (client-side substring match)
  • <Switch> "Missing picks only" — filters to rows where dish == null

Header summary chip uses manifest.summary plus aggregated dietary/allergen counts (compute client-side).

Edit button on each row opens a slide-over with <LunchPickForm> (defined in Task 20) for members, or the externals dialog for externals (defined in Task 17). Re-use both.

'use client'

import { useState, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Pencil, Download } from 'lucide-react'

export function LunchManifest({ programId }: { programId: string }) {
  const { data } = trpc.lunch.getManifest.useQuery({ programId })
  const [search, setSearch] = useState('')
  const [missingOnly, setMissingOnly] = useState(false)

  const rows = useMemo(() => {
    if (!data) return []
    const all = [
      ...data.members.map((m: any) => ({ ...m, sortKey: `0-${m.project?.name ?? ''}-${m.name}` })),
      ...data.externals.map((e: any) => ({ ...e, sortKey: `1-${e.project?.name ?? ''}-${e.name}` })),
    ]
    return all
      .filter(r => !search || (r.project?.name ?? '').toLowerCase().includes(search.toLowerCase()) || r.name.toLowerCase().includes(search.toLowerCase()))
      .filter(r => !missingOnly || !r.dish)
      .sort((a, b) => a.sortKey.localeCompare(b.sortKey))
  }, [data, search, missingOnly])

  if (!data) return null

  return (
    <Card>
      <CardHeader>
        <CardTitle className="flex items-center justify-between">
          Manifest
          <Badge variant="outline">
            {data.summary.picked}/{data.summary.total} picked · {data.summary.missing} missing
          </Badge>
        </CardTitle>
      </CardHeader>
      <CardContent className="space-y-4">
        <div className="flex items-center gap-4">
          <Input placeholder="Filter by team or name" value={search}
            onChange={(e) => setSearch(e.target.value)} className="max-w-xs" />
          <div className="flex items-center gap-2">
            <Switch id="missing-only" checked={missingOnly} onCheckedChange={setMissingOnly} />
            <Label htmlFor="missing-only">Missing picks only</Label>
          </div>
          <DownloadCsvButton programId={programId} />
        </div>

        <table className="w-full text-sm">
          <thead className="text-muted-foreground border-b text-left">
            <tr>
              <th className="py-2">Team</th>
              <th>Attendee</th>
              <th>Type</th>
              <th>Dish</th>
              <th>Allergens</th>
              <th></th>
            </tr>
          </thead>
          <tbody>
            {rows.map((r: any) => (
              <tr key={r.attendingMemberId ?? r.externalId} className="border-b">
                <td className="py-2">{r.project?.name ?? '—'}</td>
                <td>{r.name}</td>
                <td>{r.kind === 'MEMBER' ? 'Member' : 'External'}</td>
                <td>{r.dish?.name ?? <span className="text-muted-foreground">not picked</span>}</td>
                <td>{[...r.allergens, r.allergenOther].filter(Boolean).join(', ')}</td>
                <td>
                  <Button size="sm" variant="ghost"><Pencil className="h-4 w-4" /></Button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </CardContent>
    </Card>
  )
}

Define DownloadCsvButton near the top of the same file (above LunchManifest):

function DownloadCsvButton({ programId }: { programId: string }) {
  const utils = trpc.useUtils()
  return (
    <Button
      variant="outline"
      size="sm"
      className="ml-auto"
      onClick={async () => {
        const csv = await utils.client.lunch.exportManifestCsv.query({ programId })
        const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
        const url = URL.createObjectURL(blob)
        const a = document.createElement('a')
        a.href = url
        a.download = 'lunch-manifest.csv'
        document.body.appendChild(a)
        a.click()
        document.body.removeChild(a)
        URL.revokeObjectURL(url)
      }}
    >
      <Download className="mr-1 h-4 w-4" /> Download CSV
    </Button>
  )
}

The edit-pencil wiring (open slide-over, mount the appropriate form, re-invalidate the manifest on save) lands in Tasks 17 + 20 — leave the button stubbed for now.

  • Step 2: Mount in lunch-tab.tsx.

  • Step 3: Live smoke — confirm rows render, filters work, CSV downloads.

  • Step 4: Commit

git add src/components/admin/logistics/lunch-manifest.tsx src/components/admin/logistics/lunch-tab.tsx
git commit -m "feat: lunch manifest card with filters + CSV export"

Task 17: Externals card with add/edit dialog

Files:

  • Create: src/components/admin/logistics/lunch-externals.tsx

  • Modify: src/components/admin/logistics/lunch-tab.tsx

  • Modify: src/components/admin/logistics/lunch-manifest.tsx — wire the externals edit pencil into this dialog

  • Step 1: Build the card + dialog

src/components/admin/logistics/lunch-externals.tsx:

'use client'

import { useState, useImperativeHandle, forwardRef } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
  Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
  Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog'
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { toast } from 'sonner'

const ALLERGENS = [
  'GLUTEN', 'CRUSTACEANS', 'EGGS', 'FISH', 'PEANUTS', 'SOYBEANS', 'MILK',
  'TREE_NUTS', 'CELERY', 'MUSTARD', 'SESAME', 'SULPHITES', 'LUPIN', 'MOLLUSCS',
] as const
type Allergen = (typeof ALLERGENS)[number]

type Editing =
  | { mode: 'new' }
  | { mode: 'edit'; id: string }
  | null

export type LunchExternalsHandle = { openEditDialog: (id: string) => void }

export const LunchExternals = forwardRef<LunchExternalsHandle, {
  programId: string
  lunchEventId: string
}>(function LunchExternals({ programId, lunchEventId }, ref) {
  const utils = trpc.useUtils()
  const { data: externals } = trpc.lunch.listExternals.useQuery({ lunchEventId })
  const { data: dishes } = trpc.lunch.listDishes.useQuery({ lunchEventId })
  const { data: projects } = trpc.program.listFinalistProjects.useQuery({ programId })
    // ^ Add this small helper procedure on `program` router if it doesn't exist:
    // listFinalistProjects: adminProcedure
    //   .input(z.object({ programId: z.string() }))
    //   .query(({ ctx, input }) => ctx.prisma.project.findMany({
    //     where: { programId: input.programId, finalistConfirmation: { status: 'CONFIRMED' } },
    //     select: { id: true, name: true },
    //     orderBy: { name: 'asc' },
    //   })),
  const [editing, setEditing] = useState<Editing>(null)

  useImperativeHandle(ref, () => ({
    openEditDialog: (id: string) => setEditing({ mode: 'edit', id }),
  }), [])

  const create = trpc.lunch.createExternal.useMutation({
    onSuccess: () => {
      utils.lunch.listExternals.invalidate({ lunchEventId })
      utils.lunch.getManifest.invalidate({ programId })
    },
    onError: (e) => toast.error(e.message),
  })
  const update = trpc.lunch.updateExternal.useMutation({
    onSuccess: () => {
      utils.lunch.listExternals.invalidate({ lunchEventId })
      utils.lunch.getManifest.invalidate({ programId })
    },
  })
  const del = trpc.lunch.deleteExternal.useMutation({
    onSuccess: () => {
      utils.lunch.listExternals.invalidate({ lunchEventId })
      utils.lunch.getManifest.invalidate({ programId })
    },
  })

  const editingRow = editing?.mode === 'edit'
    ? externals?.find(e => e.id === editing.id) ?? null
    : null

  return (
    <Card>
      <CardHeader>
        <CardTitle className="flex items-center justify-between">
          External attendees
          <Button size="sm" onClick={() => setEditing({ mode: 'new' })}>
            <Plus className="mr-1 h-4 w-4" /> Add external
          </Button>
        </CardTitle>
      </CardHeader>
      <CardContent>
        {externals?.length === 0 && (
          <p className="text-muted-foreground text-sm">No external attendees yet.</p>
        )}
        <table className="w-full text-sm">
          <tbody>
            {externals?.map((e) => (
              <tr key={e.id} className="border-b">
                <td className="py-2">{e.name}</td>
                <td>{e.project?.name ?? '—'}</td>
                <td>{e.roleNote ?? ''}</td>
                <td>
                  <Button size="sm" variant="ghost" onClick={() => setEditing({ mode: 'edit', id: e.id })}>
                    <Pencil className="h-4 w-4" />
                  </Button>
                  <Button size="sm" variant="ghost" onClick={() => {
                    if (confirm(`Delete external attendee "${e.name}"?`)) del.mutate({ externalId: e.id })
                  }}>
                    <Trash2 className="h-4 w-4" />
                  </Button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </CardContent>

      {editing && (
        <ExternalDialog
          mode={editing.mode}
          initial={editingRow}
          dishes={dishes ?? []}
          projects={projects ?? []}
          onClose={() => setEditing(null)}
          onSubmit={(values) => {
            if (editing.mode === 'new') {
              create.mutate({ lunchEventId, ...values }, { onSuccess: () => setEditing(null) })
            } else {
              update.mutate({ externalId: editing.id, ...values }, { onSuccess: () => setEditing(null) })
            }
          }}
        />
      )}
    </Card>
  )
})

function ExternalDialog({
  mode, initial, dishes, projects, onClose, onSubmit,
}: {
  mode: 'new' | 'edit'
  initial: { name: string; email: string | null; projectId: string | null; roleNote: string | null; dishId: string | null; allergens: string[]; allergenOther: string | null } | null
  dishes: Array<{ id: string; name: string }>
  projects: Array<{ id: string; name: string }>
  onClose: () => void
  onSubmit: (values: {
    name: string; email?: string; projectId?: string | null; roleNote?: string;
    dishId?: string | null; allergens: Allergen[]; allergenOther?: string | null;
  }) => void
}) {
  const [name, setName] = useState(initial?.name ?? '')
  const [email, setEmail] = useState(initial?.email ?? '')
  const [projectId, setProjectId] = useState(initial?.projectId ?? '')
  const [roleNote, setRoleNote] = useState(initial?.roleNote ?? '')
  const [dishId, setDishId] = useState(initial?.dishId ?? '')
  const [allergens, setAllergens] = useState<Allergen[]>((initial?.allergens as Allergen[]) ?? [])
  const [allergenOther, setAllergenOther] = useState(initial?.allergenOther ?? '')

  return (
    <Dialog open onOpenChange={(o) => { if (!o) onClose() }}>
      <DialogContent className="max-w-2xl">
        <DialogHeader>
          <DialogTitle>{mode === 'new' ? 'Add external attendee' : 'Edit external attendee'}</DialogTitle>
        </DialogHeader>
        <div className="space-y-4">
          <div><Label>Name *</Label><Input value={name} onChange={(e) => setName(e.target.value)} /></div>
          <div><Label>Email</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
          <div>
            <Label>Project (optional)</Label>
            <Select value={projectId} onValueChange={setProjectId}>
              <SelectTrigger><SelectValue placeholder="Standalone" /></SelectTrigger>
              <SelectContent>
                <SelectItem value="">Standalone</SelectItem>
                {projects.map(p => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
              </SelectContent>
            </Select>
          </div>
          <div><Label>Role / note</Label><Input value={roleNote} onChange={(e) => setRoleNote(e.target.value)} /></div>
          <div>
            <Label>Dish</Label>
            <Select value={dishId} onValueChange={setDishId}>
              <SelectTrigger><SelectValue placeholder="Not picked" /></SelectTrigger>
              <SelectContent>
                <SelectItem value="">Not picked</SelectItem>
                {dishes.map(d => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}
              </SelectContent>
            </Select>
          </div>
          <div>
            <Label>Allergens</Label>
            <div className="grid grid-cols-2 gap-2">
              {ALLERGENS.map((a) => (
                <label key={a} className="flex items-center gap-2 text-sm">
                  <Checkbox checked={allergens.includes(a)}
                    onCheckedChange={(v) => setAllergens(
                      v ? [...allergens, a] : allergens.filter(x => x !== a),
                    )}
                  />
                  {a.replace('_', ' ').toLowerCase()}
                </label>
              ))}
            </div>
          </div>
          <div><Label>Other allergens / notes</Label><Textarea value={allergenOther} onChange={(e) => setAllergenOther(e.target.value)} /></div>
        </div>
        <DialogFooter>
          <Button variant="outline" onClick={onClose}>Cancel</Button>
          <Button
            disabled={!name.trim()}
            onClick={() => onSubmit({
              name: name.trim(),
              email: email.trim() || undefined,
              projectId: projectId || null,
              roleNote: roleNote.trim() || undefined,
              dishId: dishId || null,
              allergens,
              allergenOther: allergenOther.trim() || null,
            })}
          >Save</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}
  • Step 2: Add the supporting program.listFinalistProjects procedure

If it doesn't already exist, add to src/server/routers/program.ts:

listFinalistProjects: adminProcedure
  .input(z.object({ programId: z.string() }))
  .query(({ ctx, input }) =>
    ctx.prisma.project.findMany({
      where: { programId: input.programId, finalistConfirmation: { status: 'CONFIRMED' } },
      select: { id: true, name: true },
      orderBy: { name: 'asc' },
    }),
  ),
  • Step 3: Wire externals edit pencil from the manifest

Update <LunchTab> to keep a useRef<LunchExternalsHandle>(null) and pass an onEditExternal={(id) => externalsRef.current?.openEditDialog(id)} prop down to <LunchManifest>. In <LunchManifest>, change the externals row's edit button:

<Button size="sm" variant="ghost" onClick={() => onEditExternal?.(r.externalId)}>
  <Pencil className="h-4 w-4" />
</Button>

(Member rows' edit pencil opens the slide-over from Task 20 instead — leave it stubbed for now and replace in that task.)

  • Step 4: Mount in lunch-tab.tsx
const externalsRef = useRef<LunchExternalsHandle>(null)
// ...
<LunchManifest programId={programId} onEditExternal={(id) => externalsRef.current?.openEditDialog(id)} />
<LunchExternals ref={externalsRef} programId={programId} lunchEventId={event.id} />
  • Step 5: Live smoke — add a standalone external + a project-attached one. Edit. Delete. Confirm both show in the manifest. Click the manifest's externals edit-pencil — externals dialog opens.

  • Step 6: Commit

git add src/components/admin/logistics/lunch-externals.tsx \
       src/components/admin/logistics/lunch-tab.tsx \
       src/components/admin/logistics/lunch-manifest.tsx
git commit -m "feat: external lunch attendees card + dialog"

Task 18: Recap actions card

Files:

  • Create: src/components/admin/logistics/lunch-recap-actions.tsx

  • Modify: src/components/admin/logistics/lunch-tab.tsx

  • Step 1: Build the card

Two buttons + a footer line:

  • Preview recap — opens a Dialog rendering the recap payload (counts + table).
  • Send recap now — calls trpc.lunch.sendRecap.useMutation(). On PRECONDITION_FAILED, surfaces a confirm dialog ("You've already sent a recap. Send updated version to all recipients?"); on confirm, retries with forceUpdate: true.
  • Download CSV — same handler as the manifest card (extract a small util if duplicated).
  • Footer: "Last sent: · Recipients: N admins + M extra" — read from event.recapSentAt + event.extraRecipients.length and a count of admin users from trpc.user.countAdmins (add this small read if it doesn't exist; otherwise hardcode "all admins" text without a count to avoid a new endpoint).
'use client'

import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Send, Eye, Download } from 'lucide-react'
import { toast } from 'sonner'

export function LunchRecapActions({ programId, event }: {
  programId: string
  event: { recapSentAt: Date | null; extraRecipients: string[] }
}) {
  const utils = trpc.useUtils()
  const [previewOpen, setPreviewOpen] = useState(false)
  const send = trpc.lunch.sendRecap.useMutation({
    onSuccess: () => {
      utils.lunch.getEvent.invalidate({ programId })
      toast.success('Recap sent')
    },
    onError: async (e) => {
      if (e.data?.code === 'PRECONDITION_FAILED') {
        if (confirm("You've already sent a recap. Send updated version to all recipients?")) {
          send.mutate({ programId, forceUpdate: true })
        }
      } else {
        toast.error(e.message)
      }
    },
  })
  const { data: preview } = trpc.lunch.getRecapPreview.useQuery(
    { programId },
    { enabled: previewOpen },
  )

  return (
    <Card>
      <CardHeader>
        <CardTitle>Recap</CardTitle>
      </CardHeader>
      <CardContent className="space-y-4">
        <div className="flex flex-wrap gap-2">
          <Button variant="outline" onClick={() => setPreviewOpen(true)}>
            <Eye className="mr-2 h-4 w-4" /> Preview recap
          </Button>
          <Button onClick={() => send.mutate({ programId })} disabled={send.isPending}>
            <Send className="mr-2 h-4 w-4" /> Send recap now
          </Button>
        </div>
        <p className="text-muted-foreground text-xs">
          {event.recapSentAt
            ? `Last sent: ${event.recapSentAt.toLocaleString()}. Recipients: edition admins${event.extraRecipients.length ? ` + ${event.extraRecipients.length} extra` : ''}.`
            : 'Recap has not been sent yet.'}
        </p>
      </CardContent>

      <Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
        <DialogContent className="max-w-3xl">
          <DialogHeader><DialogTitle>Recap preview</DialogTitle></DialogHeader>
          {preview && (
            <div className="space-y-3 text-sm">
              <p>{preview.summary.picked}/{preview.summary.total} picked.</p>
              <h4 className="font-medium">Dishes</h4>
              <ul>{Object.entries(preview.dishCounts).map(([n, c]) => <li key={n}>{c}× {n}</li>)}</ul>
              <h4 className="font-medium">Allergens</h4>
              <ul>{Object.entries(preview.allergenCounts).map(([n, c]) => <li key={n}>{c}× {n}</li>)}</ul>
            </div>
          )}
          <DialogFooter>
            <Button variant="outline" onClick={() => setPreviewOpen(false)}>Close</Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </Card>
  )
}
  • Step 2: Mount in lunch-tab.tsx as the last card.

  • Step 3: Live smoke — preview shows aggregates, "Send" sends; second click prompts the resend confirm.

  • Step 4: Commit

git add src/components/admin/logistics/lunch-recap-actions.tsx src/components/admin/logistics/lunch-tab.tsx
git commit -m "feat: recap actions card with preview + send + resend confirm"

Task 19: Lunch banner on applicant dashboard

Files:

  • Create: src/components/applicant/lunch-banner.tsx

  • Modify: src/app/(applicant)/applicant/page.tsx

  • Step 1: Build the banner

src/components/applicant/lunch-banner.tsx — uses trpc.lunch.getEventForMember.useQuery({ programId }). Returns null when event is null (disabled or not configured).

Layout: a single-line strip showing event date/time (Intl-formatted in user locale + "Europe/Monaco" zone), venue, and a small countdown to changeDeadline. A details-disclosure for notes if non-empty.

'use client'

import { trpc } from '@/lib/trpc/client'
import { Card, CardContent } from '@/components/ui/card'
import { Calendar, MapPin } from 'lucide-react'

export function LunchBanner({ programId }: { programId: string }) {
  const { data: event } = trpc.lunch.getEventForMember.useQuery({ programId })
  if (!event) return null
  const fmt = new Intl.DateTimeFormat(undefined, {
    timeZone: 'Europe/Monaco', dateStyle: 'long', timeStyle: 'short',
  })
  return (
    <Card>
      <CardContent className="flex flex-wrap items-center gap-4 py-3 text-sm">
        <div className="flex items-center gap-1.5">
          <Calendar className="h-4 w-4" />
          {event.eventAt ? fmt.format(new Date(event.eventAt)) : 'Date TBD'}
        </div>
        {event.venue && (
          <div className="flex items-center gap-1.5">
            <MapPin className="h-4 w-4" /> {event.venue}
          </div>
        )}
        {event.changeDeadline && (
          <div className="text-muted-foreground ml-auto">
            Picks close: {fmt.format(new Date(event.changeDeadline))}
          </div>
        )}
        {event.notes && (
          <details className="basis-full">
            <summary className="text-muted-foreground cursor-pointer text-xs">Notes from organizers</summary>
            <p className="text-sm">{event.notes}</p>
          </details>
        )}
      </CardContent>
    </Card>
  )
}
  • Step 2: Mount above <AttendingMembersCard>

In src/app/(applicant)/applicant/page.tsx, add an import and place <LunchBanner programId={projectProgramId} /> above the existing <AttendingMembersCard>. The exact programId source already used for that card (likely project.programId) reuses the same prop.

  • Step 3: Live smoke — disable lunch → banner hidden; enable + set date → banner shows.

  • Step 4: Commit

git add src/components/applicant/lunch-banner.tsx src/app/\(applicant\)/applicant/page.tsx
git commit -m "feat: lunch banner on applicant dashboard"

Task 20: Lunch picker on AttendingMembersCard

Files:

  • Create: src/components/applicant/lunch-pick-form.tsx

  • Modify: src/components/applicant/attending-members-card.tsx

  • Step 1: Build the picker form

src/components/applicant/lunch-pick-form.tsx — receives attendingMemberId, lunchEventId, current pick, current user role/identity, and the enabled flag. Calls trpc.lunch.upsertPick.useMutation() on changes. Disables editing when:

  • The viewer is not self / team-lead / admin (already enforced server-side, but we also disable inputs to avoid silly UX), OR
  • The deadline has passed (UI shows the read-only message).

Renders a dropdown of dishes (grouped by dietaryTags) + the EU-14 allergen checklist + an "Other" textarea + a chip showing "Picked at [time]" once pickedAt is set.

Keep this component small (≤ 200 lines). Use useState for local draft state, commit on dropdown change / checkbox toggle / textarea blur.

The viewer-vs-editor logic: this component receives canEdit: boolean from its parent. The parent (the card row) computes it from session role + the row's userId.

  • Step 2: Embed in attending-members-card.tsx

Find the existing per-row layout. Below the visa + flight subsections, append a <LunchPickForm> that:

  • Reads event from a trpc.lunch.getEventForMember.useQuery({ programId }) call hoisted to the parent.
  • Conditionally renders only when event != null.
  • Computes canEdit per row: (role === SUPER_ADMIN || PROGRAM_ADMIN) || row.userId === sessionUser.id || isTeamLead(sessionUser, project).

isTeamLead derives from the existing project context that AttendingMembersCard already receives (it knows the project's TeamMembers). If it doesn't, add it now via the existing project read used by the dashboard.

  • Step 3: Wire the manifest edit pencil (Task 16 stub)

Now that <LunchPickForm> exists, return to lunch-manifest.tsx and replace the stubbed edit-pencil button with a slide-over (<Sheet>) that mounts <LunchPickForm> in admin mode (canEdit={true}, no deadline gating since admin-only). On save, invalidate trpc.lunch.getManifest.

  • Step 4: Live smoke

  • Open the dashboard as a member → can pick own row only.

  • Open as team lead → can pick any row.

  • Past deadline → read-only state with "contact admin" note.

  • Open /admin/logistics → Lunch → click pencil on any row → slide-over opens, edit works, manifest updates.

  • Step 5: Commit

git add src/components/applicant/lunch-pick-form.tsx \
       src/components/applicant/attending-members-card.tsx \
       src/components/admin/logistics/lunch-manifest.tsx
git commit -m "feat: lunch picker on attending-members card + admin slide-over"

Task 21: External attendees read-only strip on project page

Files:

  • Create: src/components/applicant/external-attendees-strip.tsx

  • Modify: the project detail page (locate via find src/app -path '*applicant*projects*page.tsx'; also check src/app/(applicant)/applicant/project — verify the exact path before editing)

  • Step 1: Add the getProjectExternals procedure (TDD)

Add a failing test in tests/unit/lunch-router.test.ts:

describe('lunch.getProjectExternals', () => {
  it('returns project-attached externals to a team member', async () => {
    const program = await createTestProgram()
    const lead = await createTestUser('APPLICANT')
    const project = await prisma.project.create({
      data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
    })
    await prisma.teamMember.create({
      data: { projectId: project.id, userId: lead.id, role: 'LEAD' },
    })
    const event = await prisma.lunchEvent.create({ data: { programId: program.id } })
    await prisma.externalAttendee.create({
      data: { lunchEventId: event.id, projectId: project.id, name: 'Sponsor X' },
    })
    const caller = createCaller(lunchRouter, lead)
    const result = await caller.getProjectExternals({ projectId: project.id })
    expect(result).toHaveLength(1)
    expect(result[0].name).toBe('Sponsor X')
  })

  it('rejects callers who are not on the team', async () => {
    const program = await createTestProgram()
    const stranger = await createTestUser('APPLICANT')
    const project = await prisma.project.create({
      data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
    })
    const caller = createCaller(lunchRouter, stranger)
    await expect(
      caller.getProjectExternals({ projectId: project.id }),
    ).rejects.toThrow(/FORBIDDEN/)
  })
})

Run, expect failure. Then add to src/server/routers/lunch.ts:

getProjectExternals: protectedProcedure
  .input(z.object({ projectId: z.string() }))
  .query(async ({ ctx, input }) => {
    const userId = ctx.session.user.id
    const role = ctx.session.user.role
    const isAdmin = role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN'
    if (!isAdmin) {
      const tm = await ctx.prisma.teamMember.findFirst({
        where: { projectId: input.projectId, userId },
      })
      if (!tm) throw new TRPCError({ code: 'FORBIDDEN' })
    }
    return ctx.prisma.externalAttendee.findMany({
      where: { projectId: input.projectId },
      include: { dish: true },
      orderBy: { createdAt: 'asc' },
    })
  }),

Run tests, expect green.

  • Step 2: Locate the project detail page
find src/app -type f -name 'page.tsx' | xargs grep -l "TeamMember\|project.findUnique" 2>/dev/null

Pick the page that team members use to view their project details. Confirm by checking it renders team-members / flight / visa.

  • Step 3: Build the strip
'use client'

import { trpc } from '@/lib/trpc/client'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'

export function ExternalAttendeesStrip({ projectId }: { projectId: string }) {
  const { data } = trpc.lunch.getProjectExternals.useQuery({ projectId })
  if (!data || data.length === 0) return null
  return (
    <Card>
      <CardContent className="flex flex-wrap items-center gap-2 py-3">
        <span className="text-sm font-medium">External attendees joining your team:</span>
        {data.map((e) => (
          <Badge key={e.id} variant="outline">
            {e.name}{e.roleNote ? ` (${e.roleNote})` : ''}
          </Badge>
        ))}
      </CardContent>
    </Card>
  )
}
  • Step 4: Mount on the project detail page above (or below) the team-members section.

  • Step 5: Live smoke — add a project-attached external as admin, switch to team-lead account, confirm strip shows.

  • Step 6: Commit

git add src/components/applicant/external-attendees-strip.tsx \
       src/server/routers/lunch.ts \
       tests/unit/lunch-router.test.ts \
       <project-detail-page-path>
git commit -m "feat: read-only external attendees strip on project page"

Task 22: Drop Lunch line from edition-settings "Coming soon" card

Files:

  • Modify: src/components/admin/settings/edition-settings-tab.tsx

  • Step 1: Update the import line

In src/components/admin/settings/edition-settings-tab.tsx, replace:

import { Loader2, Salad, ScrollText, Stamp, Users } from 'lucide-react'

with:

import { Loader2, ScrollText, Stamp, Users } from 'lucide-react'
  • Step 2: Update the Coming-soon card

Replace the existing block (currently around :203-221):

{/* Coming soon */}
<Card className="border-dashed">
  <CardHeader>
    <CardTitle className="text-muted-foreground text-base">Coming soon</CardTitle>
    <CardDescription>
      Lunch-event configuration and editable email templates land in upcoming
      updates and will surface here.
    </CardDescription>
  </CardHeader>
  <CardContent className="text-muted-foreground space-y-2 text-sm">
    <div className="flex items-center gap-2">
      <Salad className="h-4 w-4" /> Lunch event  dishes, allergies, RSVP deadline
    </div>
    <div className="flex items-center gap-2">
      <ScrollText className="h-4 w-4" /> Email templates  editable subject + body
      for confirmation, decline-cascade, mentor onboarding, etc.
    </div>
  </CardContent>
</Card>

with:

{/* Coming soon */}
<Card className="border-dashed">
  <CardHeader>
    <CardTitle className="text-muted-foreground text-base">Coming soon</CardTitle>
    <CardDescription>
      Editable email templates land in an upcoming update and will surface here.
    </CardDescription>
  </CardHeader>
  <CardContent className="text-muted-foreground space-y-2 text-sm">
    <div className="flex items-center gap-2">
      <ScrollText className="h-4 w-4" /> Email templates  editable subject + body
      for confirmation, decline-cascade, mentor onboarding, etc.
    </div>
  </CardContent>
</Card>
  • Step 3: Live smoke — open /admin/settings → Edition. Coming-soon card should mention only email templates.

  • Step 4: Commit

git add src/components/admin/settings/edition-settings-tab.tsx
git commit -m "chore: drop lunch placeholder from edition settings coming-soon card"

Task 23: Final verification

  • Step 1: Full test suite
npx vitest run

Expected: all green. Roughly +30 tests over baseline (lunch-router, lunch-upsert-pick, lunch-recap, lunch-cron, lunch-pick-sync).

  • Step 2: Typecheck
npm run typecheck

Expected: clean.

  • Step 3: Production build
npm run build

Expected: clean.

  • Step 4: Lint
npm run lint

Expected: clean.

  • Step 5: End-to-end smoke (browser)
  1. As SUPER_ADMIN: open /admin/logistics → Lunch tab.
  2. Toggle enabled. Set eventAt, venue. Save.
  3. Add 3 dishes with mixed dietary tags.
  4. Add a standalone external + a project-attached external.
  5. As an attending member of a CONFIRMED project: open /applicant. Verify lunch banner. Pick a dish, log allergens.
  6. As the team lead: edit a teammate's pick.
  7. Past deadline (set changeCutoffHours to 0 + eventAt in the past briefly): member sees read-only state.
  8. Back as admin: hit "Send recap now" → success toast. Click again → confirm dialog → resend.
  9. Hit cron endpoints with curl -X POST -H "x-cron-secret: $CRON_SECRET" $URL/api/cron/lunch-reminders and /api/cron/lunch-recap — confirm idempotency.
  10. Download CSV — confirm file opens cleanly in a spreadsheet.
  • Step 6: Final commit (if any cleanup)
git status   # confirm tree is clean

Self-review checklist (for the engineer executing this plan)

  1. Spec coverage: every section of docs/superpowers/specs/2026-04-29-pr6-lunch-event-design.md maps to a task above.
  2. Permission matrix (spec §2) — implemented in Task 6 (upsertPick) and Task 20 (UI gating).
  3. Cutoff enforcementupsertPick server-side guard (Task 6) + UI read-only state (Task 20).
  4. Dish delete preserves picks — Task 4 test asserts this; Prisma SetNull enforces.
  5. External attendees can be standalone or project-attached — Task 5 + Task 17 + Task 21.
  6. Recap "send updated?" — Task 9 server-side forceUpdate flag + Task 18 UI confirm dialog.
  7. Cron idempotency — Tasks 11 + 12 tests assert.
  8. Audit log entries — every mutation procedure writes a DecisionAuditLog row with the spec's exact eventType strings.
  9. No keyboard shortcuts introduced.
  10. No new public token-gated pages — picker stays inside authenticated dashboard.