diff --git a/docs/superpowers/plans/2026-04-29-pr6-lunch-event.md b/docs/superpowers/plans/2026-04-29-pr6-lunch-event.md new file mode 100644 index 0000000..c8ee67d --- /dev/null +++ b/docs/superpowers/plans/2026-04-29-pr6-lunch-event.md @@ -0,0 +1,3476 @@ +# 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 `` +- `src/components/applicant/attending-members-card.tsx` — embed `` per row +- `src/app/(applicant)/applicant/page.tsx` — render `` above the attending-members card +- `src/app/(applicant)/applicant/projects/[projectId]/page.tsx` (or equivalent) — render `` (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 { + 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('@/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 = {} + const dietaryCounts: Record = {} + const allergenCounts: Record = {} + 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 { + 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 = ` +

Hi ${opts.memberName ?? 'there'},

+

You haven't picked your lunch dish for the Monaco Ocean Protection Challenge grand finale yet.

+

Event: ${fmt.format(opts.eventAt)} (Europe/Monaco)
+ ${opts.venue ? `Venue: ${opts.venue}
` : ''} + Deadline to pick: ${fmt.format(opts.changeDeadline)}

+

Open the picker

+ ` + 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 + dietaryCounts: Record + allergenCounts: Record + summary: { total: number; picked: number; missing: number } +} + +export async function sendLunchRecapEmail( + recipients: string[], + payload: LunchRecapPayload, +): Promise { + 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]) => `
  • ${n}× ${name}
  • `).join('') + const allergyLines = Object.entries(payload.allergenCounts) + .map(([name, n]) => `
  • ${n}× ${name}
  • `).join('') + const memberRows = payload.members.map((r) => ` + + ${r.project?.name ?? ''} + ${r.name} + ${r.dish?.name ?? '—'} + ${[...r.allergens, r.allergenOther].filter(Boolean).join(', ')} + + `).join('') + const externalRows = payload.externals.map((r) => ` + + External${r.project?.name ? ` (with ${r.project.name})` : ''} + ${r.name}${r.roleNote ? ` — ${r.roleNote}` : ''} + ${r.dish?.name ?? '—'} + ${[...r.allergens, r.allergenOther].filter(Boolean).join(', ')} + + `).join('') + const html = ` +

    Lunch manifest

    +

    ${payload.summary.picked}/${payload.summary.total} picked${payload.summary.missing ? ` (${payload.summary.missing} missing)` : ''}

    +

    Dishes

      ${dishLines}
    +

    Allergens

      ${allergyLines || '
    • None reported
    • '}
    + + + ${memberRows}${externalRows} +
    TeamNameDishAllergies
    + ` + 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('@/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 + } + if (!event.enabled) { + return ( + + + Lunch is disabled + + +

    + Toggle Lunch on from the Event configuration card to begin setup. +

    + {/* Event config card mounts in Task 14, replacing this stub. */} +
    +
    + ) + } + return ( +
    + {/* Cards mount in Tasks 14-18. */} +

    Lunch tab — cards land in upcoming tasks.

    +
    + ) +} +``` + +- [ ] **Step 2: Mount it in the logistics page** + +In `src/app/(admin)/admin/logistics/page.tsx`: + +- Remove `disabled` from the Lunch `` (line ~55-58). +- Drop the "(soon)" span. +- Add a new `` block. +- Import `LunchTab`. + +```tsx +import { LunchTab } from '@/components/admin/logistics/lunch-tab' +// ... + + Lunch + +// ... + + + +``` + +- [ ] **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['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 ( + + + Event configuration + Per-edition lunch event settings. + + + {/* enabled */} +
    + + update.mutate({ programId, enabled: v })} + disabled={update.isPending} + /> +
    + + {/* eventAt */} +
    + + { + const v = e.target.value + update.mutate({ + programId, + eventAt: v ? new Date(v) : null, + }) + }} + disabled={update.isPending} + /> +
    + + {/* endAt */} +
    + + { + const v = e.target.value + update.mutate({ programId, endAt: v ? new Date(v) : null }) + }} + disabled={update.isPending} + /> +
    + + {/* venue */} +
    + + update.mutate({ programId, venue: e.target.value || null })} + disabled={update.isPending} + /> +
    + + {/* notes */} +
    + +