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

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

3477 lines
122 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PR 6: Lunch Event Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the placeholder Lunch tab on `/admin/logistics` with a working flow: admins configure a single per-edition lunch event (date, venue, dishes, change deadline, recipients), attendees pick a dish + log allergies, and the manifest is reviewable in-app, exportable to CSV, and emailed to admins at the change deadline.
**Architecture:** Four new Prisma models (`LunchEvent` 1:1 with `Program`, `Dish` per event, `MemberLunchPick` 1:1 with `AttendingMember`, `ExternalAttendee` per event with optional `projectId`) plus two enums (`DietaryTag`, `Allergen`). One new tRPC router (`lunch`) carrying admin CRUD, a mixed-permission `upsertPick` procedure (member-self / team-lead / admin), and member reads for the dashboard banner + team-wide visibility. Two cron endpoints (reminders + recap) reuse the `/api/cron/*` pattern. Email templates land inline in `src/lib/email.ts`. The five-card admin UI sits on the existing Logistics → Lunch tab; the member picker extends `AttendingMembersCard` on the applicant dashboard.
**Tech Stack:** Prisma 6 + PostgreSQL (additive migration), tRPC 11 with Zod, Vitest 4 sequential pool, NextAuth 5 RBAC via the existing procedure middleware, shadcn/ui for cards/tables/dialogs, nodemailer via the existing `sendEmail` helper.
**Spec:** `docs/superpowers/specs/2026-04-29-pr6-lunch-event-design.md`
---
## File map
**Create:**
- `src/server/routers/lunch.ts` — router with all admin + member procedures
- `src/server/services/lunch-pick-sync.ts``ensureLunchPickForAttendingMember` helper
- `src/server/services/lunch-recap.ts` — manifest aggregation + recap payload builder
- `src/app/api/cron/lunch-reminders/route.ts`
- `src/app/api/cron/lunch-recap/route.ts`
- `src/components/admin/logistics/lunch-tab.tsx` — orchestrates the five cards
- `src/components/admin/logistics/lunch-event-config.tsx`
- `src/components/admin/logistics/lunch-dishes.tsx`
- `src/components/admin/logistics/lunch-manifest.tsx`
- `src/components/admin/logistics/lunch-externals.tsx`
- `src/components/admin/logistics/lunch-recap-actions.tsx`
- `src/components/applicant/lunch-banner.tsx`
- `src/components/applicant/lunch-pick-form.tsx` — used inside `AttendingMembersCard` rows
- `src/components/applicant/external-attendees-strip.tsx` — read-only strip on the project page
- `tests/unit/lunch-router.test.ts`
- `tests/unit/lunch-upsert-pick.test.ts`
- `tests/unit/lunch-recap.test.ts`
- `tests/unit/lunch-cron.test.ts`
- `tests/unit/lunch-pick-sync.test.ts`
**Modify:**
- `prisma/schema.prisma` — new models, enums, back-refs on `Program` / `AttendingMember` / `Project`
- `src/server/routers/_app.ts` — mount `lunch` router
- `src/server/routers/finalist.ts` — wire `ensureLunchPickForAttendingMember` into the attendee-write paths
- `src/lib/email.ts` — append two new template functions
- `src/app/(admin)/admin/logistics/page.tsx` — un-disable the Lunch tab trigger and mount `<LunchTab>`
- `src/components/applicant/attending-members-card.tsx` — embed `<LunchPickForm>` per row
- `src/app/(applicant)/applicant/page.tsx` — render `<LunchBanner>` above the attending-members card
- `src/app/(applicant)/applicant/projects/[projectId]/page.tsx` (or equivalent) — render `<ExternalAttendeesStrip>` (verify exact path during Task 21)
- `src/components/admin/settings/edition-settings-tab.tsx` — drop the Lunch line from the "Coming soon" card
---
## Task 1: Schema migration — models, enums, back-refs
**Files:**
- Modify: `prisma/schema.prisma`
- Generate: a new migration via `npx prisma migrate dev --name add_lunch_event`
- [ ] **Step 1: Add the two enums** near the other domain enums in `schema.prisma` (right after `WaitlistEntryStatus` is a reasonable home):
```prisma
enum DietaryTag {
VEGETARIAN
VEGAN
GLUTEN_FREE
PESCATARIAN
}
enum Allergen {
GLUTEN
CRUSTACEANS
EGGS
FISH
PEANUTS
SOYBEANS
MILK
TREE_NUTS
CELERY
MUSTARD
SESAME
SULPHITES
LUPIN
MOLLUSCS
}
```
- [ ] **Step 2: Add the four models** in the same logistics section (right after `FlightDetail` / `VisaApplication`):
```prisma
model LunchEvent {
id String @id @default(cuid())
programId String @unique
enabled Boolean @default(false)
eventAt DateTime?
endAt DateTime?
venue String?
notes String? @db.Text
changeCutoffHours Int @default(48)
reminderHoursBeforeDeadline Int?
cronEnabled Boolean @default(true)
extraRecipients String[] @default([])
reminderSentAt DateTime?
recapSentAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
dishes Dish[]
externalAttendees ExternalAttendee[]
}
model Dish {
id String @id @default(cuid())
lunchEventId String
name String
sortOrder Int @default(0)
dietaryTags DietaryTag[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade)
memberPicks MemberLunchPick[]
externals ExternalAttendee[]
@@index([lunchEventId])
}
model MemberLunchPick {
id String @id @default(cuid())
attendingMemberId String @unique
dishId String?
allergens Allergen[] @default([])
allergenOther String?
pickedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
attendingMember AttendingMember @relation(fields: [attendingMemberId], references: [id], onDelete: Cascade)
dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull)
@@index([dishId])
}
model ExternalAttendee {
id String @id @default(cuid())
lunchEventId String
projectId String?
name String
email String?
roleNote String?
dishId String?
allergens Allergen[] @default([])
allergenOther String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lunchEvent LunchEvent @relation(fields: [lunchEventId], references: [id], onDelete: Cascade)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
dish Dish? @relation(fields: [dishId], references: [id], onDelete: SetNull)
@@index([lunchEventId])
@@index([projectId])
}
```
- [ ] **Step 3: Add the three back-refs** on existing models. Find them by searching `schema.prisma` for the existing relation lines:
In `model Program { ... }` (just below `hotel Hotel?` at the bottom of the relation block):
```prisma
lunchEvent LunchEvent?
```
In `model AttendingMember { ... }` (just below `visaApplication VisaApplication?`):
```prisma
lunchPick MemberLunchPick?
```
In `model Project { ... }` (just below `finalistAttendances AttendingMember[]`):
```prisma
externalLunchAttendees ExternalAttendee[]
```
- [ ] **Step 4: Generate the migration**
```bash
npx prisma migrate dev --name add_lunch_event
```
Expected: a new folder under `prisma/migrations/` with a `migration.sql` that creates the four new tables, the two enums, and adds no columns to existing tables (back-refs are Prisma-side only).
- [ ] **Step 5: Regenerate the client**
```bash
npx prisma generate
```
- [ ] **Step 6: Typecheck**
```bash
npm run typecheck
```
Expected: clean. (No code references the new models yet.)
- [ ] **Step 7: Commit**
```bash
git add prisma/schema.prisma prisma/migrations
git commit -m "feat: schema for lunch event, dishes, picks, externals"
```
---
## Task 2: Auto-create `MemberLunchPick` on attending-member writes (TDD)
**Files:**
- Create: `src/server/services/lunch-pick-sync.ts`
- Modify: `src/server/routers/finalist.ts` (the existing `confirm`, `editAttendees`, `unconfirm` paths that touch `AttendingMember`)
- Create: `tests/unit/lunch-pick-sync.test.ts`
- [ ] **Step 1: Locate the existing attendee write paths**
Run:
```bash
grep -n "AttendingMember" src/server/routers/finalist.ts | head -20
```
Confirm which functions create / delete `AttendingMember` rows. Expected hits: the `confirm` mutation and the `editAttendees` mutation. Note the file:line range you'll need to touch.
- [ ] **Step 2: Failing tests**
Create `tests/unit/lunch-pick-sync.test.ts`:
```ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma } from '../setup'
import {
createTestUser,
createTestProgram,
createTestCompetition,
createTestRound,
cleanupTestData,
uid,
} from '../helpers'
import { ensureLunchPickForAttendingMember } from '@/server/services/lunch-pick-sync'
describe('ensureLunchPickForAttendingMember', () => {
let programId: string
let userId: string
let projectId: string
let confirmationId: string
beforeAll(async () => {
const program = await createTestProgram()
programId = program.id
const competition = await createTestCompetition(programId)
await createTestRound(competition.id, 'LIVE_FINAL')
const user = await createTestUser('APPLICANT')
userId = user.id
const project = await prisma.project.create({
data: { programId, name: `lunch-sync-${uid()}`, category: 'IMPACT' },
})
projectId = project.id
const confirmation = await prisma.finalistConfirmation.create({
data: {
projectId,
category: 'IMPACT',
status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000),
token: `tok-${uid()}`,
},
})
confirmationId = confirmation.id
})
afterAll(async () => {
await cleanupTestData()
})
it('creates an empty MemberLunchPick when a LunchEvent exists', async () => {
await prisma.lunchEvent.create({ data: { programId } })
const member = await prisma.attendingMember.create({
data: { confirmationId, userId },
})
await ensureLunchPickForAttendingMember(prisma, member.id)
const pick = await prisma.memberLunchPick.findUnique({
where: { attendingMemberId: member.id },
})
expect(pick).not.toBeNull()
expect(pick?.dishId).toBeNull()
expect(pick?.pickedAt).toBeNull()
})
it('is idempotent — calling twice does not create a second pick', async () => {
const member = await prisma.attendingMember.findFirst({
where: { confirmation: { projectId } },
})
if (!member) throw new Error('expected member from previous test')
await ensureLunchPickForAttendingMember(prisma, member.id)
const picks = await prisma.memberLunchPick.findMany({
where: { attendingMemberId: member.id },
})
expect(picks).toHaveLength(1)
})
it('no-ops when no LunchEvent exists for the program', async () => {
const program2 = await createTestProgram()
const competition2 = await createTestCompetition(program2.id)
await createTestRound(competition2.id, 'LIVE_FINAL')
const project2 = await prisma.project.create({
data: { programId: program2.id, name: `np-${uid()}`, category: 'IMPACT' },
})
const conf2 = await prisma.finalistConfirmation.create({
data: {
projectId: project2.id,
category: 'IMPACT',
status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000),
token: `tok-${uid()}`,
},
})
const u2 = await createTestUser('APPLICANT')
const member = await prisma.attendingMember.create({
data: { confirmationId: conf2.id, userId: u2.id },
})
await ensureLunchPickForAttendingMember(prisma, member.id)
const pick = await prisma.memberLunchPick.findUnique({
where: { attendingMemberId: member.id },
})
expect(pick).toBeNull()
})
})
```
- [ ] **Step 3: Run, expect failure** (file does not exist):
```bash
npx vitest run tests/unit/lunch-pick-sync.test.ts
```
Expected: import error / module not found.
- [ ] **Step 4: Implement the helper**
Create `src/server/services/lunch-pick-sync.ts`:
```ts
import type { PrismaClient } from '@prisma/client'
/**
* Ensure a MemberLunchPick row exists for the given AttendingMember.
* No-ops when the parent program has no LunchEvent.
* Safe to call repeatedly — idempotent on the unique attendingMemberId.
*/
export async function ensureLunchPickForAttendingMember(
prisma: PrismaClient,
attendingMemberId: string,
): Promise<void> {
const member = await prisma.attendingMember.findUnique({
where: { id: attendingMemberId },
select: {
id: true,
confirmation: { select: { project: { select: { programId: true } } } },
lunchPick: { select: { id: true } },
},
})
if (!member) return
if (member.lunchPick) return
const programId = member.confirmation.project.programId
const lunchEvent = await prisma.lunchEvent.findUnique({
where: { programId },
select: { id: true },
})
if (!lunchEvent) return
await prisma.memberLunchPick.create({
data: { attendingMemberId: member.id },
})
}
```
- [ ] **Step 5: Run, expect green**
```bash
npx vitest run tests/unit/lunch-pick-sync.test.ts
```
- [ ] **Step 6: Wire into `finalist.ts`**
In `src/server/routers/finalist.ts`, after every `prisma.attendingMember.create({...})` call, call `await ensureLunchPickForAttendingMember(ctx.prisma, member.id)`. Confirm the existing import block at the top of the file gets the new import:
```ts
import { ensureLunchPickForAttendingMember } from '@/server/services/lunch-pick-sync'
```
For each create site (typical example):
```ts
const member = await ctx.prisma.attendingMember.create({ data: { confirmationId, userId } })
await ensureLunchPickForAttendingMember(ctx.prisma, member.id)
```
- [ ] **Step 7: Run the full test suite**
```bash
npx vitest run
```
Expected: all green (existing finalist tests should still pass — the helper is a no-op for programs without a `LunchEvent`).
- [ ] **Step 8: Commit**
```bash
git add src/server/services/lunch-pick-sync.ts src/server/routers/finalist.ts tests/unit/lunch-pick-sync.test.ts
git commit -m "feat: auto-create MemberLunchPick on attendee writes"
```
---
## Task 3: `lunch.getEvent` + `lunch.updateEvent` (TDD)
**Files:**
- Create: `src/server/routers/lunch.ts`
- Modify: `src/server/routers/_app.ts`
- Create: `tests/unit/lunch-router.test.ts`
- [ ] **Step 1: Failing tests** at the top of `tests/unit/lunch-router.test.ts`:
```ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { prisma } from '../setup'
import { createTestUser, createTestProgram, cleanupTestData, uid } from '../helpers'
import { createCaller } from '../helpers'
import * as lunchRouter from '@/server/routers/lunch'
describe('lunch.getEvent', () => {
it('lazily creates a LunchEvent on first call', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
const result = await caller.getEvent({ programId: program.id })
expect(result.programId).toBe(program.id)
expect(result.enabled).toBe(false)
expect(result.changeCutoffHours).toBe(48)
const row = await prisma.lunchEvent.findUnique({ where: { programId: program.id } })
expect(row).not.toBeNull()
})
it('returns the same row on subsequent calls', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
const a = await caller.getEvent({ programId: program.id })
const b = await caller.getEvent({ programId: program.id })
expect(a.id).toBe(b.id)
})
})
describe('lunch.updateEvent', () => {
it('patches an arbitrary subset of fields', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
await caller.getEvent({ programId: program.id })
const updated = await caller.updateEvent({
programId: program.id,
enabled: true,
eventAt: new Date('2026-06-28T12:30:00Z'),
venue: 'Hôtel Hermitage',
changeCutoffHours: 24,
extraRecipients: ['caterer@example.com'],
})
expect(updated.enabled).toBe(true)
expect(updated.venue).toBe('Hôtel Hermitage')
expect(updated.changeCutoffHours).toBe(24)
expect(updated.extraRecipients).toEqual(['caterer@example.com'])
})
it('rejects non-admin callers', async () => {
const program = await createTestProgram()
const member = await createTestUser('APPLICANT')
const caller = createCaller(lunchRouter, member)
await expect(caller.updateEvent({ programId: program.id, enabled: true })).rejects.toThrow()
})
})
afterAll(async () => {
await cleanupTestData()
})
```
- [ ] **Step 2: Run, expect failure** — module not found.
- [ ] **Step 3: Create the router skeleton**
`src/server/routers/lunch.ts`:
```ts
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, protectedProcedure } from '@/server/trpc'
export const lunchRouter = router({
getEvent: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const existing = await ctx.prisma.lunchEvent.findUnique({
where: { programId: input.programId },
})
if (existing) return existing
return ctx.prisma.lunchEvent.create({ data: { programId: input.programId } })
}),
updateEvent: adminProcedure
.input(
z.object({
programId: z.string(),
enabled: z.boolean().optional(),
eventAt: z.date().nullable().optional(),
endAt: z.date().nullable().optional(),
venue: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
changeCutoffHours: z.number().int().min(0).max(720).optional(),
reminderHoursBeforeDeadline: z.number().int().min(0).max(720).nullable().optional(),
cronEnabled: z.boolean().optional(),
extraRecipients: z.array(z.string().email()).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { programId, ...patch } = input
// Ensure the event exists (lazy create) before patching
await ctx.prisma.lunchEvent.upsert({
where: { programId },
create: { programId },
update: {},
})
const updated = await ctx.prisma.lunchEvent.update({
where: { programId },
data: patch,
})
await ctx.prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_EVENT_UPDATED',
entityType: 'LunchEvent',
entityId: updated.id,
actorId: ctx.session.user.id,
detailsJson: patch,
},
})
return updated
}),
})
```
- [ ] **Step 4: Mount the router** in `src/server/routers/_app.ts`:
```ts
import { lunchRouter } from './lunch'
// ...
export const appRouter = router({
// ...existing routers...
lunch: lunchRouter,
})
```
- [ ] **Step 5: Run tests, expect green**
```bash
npx vitest run tests/unit/lunch-router.test.ts
```
- [ ] **Step 6: Commit**
```bash
git add src/server/routers/lunch.ts src/server/routers/_app.ts tests/unit/lunch-router.test.ts
git commit -m "feat: lunch.getEvent + lunch.updateEvent procedures"
```
---
## Task 4: Dish CRUD (TDD)
**Files:**
- Modify: `src/server/routers/lunch.ts`
- Modify: `tests/unit/lunch-router.test.ts`
- [ ] **Step 1: Failing tests** appended to `lunch-router.test.ts`:
```ts
describe('dish CRUD', () => {
it('createDish + listDishes returns dishes ordered by sortOrder', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
const event = await caller.getEvent({ programId: program.id })
await caller.createDish({ lunchEventId: event.id, name: 'Sea bass', dietaryTags: ['PESCATARIAN'], sortOrder: 1 })
await caller.createDish({ lunchEventId: event.id, name: 'Risotto', dietaryTags: ['VEGETARIAN'], sortOrder: 0 })
const dishes = await caller.listDishes({ lunchEventId: event.id })
expect(dishes.map(d => d.name)).toEqual(['Risotto', 'Sea bass'])
})
it('updateDish patches name + tags', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
const event = await caller.getEvent({ programId: program.id })
const dish = await caller.createDish({ lunchEventId: event.id, name: 'A', dietaryTags: [] })
const updated = await caller.updateDish({ dishId: dish.id, name: 'B', dietaryTags: ['VEGAN'] })
expect(updated.name).toBe('B')
expect(updated.dietaryTags).toEqual(['VEGAN'])
})
it('deleteDish sets dishId=null on existing picks', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
const event = await caller.getEvent({ programId: program.id })
const dish = await caller.createDish({ lunchEventId: event.id, name: 'X', dietaryTags: [] })
// Stand up an attending member with a pick referencing this dish
const user = await createTestUser('APPLICANT')
const project = await prisma.project.create({
data: { programId: program.id, name: `proj-${uid()}`, category: 'IMPACT' },
})
const conf = await prisma.finalistConfirmation.create({
data: { projectId: project.id, category: 'IMPACT', status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}` },
})
const member = await prisma.attendingMember.create({
data: { confirmationId: conf.id, userId: user.id },
})
await prisma.memberLunchPick.create({
data: { attendingMemberId: member.id, dishId: dish.id, pickedAt: new Date() },
})
await caller.deleteDish({ dishId: dish.id })
const pick = await prisma.memberLunchPick.findUnique({
where: { attendingMemberId: member.id },
})
expect(pick?.dishId).toBeNull()
})
it('reorderDishes commits new sortOrder values', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
const event = await caller.getEvent({ programId: program.id })
const a = await caller.createDish({ lunchEventId: event.id, name: 'a', dietaryTags: [], sortOrder: 0 })
const b = await caller.createDish({ lunchEventId: event.id, name: 'b', dietaryTags: [], sortOrder: 1 })
await caller.reorderDishes({ ordered: [{ dishId: b.id, sortOrder: 0 }, { dishId: a.id, sortOrder: 1 }] })
const dishes = await caller.listDishes({ lunchEventId: event.id })
expect(dishes.map(d => d.name)).toEqual(['b', 'a'])
})
})
```
- [ ] **Step 2: Run, expect failure**.
- [ ] **Step 3: Add procedures to `lunch.ts`** inside the existing `router({ ... })`:
```ts
const dietaryTags = z.array(z.enum(['VEGETARIAN', 'VEGAN', 'GLUTEN_FREE', 'PESCATARIAN']))
listDishes: adminProcedure
.input(z.object({ lunchEventId: z.string() }))
.query(({ ctx, input }) =>
ctx.prisma.dish.findMany({
where: { lunchEventId: input.lunchEventId },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
}),
),
createDish: adminProcedure
.input(z.object({
lunchEventId: z.string(),
name: z.string().min(1).max(200),
dietaryTags,
sortOrder: z.number().int().optional(),
}))
.mutation(async ({ ctx, input }) => {
const dish = await ctx.prisma.dish.create({
data: {
lunchEventId: input.lunchEventId,
name: input.name,
dietaryTags: input.dietaryTags,
sortOrder: input.sortOrder ?? 0,
},
})
await ctx.prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_DISH_CREATED',
entityType: 'Dish',
entityId: dish.id,
actorId: ctx.session.user.id,
detailsJson: { name: dish.name, dietaryTags: dish.dietaryTags },
},
})
return dish
}),
updateDish: adminProcedure
.input(z.object({
dishId: z.string(),
name: z.string().min(1).max(200).optional(),
dietaryTags: dietaryTags.optional(),
sortOrder: z.number().int().optional(),
}))
.mutation(async ({ ctx, input }) => {
const { dishId, ...patch } = input
const dish = await ctx.prisma.dish.update({ where: { id: dishId }, data: patch })
await ctx.prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_DISH_UPDATED',
entityType: 'Dish',
entityId: dish.id,
actorId: ctx.session.user.id,
detailsJson: patch,
},
})
return dish
}),
deleteDish: adminProcedure
.input(z.object({ dishId: z.string() }))
.mutation(async ({ ctx, input }) => {
const dish = await ctx.prisma.dish.delete({ where: { id: input.dishId } })
await ctx.prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_DISH_DELETED',
entityType: 'Dish',
entityId: dish.id,
actorId: ctx.session.user.id,
detailsJson: { name: dish.name },
},
})
return { ok: true as const }
}),
reorderDishes: adminProcedure
.input(z.object({
ordered: z.array(z.object({ dishId: z.string(), sortOrder: z.number().int() })),
}))
.mutation(async ({ ctx, input }) => {
await ctx.prisma.$transaction(
input.ordered.map(({ dishId, sortOrder }) =>
ctx.prisma.dish.update({ where: { id: dishId }, data: { sortOrder } }),
),
)
return { ok: true as const }
}),
```
- [ ] **Step 4: Run, expect green**.
- [ ] **Step 5: Commit**
```bash
git add src/server/routers/lunch.ts tests/unit/lunch-router.test.ts
git commit -m "feat: dish CRUD on lunch router"
```
---
## Task 5: External attendees CRUD (TDD)
**Files:**
- Modify: `src/server/routers/lunch.ts`
- Modify: `tests/unit/lunch-router.test.ts`
- [ ] **Step 1: Failing tests**:
```ts
describe('external attendees CRUD', () => {
it('listExternals returns standalone + project-attached entries', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
const event = await caller.getEvent({ programId: program.id })
const project = await prisma.project.create({
data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
})
await caller.createExternal({
lunchEventId: event.id, name: 'Princess Albert',
roleNote: 'Foundation rep',
})
await caller.createExternal({
lunchEventId: event.id, name: 'Speaker Smith',
projectId: project.id, email: 's@example.com',
})
const list = await caller.listExternals({ lunchEventId: event.id })
expect(list).toHaveLength(2)
expect(list.find(e => e.name === 'Princess Albert')?.projectId).toBeNull()
expect(list.find(e => e.name === 'Speaker Smith')?.projectId).toBe(project.id)
})
it('updateExternal patches fields including dishId + allergens', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
const event = await caller.getEvent({ programId: program.id })
const dish = await caller.createDish({ lunchEventId: event.id, name: 'Steak', dietaryTags: [] })
const ext = await caller.createExternal({ lunchEventId: event.id, name: 'X' })
const updated = await caller.updateExternal({
externalId: ext.id, dishId: dish.id, allergens: ['GLUTEN', 'TREE_NUTS'],
allergenOther: 'sulphites in red wine',
})
expect(updated.dishId).toBe(dish.id)
expect(updated.allergens).toEqual(['GLUTEN', 'TREE_NUTS'])
})
it('deleteExternal removes the row', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const caller = createCaller(lunchRouter, admin)
const event = await caller.getEvent({ programId: program.id })
const ext = await caller.createExternal({ lunchEventId: event.id, name: 'tmp' })
await caller.deleteExternal({ externalId: ext.id })
const list = await caller.listExternals({ lunchEventId: event.id })
expect(list.find(e => e.id === ext.id)).toBeUndefined()
})
it('rejects non-admin callers', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const adminCaller = createCaller(lunchRouter, admin)
const event = await adminCaller.getEvent({ programId: program.id })
const member = await createTestUser('APPLICANT')
const memberCaller = createCaller(lunchRouter, member)
await expect(
memberCaller.createExternal({ lunchEventId: event.id, name: 'nope' }),
).rejects.toThrow()
})
})
```
- [ ] **Step 2: Run, expect failure**.
- [ ] **Step 3: Add procedures**:
```ts
const allergens = z.array(z.enum([
'GLUTEN', 'CRUSTACEANS', 'EGGS', 'FISH', 'PEANUTS', 'SOYBEANS', 'MILK',
'TREE_NUTS', 'CELERY', 'MUSTARD', 'SESAME', 'SULPHITES', 'LUPIN', 'MOLLUSCS',
]))
listExternals: adminProcedure
.input(z.object({ lunchEventId: z.string() }))
.query(({ ctx, input }) =>
ctx.prisma.externalAttendee.findMany({
where: { lunchEventId: input.lunchEventId },
orderBy: { createdAt: 'asc' },
include: { project: { select: { id: true, name: true } } },
}),
),
createExternal: adminProcedure
.input(z.object({
lunchEventId: z.string(),
name: z.string().min(1).max(200),
email: z.string().email().optional(),
projectId: z.string().nullable().optional(),
roleNote: z.string().max(500).optional(),
dishId: z.string().nullable().optional(),
allergens: allergens.optional(),
allergenOther: z.string().max(500).optional(),
}))
.mutation(async ({ ctx, input }) => {
const ext = await ctx.prisma.externalAttendee.create({ data: input })
await ctx.prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_EXTERNAL_CREATED', entityType: 'ExternalAttendee',
entityId: ext.id, actorId: ctx.session.user.id,
detailsJson: { name: ext.name, projectId: ext.projectId },
},
})
return ext
}),
updateExternal: adminProcedure
.input(z.object({
externalId: z.string(),
name: z.string().min(1).max(200).optional(),
email: z.string().email().nullable().optional(),
projectId: z.string().nullable().optional(),
roleNote: z.string().max(500).nullable().optional(),
dishId: z.string().nullable().optional(),
allergens: allergens.optional(),
allergenOther: z.string().max(500).nullable().optional(),
}))
.mutation(async ({ ctx, input }) => {
const { externalId, ...patch } = input
const ext = await ctx.prisma.externalAttendee.update({
where: { id: externalId }, data: patch,
})
await ctx.prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_EXTERNAL_UPDATED', entityType: 'ExternalAttendee',
entityId: ext.id, actorId: ctx.session.user.id, detailsJson: patch,
},
})
return ext
}),
deleteExternal: adminProcedure
.input(z.object({ externalId: z.string() }))
.mutation(async ({ ctx, input }) => {
const ext = await ctx.prisma.externalAttendee.delete({ where: { id: input.externalId } })
await ctx.prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_EXTERNAL_DELETED', entityType: 'ExternalAttendee',
entityId: ext.id, actorId: ctx.session.user.id,
detailsJson: { name: ext.name },
},
})
return { ok: true as const }
}),
```
- [ ] **Step 4: Run, expect green**.
- [ ] **Step 5: Commit**
```bash
git add src/server/routers/lunch.ts tests/unit/lunch-router.test.ts
git commit -m "feat: external attendees CRUD"
```
---
## Task 6: `upsertPick` mixed-permission procedure (TDD)
**Files:**
- Modify: `src/server/routers/lunch.ts`
- Create: `tests/unit/lunch-upsert-pick.test.ts`
- [ ] **Step 1: Failing tests** at `tests/unit/lunch-upsert-pick.test.ts`:
```ts
import { describe, it, expect, beforeEach, afterAll } from 'vitest'
import { prisma } from '../setup'
import {
createTestUser, createTestProgram, cleanupTestData, uid, createCaller,
} from '../helpers'
import * as lunchRouter from '@/server/routers/lunch'
async function setupTeam(opts: {
cutoffHours?: number
eventAt?: Date
enabled?: boolean
}) {
const program = await createTestProgram()
const lead = await createTestUser('APPLICANT')
const member = await createTestUser('APPLICANT')
const admin = await createTestUser('SUPER_ADMIN')
const project = await prisma.project.create({
data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
})
await prisma.teamMember.createMany({
data: [
{ projectId: project.id, userId: lead.id, role: 'LEAD' },
{ projectId: project.id, userId: member.id, role: 'MEMBER' },
],
})
const conf = await prisma.finalistConfirmation.create({
data: {
projectId: project.id, category: 'IMPACT', status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}`,
},
})
const am = await prisma.attendingMember.create({
data: { confirmationId: conf.id, userId: member.id },
})
const event = await prisma.lunchEvent.create({
data: {
programId: program.id,
enabled: opts.enabled ?? true,
eventAt: opts.eventAt ?? new Date(Date.now() + 7 * 86_400_000),
changeCutoffHours: opts.cutoffHours ?? 48,
},
})
const dish = await prisma.dish.create({
data: { lunchEventId: event.id, name: 'Risotto', dietaryTags: ['VEGETARIAN'] },
})
await prisma.memberLunchPick.create({ data: { attendingMemberId: am.id } })
return { program, lead, member, admin, project, attendingMember: am, dish, event }
}
afterAll(async () => { await cleanupTestData() })
describe('lunch.upsertPick', () => {
it('member can edit their own pick before deadline', async () => {
const t = await setupTeam({})
const caller = createCaller(lunchRouter, t.member)
const result = await caller.upsertPick({
attendingMemberId: t.attendingMember.id, dishId: t.dish.id,
allergens: ['GLUTEN'], allergenOther: null,
})
expect(result.dishId).toBe(t.dish.id)
expect(result.pickedAt).not.toBeNull()
const audit = await prisma.decisionAuditLog.findFirst({
where: { eventType: 'LUNCH_PICK_UPDATED', entityId: result.id },
orderBy: { createdAt: 'desc' },
})
expect(audit?.detailsJson).toMatchObject({ actorRole: 'SELF' })
})
it('team lead can edit a teammate pick before deadline', async () => {
const t = await setupTeam({})
const caller = createCaller(lunchRouter, t.lead)
const result = await caller.upsertPick({
attendingMemberId: t.attendingMember.id, dishId: t.dish.id,
allergens: [], allergenOther: null,
})
expect(result.dishId).toBe(t.dish.id)
const audit = await prisma.decisionAuditLog.findFirst({
where: { eventType: 'LUNCH_PICK_UPDATED', entityId: result.id },
orderBy: { createdAt: 'desc' },
})
expect(audit?.detailsJson).toMatchObject({ actorRole: 'TEAM_LEAD' })
})
it('member from a different team is forbidden', async () => {
const t = await setupTeam({})
const stranger = await createTestUser('APPLICANT')
const caller = createCaller(lunchRouter, stranger)
await expect(
caller.upsertPick({ attendingMemberId: t.attendingMember.id, dishId: t.dish.id,
allergens: [], allergenOther: null }),
).rejects.toThrow(/FORBIDDEN/)
})
it('member cannot edit their own pick after deadline', async () => {
// event is "now + 1h", cutoffHours = 24 -> deadline already passed
const t = await setupTeam({
eventAt: new Date(Date.now() + 60 * 60 * 1000), cutoffHours: 24,
})
const caller = createCaller(lunchRouter, t.member)
await expect(
caller.upsertPick({ attendingMemberId: t.attendingMember.id, dishId: t.dish.id,
allergens: [], allergenOther: null }),
).rejects.toThrow(/deadline/i)
})
it('admin can edit after deadline; audit records ADMIN role', async () => {
const t = await setupTeam({
eventAt: new Date(Date.now() + 60 * 60 * 1000), cutoffHours: 24,
})
const caller = createCaller(lunchRouter, t.admin)
const result = await caller.upsertPick({
attendingMemberId: t.attendingMember.id, dishId: t.dish.id,
allergens: [], allergenOther: null,
})
expect(result.dishId).toBe(t.dish.id)
const audit = await prisma.decisionAuditLog.findFirst({
where: { eventType: 'LUNCH_PICK_UPDATED', entityId: result.id },
orderBy: { createdAt: 'desc' },
})
expect(audit?.detailsJson).toMatchObject({ actorRole: 'ADMIN' })
})
})
```
- [ ] **Step 2: Run, expect failure**.
- [ ] **Step 3: Add the procedure**
In `lunch.ts`:
```ts
upsertPick: protectedProcedure
.input(z.object({
attendingMemberId: z.string(),
dishId: z.string().nullable(),
allergens,
allergenOther: z.string().max(500).nullable(),
}))
.mutation(async ({ ctx, input }) => {
const am = await ctx.prisma.attendingMember.findUnique({
where: { id: input.attendingMemberId },
include: {
confirmation: {
select: {
project: {
select: {
id: true,
programId: true,
teamMembers: { select: { userId: true, role: true } },
},
},
},
},
lunchPick: true,
},
})
if (!am) throw new TRPCError({ code: 'NOT_FOUND', message: 'Attending member not found' })
const userId = ctx.session.user.id
const userRole = ctx.session.user.role
const isAdmin = userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN'
const isSelf = am.userId === userId
const isLead = am.confirmation.project.teamMembers.some(
(tm) => tm.userId === userId && tm.role === 'LEAD',
)
if (!isAdmin && !isSelf && !isLead) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Not allowed to edit this pick' })
}
// Cutoff check (admins skip)
if (!isAdmin) {
const event = await ctx.prisma.lunchEvent.findUnique({
where: { programId: am.confirmation.project.programId },
select: { eventAt: true, changeCutoffHours: true },
})
if (event?.eventAt) {
const deadline = new Date(event.eventAt.getTime() - event.changeCutoffHours * 3600_000)
if (new Date() > deadline) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Past lunch change deadline. Contact an admin.',
})
}
}
}
const actorRole: 'SELF' | 'TEAM_LEAD' | 'ADMIN' = isAdmin ? 'ADMIN' : isLead && !isSelf ? 'TEAM_LEAD' : 'SELF'
const pick = await ctx.prisma.memberLunchPick.upsert({
where: { attendingMemberId: input.attendingMemberId },
create: {
attendingMemberId: input.attendingMemberId,
dishId: input.dishId,
allergens: input.allergens,
allergenOther: input.allergenOther,
pickedAt: input.dishId ? new Date() : null,
},
update: {
dishId: input.dishId,
allergens: input.allergens,
allergenOther: input.allergenOther,
pickedAt: input.dishId ? new Date() : null,
},
})
await ctx.prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_PICK_UPDATED', entityType: 'MemberLunchPick',
entityId: pick.id, actorId: userId,
detailsJson: {
actorRole,
dishId: input.dishId,
allergenCount: input.allergens.length,
},
},
})
return pick
}),
```
- [ ] **Step 4: Run, expect green**.
- [ ] **Step 5: Commit**
```bash
git add src/server/routers/lunch.ts tests/unit/lunch-upsert-pick.test.ts
git commit -m "feat: lunch.upsertPick with role-aware guard + cutoff"
```
---
## Task 7: Member reads — `getEventForMember` + `getTeamPicks` (TDD)
**Files:**
- Modify: `src/server/routers/lunch.ts`
- Modify: `tests/unit/lunch-router.test.ts`
- [ ] **Step 1: Failing tests**:
```ts
describe('lunch.getEventForMember', () => {
it('returns event details when enabled', async () => {
const program = await createTestProgram()
const member = await createTestUser('APPLICANT')
await prisma.lunchEvent.create({
data: {
programId: program.id, enabled: true,
eventAt: new Date('2026-06-28T12:30:00Z'), venue: 'Hôtel',
},
})
const caller = createCaller(lunchRouter, member)
const result = await caller.getEventForMember({ programId: program.id })
expect(result?.venue).toBe('Hôtel')
})
it('returns null when event is disabled', async () => {
const program = await createTestProgram()
const member = await createTestUser('APPLICANT')
await prisma.lunchEvent.create({
data: { programId: program.id, enabled: false },
})
const caller = createCaller(lunchRouter, member)
const result = await caller.getEventForMember({ programId: program.id })
expect(result).toBeNull()
})
})
describe('lunch.getTeamPicks', () => {
it('returns picks for all attending members of the caller team', async () => {
// caller is a TeamMember of project P, P has two AttendingMembers with picks
const program = await createTestProgram()
const lead = await createTestUser('APPLICANT')
const m1 = await createTestUser('APPLICANT')
const m2 = await createTestUser('APPLICANT')
const project = await prisma.project.create({
data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
})
await prisma.teamMember.createMany({
data: [
{ projectId: project.id, userId: lead.id, role: 'LEAD' },
{ projectId: project.id, userId: m1.id, role: 'MEMBER' },
{ projectId: project.id, userId: m2.id, role: 'MEMBER' },
],
})
const conf = await prisma.finalistConfirmation.create({
data: {
projectId: project.id, category: 'IMPACT', status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}`,
},
})
const am1 = await prisma.attendingMember.create({
data: { confirmationId: conf.id, userId: m1.id },
})
const am2 = await prisma.attendingMember.create({
data: { confirmationId: conf.id, userId: m2.id },
})
const event = await prisma.lunchEvent.create({
data: { programId: program.id, enabled: true },
})
const dish = await prisma.dish.create({
data: { lunchEventId: event.id, name: 'X', dietaryTags: [] },
})
await prisma.memberLunchPick.create({
data: { attendingMemberId: am1.id, dishId: dish.id, pickedAt: new Date() },
})
await prisma.memberLunchPick.create({ data: { attendingMemberId: am2.id } })
const caller = createCaller(lunchRouter, lead)
const picks = await caller.getTeamPicks({ projectId: project.id })
expect(picks).toHaveLength(2)
expect(picks.find(p => p.userId === m1.id)?.hasPicked).toBe(true)
expect(picks.find(p => p.userId === m2.id)?.hasPicked).toBe(false)
})
it('rejects non-team-member callers', async () => {
const program = await createTestProgram()
const stranger = await createTestUser('APPLICANT')
const project = await prisma.project.create({
data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
})
const caller = createCaller(lunchRouter, stranger)
await expect(
caller.getTeamPicks({ projectId: project.id }),
).rejects.toThrow(/FORBIDDEN/)
})
})
```
- [ ] **Step 2: Run, expect failure**.
- [ ] **Step 3: Add procedures**:
```ts
getEventForMember: protectedProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const event = await ctx.prisma.lunchEvent.findUnique({
where: { programId: input.programId },
select: {
id: true, enabled: true, eventAt: true, endAt: true,
venue: true, notes: true, changeCutoffHours: true,
},
})
if (!event || !event.enabled) return null
const changeDeadline = event.eventAt
? new Date(event.eventAt.getTime() - event.changeCutoffHours * 3600_000)
: null
return { ...event, changeDeadline }
}),
getTeamPicks: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const userId = ctx.session.user.id
const role = ctx.session.user.role
const isAdmin = role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN'
if (!isAdmin) {
const tm = await ctx.prisma.teamMember.findFirst({
where: { projectId: input.projectId, userId },
})
if (!tm) throw new TRPCError({ code: 'FORBIDDEN' })
}
const ams = await ctx.prisma.attendingMember.findMany({
where: { confirmation: { projectId: input.projectId } },
include: {
user: { select: { id: true, name: true, email: true } },
lunchPick: { include: { dish: true } },
},
})
return ams.map((am) => ({
attendingMemberId: am.id,
userId: am.user.id,
memberName: am.user.name ?? am.user.email,
dish: am.lunchPick?.dish ?? null,
allergens: am.lunchPick?.allergens ?? [],
allergenOther: am.lunchPick?.allergenOther ?? null,
hasPicked: !!am.lunchPick?.pickedAt,
}))
}),
```
- [ ] **Step 4: Run, expect green**.
- [ ] **Step 5: Commit**
```bash
git add src/server/routers/lunch.ts tests/unit/lunch-router.test.ts
git commit -m "feat: member reads — getEventForMember + getTeamPicks"
```
---
## Task 8: `getManifest` + `exportManifestCsv` (TDD)
**Files:**
- Modify: `src/server/routers/lunch.ts`
- Modify: `tests/unit/lunch-router.test.ts`
- [ ] **Step 1: Failing tests**:
```ts
describe('lunch.getManifest', () => {
it('returns confirmed attending members + externals with merged shape', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const m = await createTestUser('APPLICANT')
const project = await prisma.project.create({
data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
})
const conf = await prisma.finalistConfirmation.create({
data: {
projectId: project.id, category: 'IMPACT', status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}`,
},
})
const am = await prisma.attendingMember.create({
data: { confirmationId: conf.id, userId: m.id },
})
const event = await prisma.lunchEvent.create({
data: { programId: program.id, enabled: true },
})
const dish = await prisma.dish.create({
data: { lunchEventId: event.id, name: 'Risotto', dietaryTags: ['VEGETARIAN'] },
})
await prisma.memberLunchPick.create({
data: { attendingMemberId: am.id, dishId: dish.id, pickedAt: new Date() },
})
await prisma.externalAttendee.create({
data: { lunchEventId: event.id, name: 'External Bob', dishId: dish.id },
})
const caller = createCaller(lunchRouter, admin)
const manifest = await caller.getManifest({ programId: program.id })
expect(manifest.members).toHaveLength(1)
expect(manifest.externals).toHaveLength(1)
expect(manifest.summary.picked).toBe(1)
expect(manifest.summary.missing).toBe(0)
})
it('excludes non-CONFIRMED confirmations', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const u = await createTestUser('APPLICANT')
const project = await prisma.project.create({
data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
})
const conf = await prisma.finalistConfirmation.create({
data: {
projectId: project.id, category: 'IMPACT', status: 'PENDING',
deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}`,
},
})
await prisma.attendingMember.create({
data: { confirmationId: conf.id, userId: u.id },
})
await prisma.lunchEvent.create({ data: { programId: program.id, enabled: true } })
const caller = createCaller(lunchRouter, admin)
const manifest = await caller.getManifest({ programId: program.id })
expect(manifest.members).toHaveLength(0)
})
})
describe('lunch.exportManifestCsv', () => {
it('returns a CSV string with header + one row per attendee', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
const event = await prisma.lunchEvent.create({
data: { programId: program.id, enabled: true },
})
const dish = await prisma.dish.create({
data: { lunchEventId: event.id, name: 'Risotto', dietaryTags: [] },
})
await prisma.externalAttendee.create({
data: { lunchEventId: event.id, name: 'X Y', dishId: dish.id, allergens: ['GLUTEN'] },
})
const caller = createCaller(lunchRouter, admin)
const csv = await caller.exportManifestCsv({ programId: program.id })
expect(csv.split('\n')[0]).toBe('Type,Team,Name,Email,Dish,Allergens,Allergen notes')
expect(csv).toContain('External,,X Y,,Risotto,GLUTEN,')
})
})
```
- [ ] **Step 2: Run, expect failure**.
- [ ] **Step 3: Implement procedures**
In `lunch.ts`:
```ts
getManifest: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const event = await ctx.prisma.lunchEvent.findUnique({
where: { programId: input.programId },
include: { dishes: true },
})
if (!event) return { members: [], externals: [], dishes: [], summary: { picked: 0, missing: 0, total: 0 } }
const ams = await ctx.prisma.attendingMember.findMany({
where: {
confirmation: { project: { programId: input.programId }, status: 'CONFIRMED' },
},
include: {
user: { select: { id: true, name: true, email: true } },
confirmation: { include: { project: { select: { id: true, name: true } } } },
lunchPick: { include: { dish: true } },
},
})
const externals = await ctx.prisma.externalAttendee.findMany({
where: { lunchEventId: event.id },
include: { project: { select: { id: true, name: true } }, dish: true },
})
const members = ams.map((am) => ({
kind: 'MEMBER' as const,
attendingMemberId: am.id,
userId: am.user.id,
name: am.user.name ?? am.user.email,
email: am.user.email,
project: { id: am.confirmation.project.id, name: am.confirmation.project.name },
dish: am.lunchPick?.dish ?? null,
allergens: am.lunchPick?.allergens ?? [],
allergenOther: am.lunchPick?.allergenOther ?? null,
pickedAt: am.lunchPick?.pickedAt ?? null,
}))
const ext = externals.map((e) => ({
kind: 'EXTERNAL' as const,
externalId: e.id,
name: e.name,
email: e.email,
project: e.project,
roleNote: e.roleNote,
dish: e.dish,
allergens: e.allergens,
allergenOther: e.allergenOther,
pickedAt: e.dishId ? e.updatedAt : null,
}))
const total = members.length + ext.length
const picked = members.filter(m => m.dish).length + ext.filter(e => e.dish).length
return {
event,
dishes: event.dishes,
members,
externals: ext,
summary: { total, picked, missing: total - picked },
}
}),
exportManifestCsv: adminProcedure
.input(z.object({ programId: z.string() }))
.query(async ({ ctx, input }) => {
const m = await (lunchRouter as any).createCaller(ctx).getManifest({ programId: input.programId })
const escape = (s: string | null | undefined) => {
const v = s ?? ''
return /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v
}
const lines = [
'Type,Team,Name,Email,Dish,Allergens,Allergen notes',
...m.members.map((row: any) => [
'Member', escape(row.project?.name), escape(row.name), escape(row.email),
escape(row.dish?.name), escape((row.allergens as string[]).join(';')),
escape(row.allergenOther),
].join(',')),
...m.externals.map((row: any) => [
'External', escape(row.project?.name), escape(row.name), escape(row.email),
escape(row.dish?.name), escape((row.allergens as string[]).join(';')),
escape(row.allergenOther),
].join(',')),
]
return lines.join('\n')
}),
```
Note: the `(lunchRouter as any).createCaller(ctx)` cycle is awkward. Cleaner: extract a `buildManifest(prisma, programId)` helper used by both. Refactor in the next step.
- [ ] **Step 4: Refactor — extract `buildManifest`**
Move the body of `getManifest` into `src/server/services/lunch-recap.ts` (this file will grow more in Task 9):
```ts
import type { PrismaClient } from '@prisma/client'
export async function buildManifest(prisma: PrismaClient, programId: string) {
const event = await prisma.lunchEvent.findUnique({
where: { programId },
include: { dishes: true },
})
if (!event) return { event: null, members: [], externals: [], dishes: [], summary: { picked: 0, missing: 0, total: 0 } }
// ...rest of the body unchanged...
}
```
Then `getManifest` becomes:
```ts
getManifest: adminProcedure
.input(z.object({ programId: z.string() }))
.query(({ ctx, input }) => buildManifest(ctx.prisma, input.programId)),
```
And `exportManifestCsv` calls `buildManifest(ctx.prisma, input.programId)` directly.
- [ ] **Step 5: Run, expect green**.
- [ ] **Step 6: Commit**
```bash
git add src/server/routers/lunch.ts src/server/services/lunch-recap.ts tests/unit/lunch-router.test.ts
git commit -m "feat: lunch manifest query + CSV export"
```
---
## Task 9: Recap aggregation + `sendRecap` + preview (TDD)
**Files:**
- Modify: `src/server/services/lunch-recap.ts`
- Modify: `src/server/routers/lunch.ts`
- Create: `tests/unit/lunch-recap.test.ts`
- [ ] **Step 1: Failing tests** at `tests/unit/lunch-recap.test.ts`:
```ts
import { describe, it, expect, afterAll, vi } from 'vitest'
import { prisma } from '../setup'
import { createTestUser, createTestProgram, cleanupTestData, uid, createCaller } from '../helpers'
import * as lunchRouter from '@/server/routers/lunch'
import { buildRecapPayload } from '@/server/services/lunch-recap'
vi.mock('@/lib/email', async () => {
const actual = await vi.importActual<any>('@/lib/email')
return { ...actual, sendLunchRecapEmail: vi.fn(async () => undefined) }
})
afterAll(async () => { await cleanupTestData() })
describe('buildRecapPayload', () => {
it('aggregates dish + dietary + allergen counts', async () => {
const program = await createTestProgram()
const event = await prisma.lunchEvent.create({
data: { programId: program.id, enabled: true },
})
const veg = await prisma.dish.create({
data: { lunchEventId: event.id, name: 'Risotto', dietaryTags: ['VEGETARIAN'] },
})
const fish = await prisma.dish.create({
data: { lunchEventId: event.id, name: 'Sea bass', dietaryTags: ['PESCATARIAN'] },
})
await prisma.externalAttendee.create({
data: { lunchEventId: event.id, name: 'A', dishId: veg.id, allergens: ['GLUTEN'] },
})
await prisma.externalAttendee.create({
data: { lunchEventId: event.id, name: 'B', dishId: fish.id, allergens: ['GLUTEN', 'FISH'] },
})
const payload = await buildRecapPayload(prisma, program.id)
expect(payload.dishCounts['Risotto']).toBe(1)
expect(payload.dishCounts['Sea bass']).toBe(1)
expect(payload.dietaryCounts['VEGETARIAN']).toBe(1)
expect(payload.allergenCounts['GLUTEN']).toBe(2)
})
})
describe('lunch.sendRecap', () => {
it('sends and stamps recapSentAt', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
await prisma.lunchEvent.create({
data: { programId: program.id, enabled: true },
})
const caller = createCaller(lunchRouter, admin)
await caller.sendRecap({ programId: program.id })
const row = await prisma.lunchEvent.findUnique({ where: { programId: program.id } })
expect(row?.recapSentAt).not.toBeNull()
})
it('throws PRECONDITION_FAILED on second send unless forceUpdate', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
await prisma.lunchEvent.create({
data: { programId: program.id, enabled: true, recapSentAt: new Date() },
})
const caller = createCaller(lunchRouter, admin)
await expect(caller.sendRecap({ programId: program.id })).rejects.toThrow(/PRECONDITION_FAILED/)
await expect(caller.sendRecap({ programId: program.id, forceUpdate: true })).resolves.toBeTruthy()
})
it('writes a LUNCH_RECAP_SENT audit row', async () => {
const program = await createTestProgram()
const admin = await createTestUser('SUPER_ADMIN')
await prisma.lunchEvent.create({
data: { programId: program.id, enabled: true },
})
const caller = createCaller(lunchRouter, admin)
await caller.sendRecap({ programId: program.id })
const audit = await prisma.decisionAuditLog.findFirst({
where: { eventType: 'LUNCH_RECAP_SENT' },
orderBy: { createdAt: 'desc' },
})
expect(audit).not.toBeNull()
})
})
```
- [ ] **Step 2: Run, expect failure**.
- [ ] **Step 3: Add `buildRecapPayload`** to `src/server/services/lunch-recap.ts`:
```ts
export async function buildRecapPayload(prisma: PrismaClient, programId: string) {
const m = await buildManifest(prisma, programId)
const dishCounts: Record<string, number> = {}
const dietaryCounts: Record<string, number> = {}
const allergenCounts: Record<string, number> = {}
const allRows: Array<{ dish: { name: string; dietaryTags: string[] } | null; allergens: string[] }> = [
...m.members.map((r: any) => ({ dish: r.dish, allergens: r.allergens })),
...m.externals.map((r: any) => ({ dish: r.dish, allergens: r.allergens })),
]
for (const row of allRows) {
if (row.dish) {
dishCounts[row.dish.name] = (dishCounts[row.dish.name] ?? 0) + 1
for (const tag of row.dish.dietaryTags) {
dietaryCounts[tag] = (dietaryCounts[tag] ?? 0) + 1
}
}
for (const a of row.allergens) {
allergenCounts[a] = (allergenCounts[a] ?? 0) + 1
}
}
return { event: m.event, members: m.members, externals: m.externals, dishCounts, dietaryCounts, allergenCounts, summary: m.summary }
}
```
- [ ] **Step 4: Add procedures**
In `lunch.ts`:
```ts
import { buildManifest, buildRecapPayload } from '@/server/services/lunch-recap'
import { sendLunchRecapEmail } from '@/lib/email'
// ...
getRecapPreview: adminProcedure
.input(z.object({ programId: z.string() }))
.query(({ ctx, input }) => buildRecapPayload(ctx.prisma, input.programId)),
sendRecap: adminProcedure
.input(z.object({ programId: z.string(), forceUpdate: z.boolean().optional() }))
.mutation(async ({ ctx, input }) => {
const event = await ctx.prisma.lunchEvent.findUnique({
where: { programId: input.programId },
})
if (!event) throw new TRPCError({ code: 'NOT_FOUND' })
if (event.recapSentAt && !input.forceUpdate) {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Recap already sent. Pass forceUpdate=true to resend.',
})
}
const payload = await buildRecapPayload(ctx.prisma, input.programId)
const adminUsers = await ctx.prisma.user.findMany({
where: { role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] }, email: { not: null } },
select: { email: true },
})
const recipients = [
...adminUsers.map(u => u.email!).filter(Boolean),
...event.extraRecipients,
]
await sendLunchRecapEmail(recipients, payload)
const updated = await ctx.prisma.lunchEvent.update({
where: { programId: input.programId },
data: { recapSentAt: new Date() },
})
await ctx.prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_RECAP_SENT', entityType: 'LunchEvent',
entityId: event.id, actorId: ctx.session.user.id,
detailsJson: { recipientCount: recipients.length, forceUpdate: !!input.forceUpdate },
},
})
return updated
}),
```
- [ ] **Step 5: Run, expect green** (this depends on Task 10 stubbing `sendLunchRecapEmail`; if Task 10 is not yet done, add a stub function in `src/lib/email.ts`: `export async function sendLunchRecapEmail() {}`).
- [ ] **Step 6: Commit**
```bash
git add src/server/services/lunch-recap.ts src/server/routers/lunch.ts tests/unit/lunch-recap.test.ts src/lib/email.ts
git commit -m "feat: lunch recap aggregation + sendRecap with forceUpdate gate"
```
---
## Task 10: Email templates in `src/lib/email.ts`
**Files:**
- Modify: `src/lib/email.ts`
- [ ] **Step 1: Add the reminder template**
Append at the bottom of `email.ts` (above the closing braces / exports):
```ts
export async function sendLunchReminderEmail(opts: {
to: string
memberName: string
eventAt: Date
venue: string | null
changeDeadline: Date
pickUrl: string
}): Promise<void> {
const fmt = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Europe/Monaco', dateStyle: 'long', timeStyle: 'short',
})
const subject = `Pick your lunch dish — deadline ${fmt.format(opts.changeDeadline)} (Monaco)`
const html = `
<p>Hi ${opts.memberName ?? 'there'},</p>
<p>You haven't picked your lunch dish for the Monaco Ocean Protection Challenge grand finale yet.</p>
<p><strong>Event:</strong> ${fmt.format(opts.eventAt)} (Europe/Monaco)<br/>
${opts.venue ? `<strong>Venue:</strong> ${opts.venue}<br/>` : ''}
<strong>Deadline to pick:</strong> ${fmt.format(opts.changeDeadline)}</p>
<p><a href="${opts.pickUrl}">Open the picker</a></p>
`
const text = `Pick your lunch dish.\nEvent: ${opts.eventAt.toISOString()}\nDeadline: ${opts.changeDeadline.toISOString()}\n${opts.pickUrl}`
await sendEmail({ to: opts.to, subject, text, html })
}
```
- [ ] **Step 2: Add the recap template**
```ts
type LunchRecapPayload = {
event: { eventAt: Date | null; venue: string | null }
members: Array<{ name: string; project?: { name: string } | null; dish: { name: string } | null; allergens: string[]; allergenOther: string | null }>
externals: Array<{ name: string; project?: { name: string } | null; dish: { name: string } | null; allergens: string[]; allergenOther: string | null; roleNote?: string | null }>
dishCounts: Record<string, number>
dietaryCounts: Record<string, number>
allergenCounts: Record<string, number>
summary: { total: number; picked: number; missing: number }
}
export async function sendLunchRecapEmail(
recipients: string[],
payload: LunchRecapPayload,
): Promise<void> {
if (recipients.length === 0) return
const fmt = new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/Monaco', dateStyle: 'long', timeStyle: 'short' })
const subject = `Lunch manifest — ${payload.event.eventAt ? fmt.format(payload.event.eventAt) : 'TBD'}`
const dishLines = Object.entries(payload.dishCounts)
.map(([name, n]) => `<li>${n}× ${name}</li>`).join('')
const allergyLines = Object.entries(payload.allergenCounts)
.map(([name, n]) => `<li>${n}× ${name}</li>`).join('')
const memberRows = payload.members.map((r) => `
<tr>
<td>${r.project?.name ?? ''}</td>
<td>${r.name}</td>
<td>${r.dish?.name ?? '—'}</td>
<td>${[...r.allergens, r.allergenOther].filter(Boolean).join(', ')}</td>
</tr>
`).join('')
const externalRows = payload.externals.map((r) => `
<tr>
<td>External${r.project?.name ? ` (with ${r.project.name})` : ''}</td>
<td>${r.name}${r.roleNote ? `${r.roleNote}` : ''}</td>
<td>${r.dish?.name ?? '—'}</td>
<td>${[...r.allergens, r.allergenOther].filter(Boolean).join(', ')}</td>
</tr>
`).join('')
const html = `
<h2>Lunch manifest</h2>
<p>${payload.summary.picked}/${payload.summary.total} picked${payload.summary.missing ? ` (${payload.summary.missing} missing)` : ''}</p>
<h3>Dishes</h3><ul>${dishLines}</ul>
<h3>Allergens</h3><ul>${allergyLines || '<li>None reported</li>'}</ul>
<table border="1" cellpadding="6" cellspacing="0">
<thead><tr><th>Team</th><th>Name</th><th>Dish</th><th>Allergies</th></tr></thead>
<tbody>${memberRows}${externalRows}</tbody>
</table>
`
const text = `${payload.summary.picked}/${payload.summary.total} picked. See HTML version for details.`
for (const to of recipients) {
await sendEmail({ to, subject, text, html })
}
}
```
- [ ] **Step 3: Typecheck**
```bash
npm run typecheck
```
- [ ] **Step 4: Commit**
```bash
git add src/lib/email.ts
git commit -m "feat: lunch reminder + recap email templates"
```
---
## Task 11: `/api/cron/lunch-reminders` endpoint (TDD)
**Files:**
- Create: `src/app/api/cron/lunch-reminders/route.ts`
- Create: `tests/unit/lunch-cron.test.ts`
- [ ] **Step 1: Failing tests** at `tests/unit/lunch-cron.test.ts`:
```ts
import { describe, it, expect, afterAll, vi, beforeEach } from 'vitest'
import { prisma } from '../setup'
import { createTestUser, createTestProgram, cleanupTestData, uid } from '../helpers'
vi.mock('@/lib/email', async () => {
const actual = await vi.importActual<any>('@/lib/email')
return {
...actual,
sendLunchReminderEmail: vi.fn(async () => undefined),
sendLunchRecapEmail: vi.fn(async () => undefined),
}
})
afterAll(async () => { await cleanupTestData() })
async function callRoute(path: 'lunch-reminders' | 'lunch-recap') {
const mod = await import(`@/app/api/cron/${path}/route`)
const req = new Request(`http://test/${path}`, {
method: 'POST',
headers: { 'x-cron-secret': process.env.CRON_SECRET ?? 'test-secret' },
})
return mod.POST(req)
}
describe('POST /api/cron/lunch-reminders', () => {
beforeEach(() => { vi.clearAllMocks() })
it('skips events outside the reminder window', async () => {
const program = await createTestProgram()
await prisma.lunchEvent.create({
data: {
programId: program.id, enabled: true,
eventAt: new Date(Date.now() + 30 * 86_400_000),
changeCutoffHours: 48, reminderHoursBeforeDeadline: 24,
},
})
const res = await callRoute('lunch-reminders')
expect(res.status).toBe(200)
const { sendLunchReminderEmail } = await import('@/lib/email')
expect(sendLunchReminderEmail).not.toHaveBeenCalled()
})
it('sends reminders for unpicked attendees inside the window', async () => {
const program = await createTestProgram()
const u = await createTestUser('APPLICANT')
const project = await prisma.project.create({
data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
})
const conf = await prisma.finalistConfirmation.create({
data: {
projectId: project.id, category: 'IMPACT', status: 'CONFIRMED',
deadline: new Date(Date.now() + 86_400_000), token: `tok-${uid()}`,
},
})
const am = await prisma.attendingMember.create({
data: { confirmationId: conf.id, userId: u.id },
})
await prisma.memberLunchPick.create({ data: { attendingMemberId: am.id } })
const eventAt = new Date(Date.now() + 25 * 3600_000)
await prisma.lunchEvent.create({
data: {
programId: program.id, enabled: true,
eventAt, changeCutoffHours: 24, reminderHoursBeforeDeadline: 4,
},
})
const res = await callRoute('lunch-reminders')
expect(res.status).toBe(200)
const { sendLunchReminderEmail } = await import('@/lib/email')
expect(sendLunchReminderEmail).toHaveBeenCalledTimes(1)
const row = await prisma.lunchEvent.findUnique({ where: { programId: program.id } })
expect(row?.reminderSentAt).not.toBeNull()
})
it('is idempotent — second invocation does not resend', async () => {
// Same event from prior test: reminderSentAt now non-null.
// Re-running should not send again. Using the same DB state.
const { sendLunchReminderEmail } = await import('@/lib/email')
vi.clearAllMocks()
const res = await callRoute('lunch-reminders')
expect(res.status).toBe(200)
expect(sendLunchReminderEmail).not.toHaveBeenCalled()
})
it('rejects without CRON_SECRET', async () => {
const mod = await import('@/app/api/cron/lunch-reminders/route')
const req = new Request('http://test/lunch-reminders', { method: 'POST' })
const res = await mod.POST(req)
expect(res.status).toBe(401)
})
})
```
- [ ] **Step 2: Run, expect failure**.
- [ ] **Step 3: Implement the endpoint**
`src/app/api/cron/lunch-reminders/route.ts`:
```ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { sendLunchReminderEmail } from '@/lib/email'
export async function POST(req: Request) {
if (req.headers.get('x-cron-secret') !== process.env.CRON_SECRET) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
const now = new Date()
const events = await prisma.lunchEvent.findMany({
where: {
enabled: true,
reminderSentAt: null,
reminderHoursBeforeDeadline: { not: null },
eventAt: { not: null },
},
})
let sent = 0
for (const event of events) {
try {
if (!event.eventAt || event.reminderHoursBeforeDeadline == null) continue
const deadline = new Date(event.eventAt.getTime() - event.changeCutoffHours * 3600_000)
const reminderAt = new Date(deadline.getTime() - event.reminderHoursBeforeDeadline * 3600_000)
if (now < reminderAt || now >= deadline) continue
const ams = await prisma.attendingMember.findMany({
where: {
confirmation: { project: { programId: event.programId }, status: 'CONFIRMED' },
lunchPick: { is: { pickedAt: null } },
},
include: { user: true },
})
for (const am of ams) {
if (!am.user.email) continue
await sendLunchReminderEmail({
to: am.user.email,
memberName: am.user.name ?? am.user.email,
eventAt: event.eventAt,
venue: event.venue,
changeDeadline: deadline,
pickUrl: `${process.env.NEXTAUTH_URL}/applicant`,
})
sent++
}
await prisma.lunchEvent.update({
where: { id: event.id }, data: { reminderSentAt: new Date() },
})
} catch (e) {
console.error('[lunch-reminders] event failed', event.id, e)
}
}
return NextResponse.json({ ok: true, sent })
}
```
- [ ] **Step 4: Run, expect green**.
- [ ] **Step 5: Commit**
```bash
git add src/app/api/cron/lunch-reminders tests/unit/lunch-cron.test.ts
git commit -m "feat: cron endpoint — lunch reminders"
```
---
## Task 12: `/api/cron/lunch-recap` endpoint (TDD)
**Files:**
- Create: `src/app/api/cron/lunch-recap/route.ts`
- Modify: `tests/unit/lunch-cron.test.ts`
- [ ] **Step 1: Failing tests** appended:
```ts
describe('POST /api/cron/lunch-recap', () => {
beforeEach(() => { vi.clearAllMocks() })
it('skips events with cronEnabled=false', async () => {
const program = await createTestProgram()
await prisma.lunchEvent.create({
data: {
programId: program.id, enabled: true, cronEnabled: false,
eventAt: new Date(Date.now() - 86_400_000), // already past
changeCutoffHours: 24,
},
})
const res = await callRoute('lunch-recap')
expect(res.status).toBe(200)
const { sendLunchRecapEmail } = await import('@/lib/email')
expect(sendLunchRecapEmail).not.toHaveBeenCalled()
})
it('skips events with recapSentAt already set', async () => {
const program = await createTestProgram()
await prisma.lunchEvent.create({
data: {
programId: program.id, enabled: true, cronEnabled: true,
eventAt: new Date(Date.now() - 86_400_000),
changeCutoffHours: 24, recapSentAt: new Date(),
},
})
const res = await callRoute('lunch-recap')
expect(res.status).toBe(200)
const { sendLunchRecapEmail } = await import('@/lib/email')
expect(sendLunchRecapEmail).not.toHaveBeenCalled()
})
it('sends recap once and stamps recapSentAt', async () => {
const program = await createTestProgram()
await createTestUser('SUPER_ADMIN') // recipient
await prisma.lunchEvent.create({
data: {
programId: program.id, enabled: true, cronEnabled: true,
eventAt: new Date(Date.now() - 86_400_000),
changeCutoffHours: 24,
},
})
const res = await callRoute('lunch-recap')
expect(res.status).toBe(200)
const { sendLunchRecapEmail } = await import('@/lib/email')
expect(sendLunchRecapEmail).toHaveBeenCalledTimes(1)
const row = await prisma.lunchEvent.findUnique({ where: { programId: program.id } })
expect(row?.recapSentAt).not.toBeNull()
})
})
```
- [ ] **Step 2: Run, expect failure**.
- [ ] **Step 3: Implement the endpoint**
`src/app/api/cron/lunch-recap/route.ts`:
```ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { sendLunchRecapEmail } from '@/lib/email'
import { buildRecapPayload } from '@/server/services/lunch-recap'
export async function POST(req: Request) {
if (req.headers.get('x-cron-secret') !== process.env.CRON_SECRET) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
const now = new Date()
const events = await prisma.lunchEvent.findMany({
where: {
enabled: true, cronEnabled: true, recapSentAt: null, eventAt: { not: null },
},
})
let sent = 0
for (const event of events) {
try {
if (!event.eventAt) continue
const deadline = new Date(event.eventAt.getTime() - event.changeCutoffHours * 3600_000)
if (now < deadline) continue
const payload = await buildRecapPayload(prisma, event.programId)
const adminUsers = await prisma.user.findMany({
where: { role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] }, email: { not: null } },
select: { email: true },
})
const recipients = [
...adminUsers.map(u => u.email!).filter(Boolean),
...event.extraRecipients,
]
await sendLunchRecapEmail(recipients, payload)
await prisma.lunchEvent.update({
where: { id: event.id }, data: { recapSentAt: new Date() },
})
await prisma.decisionAuditLog.create({
data: {
eventType: 'LUNCH_RECAP_SENT', entityType: 'LunchEvent',
entityId: event.id, actorId: null,
detailsJson: { recipientCount: recipients.length, source: 'cron' },
},
})
sent++
} catch (e) {
console.error('[lunch-recap] event failed', event.id, e)
}
}
return NextResponse.json({ ok: true, sent })
}
```
- [ ] **Step 4: Run, expect green**.
- [ ] **Step 5: Commit**
```bash
git add src/app/api/cron/lunch-recap tests/unit/lunch-cron.test.ts
git commit -m "feat: cron endpoint — lunch recap"
```
---
## Task 13: Lunch tab scaffold + un-disable trigger
**Files:**
- Create: `src/components/admin/logistics/lunch-tab.tsx`
- Modify: `src/app/(admin)/admin/logistics/page.tsx`
- [ ] **Step 1: Create the empty tab component**
`src/components/admin/logistics/lunch-tab.tsx`:
```tsx
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
export function LunchTab({ programId }: { programId: string }) {
const { data: event, isLoading } = trpc.lunch.getEvent.useQuery({ programId })
if (isLoading || !event) {
return <Skeleton className="h-48 w-full" />
}
if (!event.enabled) {
return (
<Card>
<CardHeader>
<CardTitle>Lunch is disabled</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">
Toggle Lunch on from the Event configuration card to begin setup.
</p>
{/* Event config card mounts in Task 14, replacing this stub. */}
</CardContent>
</Card>
)
}
return (
<div className="space-y-6">
{/* Cards mount in Tasks 14-18. */}
<p className="text-muted-foreground text-sm">Lunch tab cards land in upcoming tasks.</p>
</div>
)
}
```
- [ ] **Step 2: Mount it in the logistics page**
In `src/app/(admin)/admin/logistics/page.tsx`:
- Remove `disabled` from the Lunch `<TabsTrigger>` (line ~55-58).
- Drop the "(soon)" span.
- Add a new `<TabsContent value="lunch"><LunchTab programId={programId} /></TabsContent>` block.
- Import `LunchTab`.
```tsx
import { LunchTab } from '@/components/admin/logistics/lunch-tab'
// ...
<TabsTrigger value="lunch">
<Salad className="mr-2 h-4 w-4" /> Lunch
</TabsTrigger>
// ...
<TabsContent value="lunch">
<LunchTab programId={programId} />
</TabsContent>
```
- [ ] **Step 3: Live smoke**`npm run dev`, open `/admin/logistics`, click Lunch. Expected: empty-state card.
- [ ] **Step 4: Commit**
```bash
git add src/components/admin/logistics/lunch-tab.tsx src/app/\(admin\)/admin/logistics/page.tsx
git commit -m "feat: lunch tab scaffold + un-disable trigger"
```
---
## Task 14: Event configuration card
**Files:**
- Create: `src/components/admin/logistics/lunch-event-config.tsx`
- Modify: `src/components/admin/logistics/lunch-tab.tsx`
- [ ] **Step 1: Build the config card**
`src/components/admin/logistics/lunch-event-config.tsx` — uses the same blur-to-commit pattern as `edition-settings-tab.tsx:20-67`. Surface fields:
- `enabled` (Switch — master)
- `eventAt`, `endAt` (datetime-local inputs)
- `venue` (text)
- `notes` (textarea)
- `changeCutoffHours`, `reminderHoursBeforeDeadline`, `cronEnabled`
- `extraRecipients[]` — chip-input (small custom component using Input + Badge; press Enter to add, click to remove)
```tsx
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import {
Card, CardContent, CardDescription, CardHeader, CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { X } from 'lucide-react'
import { toast } from 'sonner'
export function LunchEventConfig({ programId, event }: {
programId: string
event: NonNullable<ReturnType<typeof trpc.lunch.getEvent.useQuery>['data']>
}) {
const utils = trpc.useUtils()
const update = trpc.lunch.updateEvent.useMutation({
onSuccess: () => utils.lunch.getEvent.invalidate({ programId }),
onError: (e) => toast.error(e.message),
})
// Local state for chip input
const [extraInput, setExtraInput] = useState('')
return (
<Card>
<CardHeader>
<CardTitle>Event configuration</CardTitle>
<CardDescription>Per-edition lunch event settings.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* enabled */}
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
<Label htmlFor="lunch-enabled">Enable lunch event</Label>
<Switch
id="lunch-enabled"
checked={event.enabled}
onCheckedChange={(v) => update.mutate({ programId, enabled: v })}
disabled={update.isPending}
/>
</div>
{/* eventAt */}
<div className="space-y-1.5">
<Label htmlFor="event-at">Event start</Label>
<Input
id="event-at" type="datetime-local"
defaultValue={event.eventAt ? event.eventAt.toISOString().slice(0, 16) : ''}
onBlur={(e) => {
const v = e.target.value
update.mutate({
programId,
eventAt: v ? new Date(v) : null,
})
}}
disabled={update.isPending}
/>
</div>
{/* endAt */}
<div className="space-y-1.5">
<Label htmlFor="end-at">Event end</Label>
<Input
id="end-at" type="datetime-local"
defaultValue={event.endAt ? event.endAt.toISOString().slice(0, 16) : ''}
onBlur={(e) => {
const v = e.target.value
update.mutate({ programId, endAt: v ? new Date(v) : null })
}}
disabled={update.isPending}
/>
</div>
{/* venue */}
<div className="space-y-1.5">
<Label htmlFor="venue">Venue</Label>
<Input
id="venue"
defaultValue={event.venue ?? ''}
onBlur={(e) => update.mutate({ programId, venue: e.target.value || null })}
disabled={update.isPending}
/>
</div>
{/* notes */}
<div className="space-y-1.5">
<Label htmlFor="notes">Notes for attendees (optional)</Label>
<Textarea
id="notes"
defaultValue={event.notes ?? ''}
onBlur={(e) => update.mutate({ programId, notes: e.target.value || null })}
disabled={update.isPending}
/>
</div>
{/* changeCutoffHours */}
<div className="space-y-1.5">
<Label htmlFor="cutoff">Change cutoff (hours before event)</Label>
<Input
id="cutoff" type="number" min={0} max={720}
defaultValue={event.changeCutoffHours}
onBlur={(e) => {
const n = Number(e.target.value)
if (Number.isFinite(n) && n !== event.changeCutoffHours) {
update.mutate({ programId, changeCutoffHours: n })
}
}}
disabled={update.isPending}
className="max-w-[12rem]"
/>
</div>
{/* reminderHoursBeforeDeadline */}
<div className="space-y-1.5">
<Label htmlFor="reminder">Reminder (hours before deadline; blank = off)</Label>
<Input
id="reminder" type="number" min={0} max={720}
defaultValue={event.reminderHoursBeforeDeadline ?? ''}
onBlur={(e) => {
const v = e.target.value
update.mutate({
programId,
reminderHoursBeforeDeadline: v === '' ? null : Number(v),
})
}}
disabled={update.isPending}
className="max-w-[12rem]"
/>
</div>
{/* cronEnabled */}
<div className="flex items-center justify-between gap-4 rounded-md border p-4">
<Label htmlFor="cron-enabled">Auto-send recap at deadline</Label>
<Switch
id="cron-enabled"
checked={event.cronEnabled}
onCheckedChange={(v) => update.mutate({ programId, cronEnabled: v })}
disabled={update.isPending}
/>
</div>
{/* extraRecipients chip input */}
<div className="space-y-1.5">
<Label>Extra recap recipients</Label>
<div className="flex flex-wrap gap-2">
{event.extraRecipients.map((email) => (
<Badge key={email} variant="secondary" className="gap-1">
{email}
<button
className="ml-1"
onClick={() =>
update.mutate({
programId,
extraRecipients: event.extraRecipients.filter(e => e !== email),
})
}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
<Input
placeholder="email@example.com — press Enter to add"
value={extraInput}
onChange={(e) => setExtraInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && extraInput.trim()) {
update.mutate({
programId,
extraRecipients: [...event.extraRecipients, extraInput.trim()],
})
setExtraInput('')
}
}}
/>
</div>
</CardContent>
</Card>
)
}
```
- [ ] **Step 2: Mount it in `lunch-tab.tsx`** — replace the empty-state and the placeholder paragraph with `<LunchEventConfig programId={programId} event={event} />` (always render — the config is needed even when `enabled=false` to flip it on).
- [ ] **Step 3: Live smoke** — open Lunch tab, toggle enabled, set a date, set venue, set extra recipients, refresh — values persist.
- [ ] **Step 4: Commit**
```bash
git add src/components/admin/logistics/lunch-event-config.tsx src/components/admin/logistics/lunch-tab.tsx
git commit -m "feat: lunch event configuration card"
```
---
## Task 15: Dishes card
**Files:**
- Create: `src/components/admin/logistics/lunch-dishes.tsx`
- Modify: `src/components/admin/logistics/lunch-tab.tsx`
- [ ] **Step 1: Build the card**
`src/components/admin/logistics/lunch-dishes.tsx`:
```tsx
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
const DIETARY_TAGS = ['VEGETARIAN', 'VEGAN', 'GLUTEN_FREE', 'PESCATARIAN'] as const
type DietaryTag = (typeof DIETARY_TAGS)[number]
export function LunchDishes({ lunchEventId }: { lunchEventId: string }) {
const utils = trpc.useUtils()
const { data: dishes } = trpc.lunch.listDishes.useQuery({ lunchEventId })
const create = trpc.lunch.createDish.useMutation({
onSuccess: () => utils.lunch.listDishes.invalidate({ lunchEventId }),
onError: (e) => toast.error(e.message),
})
const update = trpc.lunch.updateDish.useMutation({
onSuccess: () => utils.lunch.listDishes.invalidate({ lunchEventId }),
})
const del = trpc.lunch.deleteDish.useMutation({
onSuccess: () => utils.lunch.listDishes.invalidate({ lunchEventId }),
})
const [newName, setNewName] = useState('')
const [newTags, setNewTags] = useState<DietaryTag[]>([])
return (
<Card>
<CardHeader>
<CardTitle>Dishes</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{dishes?.length === 0 && (
<p className="text-muted-foreground text-sm">Add at least one dish to open picks.</p>
)}
<ul className="space-y-2">
{dishes?.map((d) => (
<li key={d.id} className="flex items-center gap-3 rounded-md border p-3">
<span className="font-medium">{d.name}</span>
<div className="flex gap-1">
{d.dietaryTags.map((t) => (
<Badge key={t} variant="outline">{t.replace('_', ' ').toLowerCase()}</Badge>
))}
</div>
<div className="ml-auto flex gap-2">
<Button size="sm" variant="ghost"
onClick={() => {
const name = prompt('Edit name', d.name)
if (name && name !== d.name) update.mutate({ dishId: d.id, name })
}}
><Pencil className="h-4 w-4" /></Button>
<Button size="sm" variant="ghost"
onClick={() => {
if (confirm(`Delete "${d.name}"? Existing picks will go back to "not picked".`)) {
del.mutate({ dishId: d.id })
}
}}
><Trash2 className="h-4 w-4" /></Button>
</div>
</li>
))}
</ul>
<div className="flex gap-2 border-t pt-4">
<Input placeholder="New dish name" value={newName}
onChange={(e) => setNewName(e.target.value)} />
<div className="flex gap-1">
{DIETARY_TAGS.map((t) => (
<Button key={t} size="sm"
variant={newTags.includes(t) ? 'default' : 'outline'}
onClick={() => setNewTags(
newTags.includes(t) ? newTags.filter(x => x !== t) : [...newTags, t]
)}
>{t.replace('_', ' ').toLowerCase()}</Button>
))}
</div>
<Button onClick={() => {
if (!newName.trim()) return
create.mutate(
{ lunchEventId, name: newName.trim(), dietaryTags: newTags, sortOrder: dishes?.length ?? 0 },
{ onSuccess: () => { setNewName(''); setNewTags([]) } },
)
}}><Plus className="mr-1 h-4 w-4" /> Add</Button>
</div>
</CardContent>
</Card>
)
}
```
- [ ] **Step 2: Mount in `lunch-tab.tsx`** under the config card.
- [ ] **Step 3: Live smoke** — add 3 dishes with mixed dietary tags, edit one's name, delete one. Refresh.
- [ ] **Step 4: Commit**
```bash
git add src/components/admin/logistics/lunch-dishes.tsx src/components/admin/logistics/lunch-tab.tsx
git commit -m "feat: lunch dishes card with create/edit/delete"
```
---
## Task 16: Manifest card
**Files:**
- Create: `src/components/admin/logistics/lunch-manifest.tsx`
- Modify: `src/components/admin/logistics/lunch-tab.tsx`
- [ ] **Step 1: Build the card**
`src/components/admin/logistics/lunch-manifest.tsx` — table backed by `trpc.lunch.getManifest.useQuery({ programId })`. Columns: Team / Attendee / Type / Dish / Allergens / Picked at. Filters above the table:
- `<Input>` to filter by team name (client-side substring match)
- `<Switch>` "Missing picks only" — filters to rows where `dish == null`
Header summary chip uses `manifest.summary` plus aggregated dietary/allergen counts (compute client-side).
Edit button on each row opens a slide-over with `<LunchPickForm>` (defined in Task 20) for members, or the externals dialog for externals (defined in Task 17). Re-use both.
```tsx
'use client'
import { useState, useMemo } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Pencil, Download } from 'lucide-react'
export function LunchManifest({ programId }: { programId: string }) {
const { data } = trpc.lunch.getManifest.useQuery({ programId })
const [search, setSearch] = useState('')
const [missingOnly, setMissingOnly] = useState(false)
const rows = useMemo(() => {
if (!data) return []
const all = [
...data.members.map((m: any) => ({ ...m, sortKey: `0-${m.project?.name ?? ''}-${m.name}` })),
...data.externals.map((e: any) => ({ ...e, sortKey: `1-${e.project?.name ?? ''}-${e.name}` })),
]
return all
.filter(r => !search || (r.project?.name ?? '').toLowerCase().includes(search.toLowerCase()) || r.name.toLowerCase().includes(search.toLowerCase()))
.filter(r => !missingOnly || !r.dish)
.sort((a, b) => a.sortKey.localeCompare(b.sortKey))
}, [data, search, missingOnly])
if (!data) return null
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
Manifest
<Badge variant="outline">
{data.summary.picked}/{data.summary.total} picked · {data.summary.missing} missing
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<Input placeholder="Filter by team or name" value={search}
onChange={(e) => setSearch(e.target.value)} className="max-w-xs" />
<div className="flex items-center gap-2">
<Switch id="missing-only" checked={missingOnly} onCheckedChange={setMissingOnly} />
<Label htmlFor="missing-only">Missing picks only</Label>
</div>
<DownloadCsvButton programId={programId} />
</div>
<table className="w-full text-sm">
<thead className="text-muted-foreground border-b text-left">
<tr>
<th className="py-2">Team</th>
<th>Attendee</th>
<th>Type</th>
<th>Dish</th>
<th>Allergens</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((r: any) => (
<tr key={r.attendingMemberId ?? r.externalId} className="border-b">
<td className="py-2">{r.project?.name ?? '—'}</td>
<td>{r.name}</td>
<td>{r.kind === 'MEMBER' ? 'Member' : 'External'}</td>
<td>{r.dish?.name ?? <span className="text-muted-foreground">not picked</span>}</td>
<td>{[...r.allergens, r.allergenOther].filter(Boolean).join(', ')}</td>
<td>
<Button size="sm" variant="ghost"><Pencil className="h-4 w-4" /></Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
)
}
```
Define `DownloadCsvButton` near the top of the same file (above `LunchManifest`):
```tsx
function DownloadCsvButton({ programId }: { programId: string }) {
const utils = trpc.useUtils()
return (
<Button
variant="outline"
size="sm"
className="ml-auto"
onClick={async () => {
const csv = await utils.client.lunch.exportManifestCsv.query({ programId })
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'lunch-manifest.csv'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}}
>
<Download className="mr-1 h-4 w-4" /> Download CSV
</Button>
)
}
```
The edit-pencil wiring (open slide-over, mount the appropriate form, re-invalidate the manifest on save) lands in Tasks 17 + 20 — leave the button stubbed for now.
- [ ] **Step 2: Mount in `lunch-tab.tsx`**.
- [ ] **Step 3: Live smoke** — confirm rows render, filters work, CSV downloads.
- [ ] **Step 4: Commit**
```bash
git add src/components/admin/logistics/lunch-manifest.tsx src/components/admin/logistics/lunch-tab.tsx
git commit -m "feat: lunch manifest card with filters + CSV export"
```
---
## Task 17: Externals card with add/edit dialog
**Files:**
- Create: `src/components/admin/logistics/lunch-externals.tsx`
- Modify: `src/components/admin/logistics/lunch-tab.tsx`
- Modify: `src/components/admin/logistics/lunch-manifest.tsx` — wire the externals edit pencil into this dialog
- [ ] **Step 1: Build the card + dialog**
`src/components/admin/logistics/lunch-externals.tsx`:
```tsx
'use client'
import { useState, useImperativeHandle, forwardRef } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog'
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
const ALLERGENS = [
'GLUTEN', 'CRUSTACEANS', 'EGGS', 'FISH', 'PEANUTS', 'SOYBEANS', 'MILK',
'TREE_NUTS', 'CELERY', 'MUSTARD', 'SESAME', 'SULPHITES', 'LUPIN', 'MOLLUSCS',
] as const
type Allergen = (typeof ALLERGENS)[number]
type Editing =
| { mode: 'new' }
| { mode: 'edit'; id: string }
| null
export type LunchExternalsHandle = { openEditDialog: (id: string) => void }
export const LunchExternals = forwardRef<LunchExternalsHandle, {
programId: string
lunchEventId: string
}>(function LunchExternals({ programId, lunchEventId }, ref) {
const utils = trpc.useUtils()
const { data: externals } = trpc.lunch.listExternals.useQuery({ lunchEventId })
const { data: dishes } = trpc.lunch.listDishes.useQuery({ lunchEventId })
const { data: projects } = trpc.program.listFinalistProjects.useQuery({ programId })
// ^ Add this small helper procedure on `program` router if it doesn't exist:
// listFinalistProjects: adminProcedure
// .input(z.object({ programId: z.string() }))
// .query(({ ctx, input }) => ctx.prisma.project.findMany({
// where: { programId: input.programId, finalistConfirmation: { status: 'CONFIRMED' } },
// select: { id: true, name: true },
// orderBy: { name: 'asc' },
// })),
const [editing, setEditing] = useState<Editing>(null)
useImperativeHandle(ref, () => ({
openEditDialog: (id: string) => setEditing({ mode: 'edit', id }),
}), [])
const create = trpc.lunch.createExternal.useMutation({
onSuccess: () => {
utils.lunch.listExternals.invalidate({ lunchEventId })
utils.lunch.getManifest.invalidate({ programId })
},
onError: (e) => toast.error(e.message),
})
const update = trpc.lunch.updateExternal.useMutation({
onSuccess: () => {
utils.lunch.listExternals.invalidate({ lunchEventId })
utils.lunch.getManifest.invalidate({ programId })
},
})
const del = trpc.lunch.deleteExternal.useMutation({
onSuccess: () => {
utils.lunch.listExternals.invalidate({ lunchEventId })
utils.lunch.getManifest.invalidate({ programId })
},
})
const editingRow = editing?.mode === 'edit'
? externals?.find(e => e.id === editing.id) ?? null
: null
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
External attendees
<Button size="sm" onClick={() => setEditing({ mode: 'new' })}>
<Plus className="mr-1 h-4 w-4" /> Add external
</Button>
</CardTitle>
</CardHeader>
<CardContent>
{externals?.length === 0 && (
<p className="text-muted-foreground text-sm">No external attendees yet.</p>
)}
<table className="w-full text-sm">
<tbody>
{externals?.map((e) => (
<tr key={e.id} className="border-b">
<td className="py-2">{e.name}</td>
<td>{e.project?.name ?? '—'}</td>
<td>{e.roleNote ?? ''}</td>
<td>
<Button size="sm" variant="ghost" onClick={() => setEditing({ mode: 'edit', id: e.id })}>
<Pencil className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" onClick={() => {
if (confirm(`Delete external attendee "${e.name}"?`)) del.mutate({ externalId: e.id })
}}>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
{editing && (
<ExternalDialog
mode={editing.mode}
initial={editingRow}
dishes={dishes ?? []}
projects={projects ?? []}
onClose={() => setEditing(null)}
onSubmit={(values) => {
if (editing.mode === 'new') {
create.mutate({ lunchEventId, ...values }, { onSuccess: () => setEditing(null) })
} else {
update.mutate({ externalId: editing.id, ...values }, { onSuccess: () => setEditing(null) })
}
}}
/>
)}
</Card>
)
})
function ExternalDialog({
mode, initial, dishes, projects, onClose, onSubmit,
}: {
mode: 'new' | 'edit'
initial: { name: string; email: string | null; projectId: string | null; roleNote: string | null; dishId: string | null; allergens: string[]; allergenOther: string | null } | null
dishes: Array<{ id: string; name: string }>
projects: Array<{ id: string; name: string }>
onClose: () => void
onSubmit: (values: {
name: string; email?: string; projectId?: string | null; roleNote?: string;
dishId?: string | null; allergens: Allergen[]; allergenOther?: string | null;
}) => void
}) {
const [name, setName] = useState(initial?.name ?? '')
const [email, setEmail] = useState(initial?.email ?? '')
const [projectId, setProjectId] = useState(initial?.projectId ?? '')
const [roleNote, setRoleNote] = useState(initial?.roleNote ?? '')
const [dishId, setDishId] = useState(initial?.dishId ?? '')
const [allergens, setAllergens] = useState<Allergen[]>((initial?.allergens as Allergen[]) ?? [])
const [allergenOther, setAllergenOther] = useState(initial?.allergenOther ?? '')
return (
<Dialog open onOpenChange={(o) => { if (!o) onClose() }}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{mode === 'new' ? 'Add external attendee' : 'Edit external attendee'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div><Label>Name *</Label><Input value={name} onChange={(e) => setName(e.target.value)} /></div>
<div><Label>Email</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
<div>
<Label>Project (optional)</Label>
<Select value={projectId} onValueChange={setProjectId}>
<SelectTrigger><SelectValue placeholder="Standalone" /></SelectTrigger>
<SelectContent>
<SelectItem value="">Standalone</SelectItem>
{projects.map(p => <SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div><Label>Role / note</Label><Input value={roleNote} onChange={(e) => setRoleNote(e.target.value)} /></div>
<div>
<Label>Dish</Label>
<Select value={dishId} onValueChange={setDishId}>
<SelectTrigger><SelectValue placeholder="Not picked" /></SelectTrigger>
<SelectContent>
<SelectItem value="">Not picked</SelectItem>
{dishes.map(d => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>Allergens</Label>
<div className="grid grid-cols-2 gap-2">
{ALLERGENS.map((a) => (
<label key={a} className="flex items-center gap-2 text-sm">
<Checkbox checked={allergens.includes(a)}
onCheckedChange={(v) => setAllergens(
v ? [...allergens, a] : allergens.filter(x => x !== a),
)}
/>
{a.replace('_', ' ').toLowerCase()}
</label>
))}
</div>
</div>
<div><Label>Other allergens / notes</Label><Textarea value={allergenOther} onChange={(e) => setAllergenOther(e.target.value)} /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button
disabled={!name.trim()}
onClick={() => onSubmit({
name: name.trim(),
email: email.trim() || undefined,
projectId: projectId || null,
roleNote: roleNote.trim() || undefined,
dishId: dishId || null,
allergens,
allergenOther: allergenOther.trim() || null,
})}
>Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
```
- [ ] **Step 2: Add the supporting `program.listFinalistProjects` procedure**
If it doesn't already exist, add to `src/server/routers/program.ts`:
```ts
listFinalistProjects: adminProcedure
.input(z.object({ programId: z.string() }))
.query(({ ctx, input }) =>
ctx.prisma.project.findMany({
where: { programId: input.programId, finalistConfirmation: { status: 'CONFIRMED' } },
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
),
```
- [ ] **Step 3: Wire externals edit pencil from the manifest**
Update `<LunchTab>` to keep a `useRef<LunchExternalsHandle>(null)` and pass an `onEditExternal={(id) => externalsRef.current?.openEditDialog(id)}` prop down to `<LunchManifest>`. In `<LunchManifest>`, change the externals row's edit button:
```tsx
<Button size="sm" variant="ghost" onClick={() => onEditExternal?.(r.externalId)}>
<Pencil className="h-4 w-4" />
</Button>
```
(Member rows' edit pencil opens the slide-over from Task 20 instead — leave it stubbed for now and replace in that task.)
- [ ] **Step 4: Mount in `lunch-tab.tsx`**
```tsx
const externalsRef = useRef<LunchExternalsHandle>(null)
// ...
<LunchManifest programId={programId} onEditExternal={(id) => externalsRef.current?.openEditDialog(id)} />
<LunchExternals ref={externalsRef} programId={programId} lunchEventId={event.id} />
```
- [ ] **Step 5: Live smoke** — add a standalone external + a project-attached one. Edit. Delete. Confirm both show in the manifest. Click the manifest's externals edit-pencil — externals dialog opens.
- [ ] **Step 6: Commit**
```bash
git add src/components/admin/logistics/lunch-externals.tsx \
src/components/admin/logistics/lunch-tab.tsx \
src/components/admin/logistics/lunch-manifest.tsx
git commit -m "feat: external lunch attendees card + dialog"
```
---
## Task 18: Recap actions card
**Files:**
- Create: `src/components/admin/logistics/lunch-recap-actions.tsx`
- Modify: `src/components/admin/logistics/lunch-tab.tsx`
- [ ] **Step 1: Build the card**
Two buttons + a footer line:
- **Preview recap** — opens a Dialog rendering the recap payload (counts + table).
- **Send recap now** — calls `trpc.lunch.sendRecap.useMutation()`. On `PRECONDITION_FAILED`, surfaces a confirm dialog ("You've already sent a recap. Send updated version to all recipients?"); on confirm, retries with `forceUpdate: true`.
- **Download CSV** — same handler as the manifest card (extract a small util if duplicated).
- Footer: "Last sent: <timestamp> · Recipients: N admins + M extra" — read from `event.recapSentAt` + `event.extraRecipients.length` and a count of admin users from `trpc.user.countAdmins` (add this small read if it doesn't exist; otherwise hardcode "all admins" text without a count to avoid a new endpoint).
```tsx
'use client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Send, Eye, Download } from 'lucide-react'
import { toast } from 'sonner'
export function LunchRecapActions({ programId, event }: {
programId: string
event: { recapSentAt: Date | null; extraRecipients: string[] }
}) {
const utils = trpc.useUtils()
const [previewOpen, setPreviewOpen] = useState(false)
const send = trpc.lunch.sendRecap.useMutation({
onSuccess: () => {
utils.lunch.getEvent.invalidate({ programId })
toast.success('Recap sent')
},
onError: async (e) => {
if (e.data?.code === 'PRECONDITION_FAILED') {
if (confirm("You've already sent a recap. Send updated version to all recipients?")) {
send.mutate({ programId, forceUpdate: true })
}
} else {
toast.error(e.message)
}
},
})
const { data: preview } = trpc.lunch.getRecapPreview.useQuery(
{ programId },
{ enabled: previewOpen },
)
return (
<Card>
<CardHeader>
<CardTitle>Recap</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setPreviewOpen(true)}>
<Eye className="mr-2 h-4 w-4" /> Preview recap
</Button>
<Button onClick={() => send.mutate({ programId })} disabled={send.isPending}>
<Send className="mr-2 h-4 w-4" /> Send recap now
</Button>
</div>
<p className="text-muted-foreground text-xs">
{event.recapSentAt
? `Last sent: ${event.recapSentAt.toLocaleString()}. Recipients: edition admins${event.extraRecipients.length ? ` + ${event.extraRecipients.length} extra` : ''}.`
: 'Recap has not been sent yet.'}
</p>
</CardContent>
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader><DialogTitle>Recap preview</DialogTitle></DialogHeader>
{preview && (
<div className="space-y-3 text-sm">
<p>{preview.summary.picked}/{preview.summary.total} picked.</p>
<h4 className="font-medium">Dishes</h4>
<ul>{Object.entries(preview.dishCounts).map(([n, c]) => <li key={n}>{c}× {n}</li>)}</ul>
<h4 className="font-medium">Allergens</h4>
<ul>{Object.entries(preview.allergenCounts).map(([n, c]) => <li key={n}>{c}× {n}</li>)}</ul>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setPreviewOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}
```
- [ ] **Step 2: Mount in `lunch-tab.tsx`** as the last card.
- [ ] **Step 3: Live smoke** — preview shows aggregates, "Send" sends; second click prompts the resend confirm.
- [ ] **Step 4: Commit**
```bash
git add src/components/admin/logistics/lunch-recap-actions.tsx src/components/admin/logistics/lunch-tab.tsx
git commit -m "feat: recap actions card with preview + send + resend confirm"
```
---
## Task 19: Lunch banner on applicant dashboard
**Files:**
- Create: `src/components/applicant/lunch-banner.tsx`
- Modify: `src/app/(applicant)/applicant/page.tsx`
- [ ] **Step 1: Build the banner**
`src/components/applicant/lunch-banner.tsx` — uses `trpc.lunch.getEventForMember.useQuery({ programId })`. Returns null when event is null (disabled or not configured).
Layout: a single-line strip showing event date/time (Intl-formatted in user locale + "Europe/Monaco" zone), venue, and a small countdown to `changeDeadline`. A details-disclosure for `notes` if non-empty.
```tsx
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent } from '@/components/ui/card'
import { Calendar, MapPin } from 'lucide-react'
export function LunchBanner({ programId }: { programId: string }) {
const { data: event } = trpc.lunch.getEventForMember.useQuery({ programId })
if (!event) return null
const fmt = new Intl.DateTimeFormat(undefined, {
timeZone: 'Europe/Monaco', dateStyle: 'long', timeStyle: 'short',
})
return (
<Card>
<CardContent className="flex flex-wrap items-center gap-4 py-3 text-sm">
<div className="flex items-center gap-1.5">
<Calendar className="h-4 w-4" />
{event.eventAt ? fmt.format(new Date(event.eventAt)) : 'Date TBD'}
</div>
{event.venue && (
<div className="flex items-center gap-1.5">
<MapPin className="h-4 w-4" /> {event.venue}
</div>
)}
{event.changeDeadline && (
<div className="text-muted-foreground ml-auto">
Picks close: {fmt.format(new Date(event.changeDeadline))}
</div>
)}
{event.notes && (
<details className="basis-full">
<summary className="text-muted-foreground cursor-pointer text-xs">Notes from organizers</summary>
<p className="text-sm">{event.notes}</p>
</details>
)}
</CardContent>
</Card>
)
}
```
- [ ] **Step 2: Mount above `<AttendingMembersCard>`**
In `src/app/(applicant)/applicant/page.tsx`, add an import and place `<LunchBanner programId={projectProgramId} />` above the existing `<AttendingMembersCard>`. The exact `programId` source already used for that card (likely `project.programId`) reuses the same prop.
- [ ] **Step 3: Live smoke** — disable lunch → banner hidden; enable + set date → banner shows.
- [ ] **Step 4: Commit**
```bash
git add src/components/applicant/lunch-banner.tsx src/app/\(applicant\)/applicant/page.tsx
git commit -m "feat: lunch banner on applicant dashboard"
```
---
## Task 20: Lunch picker on `AttendingMembersCard`
**Files:**
- Create: `src/components/applicant/lunch-pick-form.tsx`
- Modify: `src/components/applicant/attending-members-card.tsx`
- [ ] **Step 1: Build the picker form**
`src/components/applicant/lunch-pick-form.tsx` — receives `attendingMemberId`, `lunchEventId`, current pick, current user role/identity, and the `enabled` flag. Calls `trpc.lunch.upsertPick.useMutation()` on changes. Disables editing when:
- The viewer is not self / team-lead / admin (already enforced server-side, but we also disable inputs to avoid silly UX), OR
- The deadline has passed (UI shows the read-only message).
Renders a dropdown of dishes (grouped by `dietaryTags`) + the EU-14 allergen checklist + an "Other" textarea + a chip showing "Picked at [time]" once `pickedAt` is set.
Keep this component small (≤ 200 lines). Use `useState` for local draft state, commit on dropdown change / checkbox toggle / textarea blur.
The viewer-vs-editor logic: this component receives `canEdit: boolean` from its parent. The parent (the card row) computes it from session role + the row's `userId`.
- [ ] **Step 2: Embed in `attending-members-card.tsx`**
Find the existing per-row layout. Below the visa + flight subsections, append a `<LunchPickForm>` that:
- Reads `event` from a `trpc.lunch.getEventForMember.useQuery({ programId })` call hoisted to the parent.
- Conditionally renders only when `event != null`.
- Computes `canEdit` per row: `(role === SUPER_ADMIN || PROGRAM_ADMIN) || row.userId === sessionUser.id || isTeamLead(sessionUser, project)`.
`isTeamLead` derives from the existing project context that `AttendingMembersCard` already receives (it knows the project's TeamMembers). If it doesn't, add it now via the existing project read used by the dashboard.
- [ ] **Step 3: Wire the manifest edit pencil (Task 16 stub)**
Now that `<LunchPickForm>` exists, return to `lunch-manifest.tsx` and replace the stubbed edit-pencil button with a slide-over (`<Sheet>`) that mounts `<LunchPickForm>` in admin mode (`canEdit={true}`, no deadline gating since admin-only). On save, invalidate `trpc.lunch.getManifest`.
- [ ] **Step 4: Live smoke**
- Open the dashboard as a member → can pick own row only.
- Open as team lead → can pick any row.
- Past deadline → read-only state with "contact admin" note.
- Open `/admin/logistics` → Lunch → click pencil on any row → slide-over opens, edit works, manifest updates.
- [ ] **Step 5: Commit**
```bash
git add src/components/applicant/lunch-pick-form.tsx \
src/components/applicant/attending-members-card.tsx \
src/components/admin/logistics/lunch-manifest.tsx
git commit -m "feat: lunch picker on attending-members card + admin slide-over"
```
---
## Task 21: External attendees read-only strip on project page
**Files:**
- Create: `src/components/applicant/external-attendees-strip.tsx`
- Modify: the project detail page (locate via `find src/app -path '*applicant*projects*page.tsx'`; also check `src/app/(applicant)/applicant/project` — verify the exact path before editing)
- [ ] **Step 1: Add the `getProjectExternals` procedure (TDD)**
Add a failing test in `tests/unit/lunch-router.test.ts`:
```ts
describe('lunch.getProjectExternals', () => {
it('returns project-attached externals to a team member', async () => {
const program = await createTestProgram()
const lead = await createTestUser('APPLICANT')
const project = await prisma.project.create({
data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
})
await prisma.teamMember.create({
data: { projectId: project.id, userId: lead.id, role: 'LEAD' },
})
const event = await prisma.lunchEvent.create({ data: { programId: program.id } })
await prisma.externalAttendee.create({
data: { lunchEventId: event.id, projectId: project.id, name: 'Sponsor X' },
})
const caller = createCaller(lunchRouter, lead)
const result = await caller.getProjectExternals({ projectId: project.id })
expect(result).toHaveLength(1)
expect(result[0].name).toBe('Sponsor X')
})
it('rejects callers who are not on the team', async () => {
const program = await createTestProgram()
const stranger = await createTestUser('APPLICANT')
const project = await prisma.project.create({
data: { programId: program.id, name: `p-${uid()}`, category: 'IMPACT' },
})
const caller = createCaller(lunchRouter, stranger)
await expect(
caller.getProjectExternals({ projectId: project.id }),
).rejects.toThrow(/FORBIDDEN/)
})
})
```
Run, expect failure. Then add to `src/server/routers/lunch.ts`:
```ts
getProjectExternals: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const userId = ctx.session.user.id
const role = ctx.session.user.role
const isAdmin = role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN'
if (!isAdmin) {
const tm = await ctx.prisma.teamMember.findFirst({
where: { projectId: input.projectId, userId },
})
if (!tm) throw new TRPCError({ code: 'FORBIDDEN' })
}
return ctx.prisma.externalAttendee.findMany({
where: { projectId: input.projectId },
include: { dish: true },
orderBy: { createdAt: 'asc' },
})
}),
```
Run tests, expect green.
- [ ] **Step 2: Locate the project detail page**
```bash
find src/app -type f -name 'page.tsx' | xargs grep -l "TeamMember\|project.findUnique" 2>/dev/null
```
Pick the page that team members use to view their project details. Confirm by checking it renders team-members / flight / visa.
- [ ] **Step 3: Build the strip**
```tsx
'use client'
import { trpc } from '@/lib/trpc/client'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
export function ExternalAttendeesStrip({ projectId }: { projectId: string }) {
const { data } = trpc.lunch.getProjectExternals.useQuery({ projectId })
if (!data || data.length === 0) return null
return (
<Card>
<CardContent className="flex flex-wrap items-center gap-2 py-3">
<span className="text-sm font-medium">External attendees joining your team:</span>
{data.map((e) => (
<Badge key={e.id} variant="outline">
{e.name}{e.roleNote ? ` (${e.roleNote})` : ''}
</Badge>
))}
</CardContent>
</Card>
)
}
```
- [ ] **Step 4: Mount on the project detail page** above (or below) the team-members section.
- [ ] **Step 5: Live smoke** — add a project-attached external as admin, switch to team-lead account, confirm strip shows.
- [ ] **Step 6: Commit**
```bash
git add src/components/applicant/external-attendees-strip.tsx \
src/server/routers/lunch.ts \
tests/unit/lunch-router.test.ts \
<project-detail-page-path>
git commit -m "feat: read-only external attendees strip on project page"
```
---
## Task 22: Drop Lunch line from edition-settings "Coming soon" card
**Files:**
- Modify: `src/components/admin/settings/edition-settings-tab.tsx`
- [ ] **Step 1: Update the import line**
In `src/components/admin/settings/edition-settings-tab.tsx`, replace:
```tsx
import { Loader2, Salad, ScrollText, Stamp, Users } from 'lucide-react'
```
with:
```tsx
import { Loader2, ScrollText, Stamp, Users } from 'lucide-react'
```
- [ ] **Step 2: Update the Coming-soon card**
Replace the existing block (currently around `:203-221`):
```tsx
{/* Coming soon */}
<Card className="border-dashed">
<CardHeader>
<CardTitle className="text-muted-foreground text-base">Coming soon</CardTitle>
<CardDescription>
Lunch-event configuration and editable email templates land in upcoming
updates and will surface here.
</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground space-y-2 text-sm">
<div className="flex items-center gap-2">
<Salad className="h-4 w-4" /> Lunch event dishes, allergies, RSVP deadline
</div>
<div className="flex items-center gap-2">
<ScrollText className="h-4 w-4" /> Email templates editable subject + body
for confirmation, decline-cascade, mentor onboarding, etc.
</div>
</CardContent>
</Card>
```
with:
```tsx
{/* Coming soon */}
<Card className="border-dashed">
<CardHeader>
<CardTitle className="text-muted-foreground text-base">Coming soon</CardTitle>
<CardDescription>
Editable email templates land in an upcoming update and will surface here.
</CardDescription>
</CardHeader>
<CardContent className="text-muted-foreground space-y-2 text-sm">
<div className="flex items-center gap-2">
<ScrollText className="h-4 w-4" /> Email templates editable subject + body
for confirmation, decline-cascade, mentor onboarding, etc.
</div>
</CardContent>
</Card>
```
- [ ] **Step 3: Live smoke** — open `/admin/settings` → Edition. Coming-soon card should mention only email templates.
- [ ] **Step 4: Commit**
```bash
git add src/components/admin/settings/edition-settings-tab.tsx
git commit -m "chore: drop lunch placeholder from edition settings coming-soon card"
```
---
## Task 23: Final verification
- [ ] **Step 1: Full test suite**
```bash
npx vitest run
```
Expected: all green. Roughly +30 tests over baseline (lunch-router, lunch-upsert-pick, lunch-recap, lunch-cron, lunch-pick-sync).
- [ ] **Step 2: Typecheck**
```bash
npm run typecheck
```
Expected: clean.
- [ ] **Step 3: Production build**
```bash
npm run build
```
Expected: clean.
- [ ] **Step 4: Lint**
```bash
npm run lint
```
Expected: clean.
- [ ] **Step 5: End-to-end smoke (browser)**
1. As `SUPER_ADMIN`: open `/admin/logistics` → Lunch tab.
2. Toggle enabled. Set `eventAt`, venue. Save.
3. Add 3 dishes with mixed dietary tags.
4. Add a standalone external + a project-attached external.
5. As an attending member of a CONFIRMED project: open `/applicant`. Verify lunch banner. Pick a dish, log allergens.
6. As the team lead: edit a teammate's pick.
7. Past deadline (set `changeCutoffHours` to 0 + `eventAt` in the past briefly): member sees read-only state.
8. Back as admin: hit "Send recap now" → success toast. Click again → confirm dialog → resend.
9. Hit cron endpoints with `curl -X POST -H "x-cron-secret: $CRON_SECRET" $URL/api/cron/lunch-reminders` and `/api/cron/lunch-recap` — confirm idempotency.
10. Download CSV — confirm file opens cleanly in a spreadsheet.
- [ ] **Step 6: Final commit (if any cleanup)**
```bash
git status # confirm tree is clean
```
---
## Self-review checklist (for the engineer executing this plan)
1. **Spec coverage:** every section of `docs/superpowers/specs/2026-04-29-pr6-lunch-event-design.md` maps to a task above.
2. **Permission matrix** (spec §2) — implemented in Task 6 (`upsertPick`) and Task 20 (UI gating).
3. **Cutoff enforcement**`upsertPick` server-side guard (Task 6) + UI read-only state (Task 20).
4. **Dish delete preserves picks** — Task 4 test asserts this; Prisma `SetNull` enforces.
5. **External attendees can be standalone or project-attached** — Task 5 + Task 17 + Task 21.
6. **Recap "send updated?"** — Task 9 server-side `forceUpdate` flag + Task 18 UI confirm dialog.
7. **Cron idempotency** — Tasks 11 + 12 tests assert.
8. **Audit log entries** — every mutation procedure writes a `DecisionAuditLog` row with the spec's exact `eventType` strings.
9. **No keyboard shortcuts** introduced.
10. **No new public token-gated pages** — picker stays inside authenticated dashboard.