Files
MOPC-Portal/docs/superpowers/plans/2026-04-29-pr6-lunch-event.md

3477 lines
122 KiB
Markdown
Raw Normal View History

# PR 6: Lunch Event Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the placeholder Lunch tab on `/admin/logistics` with a working flow: admins configure a single per-edition lunch event (date, venue, dishes, change deadline, recipients), attendees pick a dish + log allergies, and the manifest is reviewable in-app, exportable to CSV, and emailed to admins at the change deadline.
**Architecture:** Four new Prisma models (`LunchEvent` 1:1 with `Program`, `Dish` per event, `MemberLunchPick` 1:1 with `AttendingMember`, `ExternalAttendee` per event with optional `projectId`) plus two enums (`DietaryTag`, `Allergen`). One new tRPC router (`lunch`) carrying admin CRUD, a mixed-permission `upsertPick` procedure (member-self / team-lead / admin), and member reads for the dashboard banner + team-wide visibility. Two cron endpoints (reminders + recap) reuse the `/api/cron/*` pattern. Email templates land inline in `src/lib/email.ts`. The five-card admin UI sits on the existing Logistics → Lunch tab; the member picker extends `AttendingMembersCard` on the applicant dashboard.
**Tech Stack:** Prisma 6 + PostgreSQL (additive migration), tRPC 11 with Zod, Vitest 4 sequential pool, NextAuth 5 RBAC via the existing procedure middleware, shadcn/ui for cards/tables/dialogs, nodemailer via the existing `sendEmail` helper.
**Spec:** `docs/superpowers/specs/2026-04-29-pr6-lunch-event-design.md`
---
## File map
**Create:**
- `src/server/routers/lunch.ts` — router with all admin + member procedures
- `src/server/services/lunch-pick-sync.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.