Files
MOPC-Portal/docs/superpowers/specs/2026-06-05-external-attendee-dish-selection-design.md
Matt 8d4f0bac1e
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m51s
feat(logistics): external attendees self-select lunch dish via tokenized page
External lunch attendees had no way to pick their own dish — an admin had to set
it inline and no email was ever sent. (Marine added herself as an external
expecting a dish-selection link and never received one.)

Adds:
- ExternalAttendee.inviteSentAt + additive migration
- HMAC-signed external lunch token (mirrors finalist-token)
- Public no-login picker page /lunch/pick/[token] — dish + allergens + notes,
  gated by the lunch change deadline, read-only after
- tRPC getExternalByToken / setExternalPick (public) + sendExternalInvite (admin)
- Auto-send invite on createExternal when an email is present; per-row resend
  button + status chip (Invited / Picked / no email) in the logistics screen
- Unpicked externals chased by the lunch reminder cron + manual "Send reminders"
- sendExternalDishInviteEmail (branded). Page + email title use the configurable
  venue ("Lunch at {venue}") rather than "grand finale"

Tests: token roundtrip/tamper/expiry, selectUnpickedExternals filter,
get/set-by-token happy + deadline + bad-token, createExternal auto-send,
cron external reminders. Full suite 303 passing; build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:04:13 +02:00

