Locked-in design covering data model (LunchEvent, Dish, MemberLunchPick, ExternalAttendee + DietaryTag/Allergen enums), tRPC API surface, admin/team-lead/member UI on Logistics → Lunch tab and applicant dashboard, reminder + recap email/cron flows, edge cases, and testing strategy. Ready for implementation plan. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
19 KiB
PR 6 — Lunch event (design)
Date: 2026-04-29 Status: design locked, ready for implementation plan
1. Goal & scope
Replace the Lunch tab placeholder on /admin/logistics with a working flow: admins configure a single per-edition lunch event, attendees pick a dish + log allergies, and the manifest is reviewable in-app, exportable to CSV, and emailed at the change deadline.
In scope:
- New models:
LunchEvent(1:1 per program),Dish(per event),MemberLunchPick(1:1 perAttendingMember),ExternalAttendee(per program, optionally team-attached). - Enums:
DietaryTag,Allergen. - Admin UI on the existing Logistics → Lunch tab: event config card, dishes CRUD, manifest table, externals CRUD, recap preview/send, audit logging.
- Team-lead UX: dish/allergy editing for any
AttendingMemberon their project, on the existing applicant dashboard. - Member self-serve UX: dish/allergy editing for own
AttendingMember, on the same dashboard. - Single reminder email (configurable hours before deadline).
- Recap email (manual button + cron auto-send, both toggleable; admin recipients + free-form extras).
- Removal of the Lunch placeholders from Edition settings and from the disabled Logistics tab trigger.
Out of scope:
- No caterer-facing email integration. Admins forward the recap manually.
- No multi-event per edition (1:1 with
Program). - No public token-gated picker. Members must have an account; team leads or admins fill in for non-active members.
- Editable email templates (lands in PR 7).
2. Permission matrix
| Editor | Can edit |
|---|---|
| Member (logged in) | Their own dish + allergies, until deadline |
| Team lead | Any AttendingMember on their project, until deadline |
| Admin | Everything — all AttendingMember picks + all ExternalAttendee records, no deadline cap |
External (off-platform) attendees are admin-only to manage; team leads see them on the project page read-only when an external is attached to their team.
"Team lead" throughout this spec means a user with a TeamMember row on the project where TeamMember.role === 'LEAD' (existing enum value, defined at schema.prisma:273-277).
"Admins of the edition" (used by recap recipients and audit-log actor scoping) means all users with role === 'SUPER_ADMIN' plus all users with role === 'PROGRAM_ADMIN'. There is no per-program admin scoping today, so all program admins receive the recap.
3. Data model
enum DietaryTag {
VEGETARIAN
VEGAN
GLUTEN_FREE
PESCATARIAN
}
enum Allergen {
GLUTEN // cereals containing gluten
CRUSTACEANS
EGGS
FISH
PEANUTS
SOYBEANS
MILK
TREE_NUTS
CELERY
MUSTARD
SESAME
SULPHITES
LUPIN
MOLLUSCS
}
model LunchEvent {
id String @id @default(cuid())
programId String @unique // 1:1 — one lunch per edition
enabled Boolean @default(false)
eventAt DateTime? // nullable until admin sets it
endAt DateTime?
venue String?
notes String? @db.Text
changeCutoffHours Int @default(48)
reminderHoursBeforeDeadline Int? // null = no reminder
cronEnabled Boolean @default(true) // auto-recap at deadline
extraRecipients String[] @default([]) // off-platform recap recipients
reminderSentAt DateTime? // cron idempotency
recapSentAt DateTime? // gates "send updated recap?" prompt
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 // 1:1, mirrors FlightDetail/VisaApplication
dishId String? // null = not picked yet
allergens Allergen[] @default([])
allergenOther String? // "other" free-text
pickedAt DateTime? // null until first pick made
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? // optional — null = standalone (jury/dignitary/etc.)
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])
}
Back-references on existing models:
model Program {
// ...existing fields...
lunchEvent LunchEvent?
}
model AttendingMember {
// ...existing fields...
lunchPick MemberLunchPick?
}
model Project {
// ...existing fields...
externalLunchAttendees ExternalAttendee[]
}
Auto-create hook. When an AttendingMember is created, if a LunchEvent exists for the parent program, also create an empty MemberLunchPick (dishId=null, pickedAt=null). When the AttendingMember is deleted, the cascade handles the pick. Mirrors the visa-application auto-sync added in commit bdfd998.
Migrations are additive. Nothing existing changes shape. pickedAt is set on the first upsertPick call where dishId is non-null; subsequent edits update updatedAt only.
4. API surface
New router src/server/routers/lunch.ts, mounted as trpc.lunch.*. Logistics router unchanged.
Admin-only (adminProcedure)
| Procedure | Purpose |
|---|---|
getEvent |
Get-or-create the LunchEvent for the current program (lazy create, mirrors hotel's pattern). |
updateEvent |
Patch any subset of: enabled, eventAt, endAt, venue, notes, changeCutoffHours, reminderHoursBeforeDeadline, cronEnabled, extraRecipients[]. |
createDish / updateDish / deleteDish / reorderDishes |
Dish CRUD. Delete sets dishId=null on picks via Prisma SetNull. |
listExternals / createExternal / updateExternal / deleteExternal |
External-attendee CRUD. |
getManifest |
Full manifest: attending members (filtered to FinalistConfirmation.status === CONFIRMED) + externals, each with project, name, dish, allergens. Backs the Lunch tab table and CSV export. |
exportManifestCsv |
Server-side CSV generation; returns string for client-side download. |
getRecapPreview |
Returns the recap email payload (counts + table) for in-app preview. |
sendRecap |
Manual send. Input { forceUpdate?: boolean }. If recapSentAt is set and forceUpdate=false, throws PRECONDITION_FAILED so the UI can show the "send updated?" confirm. Sends to admins of the edition + extraRecipients[]. Updates recapSentAt. Audit-logged. |
Mixed permission (protectedProcedure with role guard inside)
| Procedure | Purpose |
|---|---|
upsertPick |
Single procedure for member-self / team-lead / admin. Input: { attendingMemberId, dishId, allergens, allergenOther }. Guard: caller is (a) the AttendingMember.userId, OR (b) team lead of the parent project, OR (c) admin. After changeCutoffHours cutoff, only admins pass. Audit-logged on every write with actor role. |
Member read (protectedProcedure)
| Procedure | Purpose |
|---|---|
getEventForMember |
Public-ish event view: { enabled, eventAt, endAt, venue, notes, changeDeadline } for the dashboard banner. Returns null when enabled=false. |
getTeamPicks |
All picks for the caller's team (resolved via TeamMember → project). Returns [{ attendingMemberId, memberName, dish, allergens, hasPicked }] for the team-wide-read visibility. |
Cron endpoints (REST, CRON_SECRET guarded)
| Endpoint | Behavior |
|---|---|
POST /api/cron/lunch-reminders |
Single fire per event: scans enabled LunchEvents with reminderHoursBeforeDeadline set and reminderSentAt null. If now ∈ [reminderAt, deadline), emails attending members with pickedAt=null whose parent FinalistConfirmation.status === CONFIRMED, then stamps reminderSentAt. Idempotent. |
POST /api/cron/lunch-recap |
Single fire per event: scans enabled LunchEvents with cronEnabled=true, recapSentAt null, and now >= deadline. Sends recap to admins + extraRecipients[], stamps recapSentAt. Idempotent. |
Both endpoints follow the CLAUDE.md rule "Round notifications never throw — all errors caught and logged" with per-event try/catch so one failure does not poison the sweep.
5. UI
Admin: /admin/logistics → Lunch tab
Stack of cards on the existing tab content area:
- Event config card — enabled toggle (master switch),
eventAt+endAtdate pickers,venue,notes,changeCutoffHours,reminderHoursBeforeDeadline,cronEnabled,extraRecipients[](chip-input for emails). Same blur-to-commit pattern used by the Edition settings tab. - Dishes card — list of dishes (name, dietary-tag pills, drag handle for
sortOrder), inline add row, edit/delete buttons. Empty state: "Add at least one dish to open picks." - Manifest card — table:
Team | Attendee | Type (member/external) | Dish | Allergens | Picked at. Filters: team dropdown, "Missing picks only" toggle. Header summary chip: "23/30 picked · 3 vegan · 2 nut-allergic · 1 missing". Externals appear inline (team column shows "—" for standalone). Edit-pencil opens a slide-over on any row (admin override). - Externals card — table of external attendees with add button → dialog (name, email, project (optional),
roleNote,dishId,allergens,allergenOther). Edits use the same dialog. - Recap actions card — two buttons: "Preview recap" (modal showing email body) and "Send recap now" (with the post-deadline "you already sent — resend updated?" confirm); plus "Download CSV". Footer text: "Last sent: 2026-06-25 14:02. Recipients: 4 admins + 2 extra."
When enabled=false, cards 2–5 collapse to a single empty state: "Lunch is disabled — toggle on to configure."
Applicant dashboard (/applicant) — extend AttendingMembersCard
Each attending-member row (already shows visa + flight) gets a new collapsible Lunch subsection:
- Dish dropdown (grouped by dietary tag — "Vegetarian options", "All options").
- Allergen checklist (EU 14 inline grid) + "other" textarea.
- "Picked" chip with timestamp once
pickedAtis set.
Edit affordance:
- Member viewing own row: editable until deadline.
- Team lead viewing teammates' rows: editable until deadline, with a clear "Editing on behalf of [Name]" label.
- Past deadline: read-only, with note "Past change deadline. Contact an admin for changes."
Above AttendingMembersCard, a thin lunch banner (only when enabled=true) shows event date/time, venue, change-deadline countdown, and a "Notes from organizers" expander.
Project page
Read-only External attendees for your team strip — only when externals with projectId === thisProject exist, so the team knows who's joining them. No edits — admin-only.
Removals
- Drop the Lunch line from the "Coming soon" card on
edition-settings-tab.tsx:212-216. - Remove
disabledfrom the Lunch tab trigger inlogistics/page.tsx:55-58and wire it to a new<LunchTab>component.
6. Email + cron details
Email templates live inline in src/lib/email.ts (the existing single-file pattern); no new infrastructure.
Reminder. Subject: "Pick your lunch dish — deadline in [Xh]". Body: greeting, event date/venue/notes, deadline timestamp, button → applicant dashboard. Sent only to attending members with pickedAt=null whose confirmation is CONFIRMED.
Recap. Subject: "Lunch manifest — [event date]". Body: aggregate counts (dishes, dietary tags, allergens), per-attendee table grouped by team (externals as a final group), event metadata. Plain HTML; no CSV attachment in v1 — admins use the in-app "Download CSV" button when needed.
Time formatting. Same approach as the confirmation page: format with Intl.DateTimeFormat in the recipient's email-client locale, plus a hardcoded "Europe/Monaco" zone label and the ISO timestamp for unambiguous parsing.
Audit log entries (new eventType string literals on the existing DecisionAuditLog.eventType field — no schema change since the column is free-form):
LUNCH_EVENT_UPDATEDLUNCH_DISH_CREATED/LUNCH_DISH_UPDATED/LUNCH_DISH_DELETEDLUNCH_PICK_UPDATED(records actor role:SELF/TEAM_LEAD/ADMIN)LUNCH_EXTERNAL_CREATED/LUNCH_EXTERNAL_UPDATED/LUNCH_EXTERNAL_DELETEDLUNCH_RECAP_SENT(with recipient count)
7. Edge cases & error handling
| Case | Behavior |
|---|---|
LunchEvent does not yet exist for the program |
getEvent lazily creates it with defaults; member/team-lead reads return null (banner hidden). |
| Admin disables lunch after picks made | Picks remain in the database; UI hides them; recap still works if re-enabled. |
FinalistConfirmation flips from CONFIRMED to SUPERSEDED after a pick was made |
Pick row stays; manifest filters the team out. If the team is later re-promoted via waitlist, picks are visible again. |
| Dish is deleted | dishId becomes null on picks/externals; rows show as "not picked"; admins re-pick. Audit logged. |
eventAt is moved |
Deadline (eventAt - changeCutoffHours) and reminder window recalculate automatically — no manual adjustment needed. |
eventAt is set in the past |
Admin sees a UI warning; cron treats deadline as already-past (so a manual send is the only recourse, since recapSentAt may already be moot). |
changeCutoffHours = 0 |
Deadline equals eventAt. Allowed. |
Admin edits a pick after recapSentAt is set |
UI surfaces a confirm dialog: "This will not auto-resend the recap. Send updated recap?" ─ "Yes" calls sendRecap with forceUpdate=true. Audit logged regardless. |
Member with no AttendingMember row |
Cannot edit. UI hides the lunch subsection (no row exists). |
External with projectId that points to a project no longer in the edition |
onDelete: SetNull on the relation already covers cascades; standalone-display fallback. |
8. Testing strategy
Vitest, sequential pool (per CLAUDE.md). Tests grouped by router/service:
tests/lunch/lunch-router.test.ts
getEventlazily creates the row on first call.updateEventpatches an arbitrary subset.- Dish CRUD (
createDish,updateDish,deleteDish,reorderDishes) — delete setsdishId=nullon existing picks. - External CRUD covers the standalone (
projectId=null) and team-attached cases. getManifestfilters out non-CONFIRMEDconfirmations and merges externals.
tests/lunch/upsert-pick.test.ts
- Member edits own row: succeeds before deadline, fails after.
- Team lead edits teammate row: succeeds before deadline, fails after.
- Team lead edits a non-team member's row: fails with
FORBIDDEN. - Admin edits any row before/after deadline: succeeds in both cases.
- Audit log records actor role correctly per case.
tests/lunch/recap.test.ts
sendRecapwithrecapSentAt=nullsucceeds and stamps the timestamp.sendRecapwithrecapSentAtset andforceUpdate=falsethrowsPRECONDITION_FAILED.sendRecapwithforceUpdate=truesucceeds and re-stamps.- Recap aggregation is correct (dish counts, dietary-tag counts, allergen counts).
tests/lunch/cron.test.ts
lunch-remindersis idempotent (second call within window does not double-send).lunch-remindersskips events withreminderSentAtalready set.lunch-recapskips events withcronEnabled=false.lunch-recapskips events withrecapSentAtalready set.- Per-event try/catch — a failing send for one event does not stop the next from being processed.
tests/lunch/auto-create.test.ts
- Creating an
AttendingMemberwhile aLunchEventexists also creates an emptyMemberLunchPick. - Creating an
AttendingMemberwhile noLunchEventexists does not error and does not create a pick.
Build (npm run build), typecheck (npm run typecheck), and full test suite must be green before commit.
9. File-level work surface (informative — drives the implementation plan)
prisma/schema.prisma— add models, enums, back-references; new migration.src/server/routers/lunch.ts(new) — router as designed.src/server/routers/_app.ts— mountlunchrouter.src/server/services/lunch-pick-sync.ts(new) —ensureLunchPickForAttendingMemberhelper called from existing attendee-creation paths.src/server/services/lunch-recap.ts(new) — manifest aggregation + email body builder, used bysendRecapand the recap cron.src/lib/email.ts— append two new template functions (reminder + recap).src/app/api/cron/lunch-reminders/route.ts(new).src/app/api/cron/lunch-recap/route.ts(new).src/app/(admin)/admin/logistics/page.tsx— un-disable the Lunch tab trigger; mount new tab content.src/components/admin/logistics/lunch-tab.tsx(new) — orchestrates the five cards.src/components/admin/logistics/lunch-event-config.tsx(new) — config card.src/components/admin/logistics/lunch-dishes.tsx(new) — dishes card.src/components/admin/logistics/lunch-manifest.tsx(new) — manifest card.src/components/admin/logistics/lunch-externals.tsx(new) — externals card.src/components/admin/logistics/lunch-recap-actions.tsx(new) — recap actions card.src/components/applicant/attending-members-card.tsx— extend each row with the lunch subsection.src/components/applicant/lunch-banner.tsx(new) — the dashboard banner above the attending-members card.src/components/admin/settings/edition-settings-tab.tsx— drop the Lunch line from the "Coming soon" card.
10. Non-goals reminder
- No keyboard shortcuts anywhere — visible UI affordances only (per existing user feedback memory).
- No editable email templates in this PR (PR 7).
- No public token-gated picker.
- No multi-event support.
- No caterer email integration.