Files
MOPC-Portal/docs/superpowers/specs/2026-04-29-pr6-lunch-event-design.md
Matt 1a58b3db1a docs: design spec for PR 6 — lunch event
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>
2026-04-29 02:06:28 +02:00

19 KiB
Raw Permalink Blame History

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 per AttendingMember), 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 AttendingMember on 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:

  1. Event config card — enabled toggle (master switch), eventAt + endAt date pickers, venue, notes, changeCutoffHours, reminderHoursBeforeDeadline, cronEnabled, extraRecipients[] (chip-input for emails). Same blur-to-commit pattern used by the Edition settings tab.
  2. 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."
  3. 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).
  4. Externals card — table of external attendees with add button → dialog (name, email, project (optional), roleNote, dishId, allergens, allergenOther). Edits use the same dialog.
  5. 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 25 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 pickedAt is 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 disabled from the Lunch tab trigger in logistics/page.tsx:55-58 and 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_UPDATED
  • LUNCH_DISH_CREATED / LUNCH_DISH_UPDATED / LUNCH_DISH_DELETED
  • LUNCH_PICK_UPDATED (records actor role: SELF / TEAM_LEAD / ADMIN)
  • LUNCH_EXTERNAL_CREATED / LUNCH_EXTERNAL_UPDATED / LUNCH_EXTERNAL_DELETED
  • LUNCH_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

  • getEvent lazily creates the row on first call.
  • updateEvent patches an arbitrary subset.
  • Dish CRUD (createDish, updateDish, deleteDish, reorderDishes) — delete sets dishId=null on existing picks.
  • External CRUD covers the standalone (projectId=null) and team-attached cases.
  • getManifest filters out non-CONFIRMED confirmations 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

  • sendRecap with recapSentAt=null succeeds and stamps the timestamp.
  • sendRecap with recapSentAt set and forceUpdate=false throws PRECONDITION_FAILED.
  • sendRecap with forceUpdate=true succeeds and re-stamps.
  • Recap aggregation is correct (dish counts, dietary-tag counts, allergen counts).

tests/lunch/cron.test.ts

  • lunch-reminders is idempotent (second call within window does not double-send).
  • lunch-reminders skips events with reminderSentAt already set.
  • lunch-recap skips events with cronEnabled=false.
  • lunch-recap skips events with recapSentAt already 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 AttendingMember while a LunchEvent exists also creates an empty MemberLunchPick.
  • Creating an AttendingMember while no LunchEvent exists 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 — mount lunch router.
  • src/server/services/lunch-pick-sync.ts (new) — ensureLunchPickForAttendingMember helper called from existing attendee-creation paths.
  • src/server/services/lunch-recap.ts (new) — manifest aggregation + email body builder, used by sendRecap and 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.