# 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.ts` — `ensureLunchPickForAttendingMember` 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):

```prisma
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`):

```prisma
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):

```prisma
  lunchEvent LunchEvent?
```

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

```prisma
  lunchPick MemberLunchPick?
```

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

```prisma
  externalLunchAttendees ExternalAttendee[]
```

- [ ] **Step 4: Generate the migration**

```bash
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**

```bash
npx prisma generate
```

- [ ] **Step 6: Typecheck**

```bash
npm run typecheck
```

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

- [ ] **Step 7: Commit**

```bash
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:

```bash
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`:

```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):

```bash
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`:

```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**

```bash
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:

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

For each create site (typical example):

```ts
const member = await ctx.prisma.attendingMember.create({ data: { confirmationId, userId } })
await ensureLunchPickForAttendingMember(ctx.prisma, member.id)
```

- [ ] **Step 7: Run the full test suite**

```bash
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**

```bash
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`:

```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`:

```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`:

```ts
import { lunchRouter } from './lunch'
// ...
export const appRouter = router({
  // ...existing routers...
  lunch: lunchRouter,
})
```

- [ ] **Step 5: Run tests, expect green**

```bash
npx vitest run tests/unit/lunch-router.test.ts
```

- [ ] **Step 6: Commit**

```bash
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`:

```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({ ... })`:

```ts
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**

```bash
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**:

```ts
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**:

```ts
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**

```bash
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`:

```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`:

```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**

```bash
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**:

```ts
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**:

```ts
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**

```bash
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**:

```ts
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`:

```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):

```ts
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:

```ts
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**

```bash
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`:

```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`:

```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`:

```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**

```bash
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):

```ts
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**

```ts
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**

```bash
npm run typecheck
```

- [ ] **Step 4: Commit**

```bash
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`:

```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`:

```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**

```bash
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:

```ts
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`:

```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**

```bash
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`:

```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`.

```tsx
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 smoke** — `npm run dev`, open `/admin/logistics`, click Lunch. Expected: empty-state card.

- [ ] **Step 4: Commit**

```bash
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)

```tsx
'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**

```bash
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`:

```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**

```bash
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.

```tsx
'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`):

```tsx
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**

```bash
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`:

```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`:

```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:

```tsx
<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`**

```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**

```bash
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: <timestamp> · 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).

```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 { 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**

```bash
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.

```tsx
'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**

```bash
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**

```bash
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`:

```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`:

```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**

```bash
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**

```tsx
'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**

```bash
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:

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

with:

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

- [ ] **Step 2: Update the Coming-soon card**

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

```tsx
{/* 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:

```tsx
{/* 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**

```bash
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**

```bash
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**

```bash
npm run typecheck
```

Expected: clean.

- [ ] **Step 3: Production build**

```bash
npm run build
```

Expected: clean.

- [ ] **Step 4: Lint**

```bash
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)**

```bash
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 enforcement** — `upsertPick` 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.