200 lines
8.7 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.
# External Attendee Dish Self-Selection — Design Spec
**Date:** 2026-06-05
**Status:** Approved (design), pending implementation plan
**Author:** Matt + Claude
## Problem
External lunch attendees (e.g. partners, VIPs added by an admin in the logistics
screen) currently have **no way to choose their own dish**. The admin is expected
to set each external's `dishId` inline. There is no email and no self-service page.
This surfaced when an admin (Marine Jacq-Pietri, `marine@monaco-impact.org`) added
herself as an external attendee expecting to receive an email to pick a dish, and
never got one — because the flow does not exist. Verified in prod 2026-06-05:
the `ExternalAttendee` row exists with `dishId = null`, and no email path targets
externals.
### Current behaviour (verified in code + prod)
- `lunch.createExternal` / `lunch.updateExternal` write the row and send **no email**.
- The only "Pick your lunch dish" email (`sendLunchReminderEmail`) is driven by
`selectUnpickedAttendees`, which queries **`AttendingMember` rows tied to a
CONFIRMED `FinalistConfirmation`** — finalist team members only. Externals are
never in that set.
- `sendLunchRecapEmail` goes to admins + `extraRecipients` only (a manifest, not a picker).
- Externals' dishes are meant to be set by the admin inline via `dishId`.
## Goal
External attendees with an email on file receive a dish-selection email containing
a tokenized link to a dedicated, no-login page where they choose a dish, declare
allergens, and add allergen notes — mirroring the finalist team-member picker.
## Design decisions (locked)
1. **Email trigger:** auto-send on add (when the external has an email) **plus** a
per-row "Resend invite" button in the logistics screen.
2. **Reminders:** unpicked externals are included in both the reminder cron and the
manual "Send reminders" action.
3. **Page fields:** dish + allergens + allergen notes (mirror the member picker).
4. **Dish write precedence:** last-write-wins. Both the inline admin `dishId` field
and the self-service page can write the dish; the admin can always override.
## Reference pattern
This feature mirrors the existing **finalist confirmation flow**:
- `src/lib/finalist-token.ts` — HMAC-signed token (`{ confirmationId, exp }`) via
`NEXTAUTH_SECRET`.
- `src/app/(public)/finalist/confirm/[token]/page.tsx` — public, tokenized, no-login page.
- `finalist.getByToken` / `finalist.confirm` / `finalist.decline``publicProcedure`s.
We replicate this shape for externals.
## Architecture
### 1. Data model
`prisma/schema.prisma``ExternalAttendee` gains one nullable field:
```prisma
inviteSentAt DateTime? // when the dish-selection email was last sent
```
- Drives an "invited ✓" indicator in the admin UI.
- Does **not** gate resends or reminders (those are intentionally repeatable).
- Nullable, so the migration is additive with no backfill.
**No `token` column.** The link is a stateless HMAC-signed token; the external is
loaded by the `externalId` embedded in the verified payload. Trade-off accepted:
individual links can't be revoked without rotating `NEXTAUTH_SECRET` — acceptable
for low-stakes dish picking. (This is the one intentional divergence from the
finalist flow, which stores a DB token for supersede/rotation scenarios that
externals don't have.)
Migration: single additive column. Apply in prod via `prisma migrate deploy`
(runs automatically on container start per the entrypoint). **Do not** run
`migrate dev` against the drifted dev DB — create the migration SQL and use
`db execute` + `migrate resolve` if needed locally.
### 2. Token helper — `src/lib/external-lunch-token.ts`
Mirror `finalist-token.ts`:
```ts
export type ExternalLunchTokenPayload = { externalId: string; exp: number }
export function signExternalLunchToken(payload): string
export function verifyExternalLunchToken(token): ExternalLunchTokenPayload // throws on bad sig / expired
```
- HMAC-SHA256 over base64url payload, `timingSafeEqual` comparison.
- `exp` = `eventAt + 24h` when `eventAt` is set, else `now + 30d`. Generous so the
link outlives the change deadline (the deadline is enforced separately at write time).
### 3. tRPC — `src/server/routers/lunch.ts`
- **`getExternalByToken`** (`publicProcedure`, input `{ token }`):
verify token → load external (+ its `LunchEvent`, ordered `dishes`, current
`dish`/`allergens`/`allergenOther`) → return payload incl. computed
`changeDeadline = eventAt changeCutoffHours`. Throws map to the page's friendly
error states (`expired` / `signature` / not found).
- **`setExternalPick`** (`publicProcedure`, input
`{ token, dishId: string | null, allergens, allergenOther }`):
verify token → if `eventAt` set and `now > changeDeadline``PRECONDITION_FAILED`
→ update the external's `dishId` / `allergens` / `allergenOther`. No audit row
(no authenticated user on a public pick).
- **`sendExternalInvite`** (`adminProcedure`, input `{ externalId }`):
load external (must have an email, else `PRECONDITION_FAILED`) → sign token →
`sendExternalDishInviteEmail(...)` → stamp `inviteSentAt = now` → audit
`LUNCH_EXTERNAL_INVITE_SENT`. Returns the updated row.
- **`createExternal`** (existing, modified): after insert, if `input.email` present,
fire-and-forget send the invite (sign token, send email, stamp `inviteSentAt`)
wrapped in `try/catch`**never throws** (per the "round notifications never
throw" project constraint). A failed send leaves `inviteSentAt = null` so the
admin can resend.
### 4. Email — `src/lib/email.ts`
```ts
export async function sendExternalDishInviteEmail(opts: {
to: string
name: string
eventAt: Date | null
venue: string | null
notes: string | null
changeDeadline: Date | null
pickUrl: string
}): Promise<void>
```
- Uses the existing branded wrapper.
- Subject: `Choose your lunch dish — MOPC grand finale`.
- Body: greeting, event date (Europe/Monaco), venue, optional notes, deadline, CTA
button → `pickUrl`.
- One template serves both the initial invite and reminders.
### 5. Reminders — extend existing flow
- **`src/server/services/lunch-reminders.ts`**: add
`selectUnpickedExternals(prisma, event)` → externals where `email` is set and
`dishId IS NULL` for the event.
- **`src/app/api/cron/lunch-reminders/route.ts`** and **`lunch.sendReminders`**:
after the existing `AttendingMember` loop, also loop unpicked externals and send
`sendExternalDishInviteEmail` with a freshly signed token URL. External links go
to `/lunch/pick/<token>` (not `/applicant`). Per-send errors are caught and
logged, consistent with the member loop.
### 6. Public page — `src/app/(public)/lunch/pick/[token]/page.tsx`
Mirror `(public)/finalist/confirm/[token]/page.tsx`:
- `'use client'`, reads `token` from params, queries `lunch.getExternalByToken`.
- States: loading skeleton; invalid/expired/not-found friendly cards (reuse the
`FriendlyError` pattern with `info@monaco-opc.com` fallback).
- Header card: event date, venue, notes, deadline countdown (reuse `CountdownLabel`).
- Form: dish radio list with dietary-tag badges, allergen checkboxes, allergen-notes
textarea. Submit → `lunch.setExternalPick`.
- Success state: "Your dish is saved", editable until the deadline.
- Past deadline: read-only with "contact an admin" message.
### 7. Admin UI — logistics externals table
- Per-row status chip: `no email` / `Invited` / `Picked`.
- Per-row **Resend invite** button → `lunch.sendExternalInvite` (disabled when no email).
- The inline `dishId` editor stays (admin override path).
### Manifest / recap
No change. `lunch-recap.ts` already includes externals, so self-service picks flow
into the manifest, CSV export, and recap email automatically.
## Edge cases
- **No email on external:** auto-send skipped; resend button disabled; reminders skip.
- **Tampered / expired link:** friendly error card; no data leak.
- **Pick after deadline:** `PRECONDITION_FAILED`; page shows read-only state.
- **Admin and external both set a dish:** last-write-wins (intended).
- **Email added later via `updateExternal`:** no auto-send on update; admin uses the
resend button (keeps `updateExternal` side-effect-free).
## Testing
- Unit: token sign/verify roundtrip + tamper + expiry rejection (`external-lunch-token`).
- Unit: `selectUnpickedExternals` returns only emailed + unpicked externals.
- Integration: `getExternalByToken` happy path; bad/expired token errors.
- Integration: `setExternalPick` happy path; deadline rejection.
- Integration: `createExternal` with email stamps `inviteSentAt` (mocked email send);
without email leaves it null.
## Out of scope
- External attendee decline / RSVP (this is dish-only).
- Reworking the member picker.
- Audience-window / live-voting rework (tracked separately).
```