# 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 */}