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

349 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# PR 6 — Lunch event (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
```prisma
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:**
```prisma
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 `LunchEvent`s 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 `LunchEvent`s 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.