# 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 ``` - 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/` (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). ```