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>
This commit is contained in:
348
docs/superpowers/specs/2026-04-29-pr6-lunch-event-design.md
Normal file
348
docs/superpowers/specs/2026-04-29-pr6-lunch-event-design.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# 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 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 `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.
|
||||
Reference in New Issue
Block a user