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>
8.7 KiB
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.updateExternalwrite the row and send no email.- The only "Pick your lunch dish" email (
sendLunchReminderEmail) is driven byselectUnpickedAttendees, which queriesAttendingMemberrows tied to a CONFIRMEDFinalistConfirmation— finalist team members only. Externals are never in that set. sendLunchRecapEmailgoes to admins +extraRecipientsonly (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)
- Email trigger: auto-send on add (when the external has an email) plus a per-row "Resend invite" button in the logistics screen.
- Reminders: unpicked externals are included in both the reminder cron and the manual "Send reminders" action.
- Page fields: dish + allergens + allergen notes (mirror the member picker).
- Dish write precedence: last-write-wins. Both the inline admin
dishIdfield 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 }) viaNEXTAUTH_SECRET.src/app/(public)/finalist/confirm/[token]/page.tsx— public, tokenized, no-login page.finalist.getByToken/finalist.confirm/finalist.decline—publicProcedures.
We replicate this shape for externals.
Architecture
1. Data model
prisma/schema.prisma — ExternalAttendee gains one nullable field:
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:
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,
timingSafeEqualcomparison. exp=eventAt + 24hwheneventAtis set, elsenow + 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 (+ itsLunchEvent, ordereddishes, currentdish/allergens/allergenOther) → return payload incl. computedchangeDeadline = 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 → ifeventAtset andnow > changeDeadline→PRECONDITION_FAILED→ update the external'sdishId/allergens/allergenOther. No audit row (no authenticated user on a public pick). -
sendExternalInvite(adminProcedure, input{ externalId }): load external (must have an email, elsePRECONDITION_FAILED) → sign token →sendExternalDishInviteEmail(...)→ stampinviteSentAt = now→ auditLUNCH_EXTERNAL_INVITE_SENT. Returns the updated row. -
createExternal(existing, modified): after insert, ifinput.emailpresent, fire-and-forget send the invite (sign token, send email, stampinviteSentAt) wrapped intry/catch— never throws (per the "round notifications never throw" project constraint). A failed send leavesinviteSentAt = nullso the admin can resend.
4. Email — src/lib/email.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: addselectUnpickedExternals(prisma, event)→ externals whereemailis set anddishId IS NULLfor the event.src/app/api/cron/lunch-reminders/route.tsandlunch.sendReminders: after the existingAttendingMemberloop, also loop unpicked externals and sendsendExternalDishInviteEmailwith 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', readstokenfrom params, querieslunch.getExternalByToken.- States: loading skeleton; invalid/expired/not-found friendly cards (reuse the
FriendlyErrorpattern withinfo@monaco-opc.comfallback). - 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
dishIdeditor 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 (keepsupdateExternalside-effect-free).
Testing
- Unit: token sign/verify roundtrip + tamper + expiry rejection (
external-lunch-token). - Unit:
selectUnpickedExternalsreturns only emailed + unpicked externals. - Integration:
getExternalByTokenhappy path; bad/expired token errors. - Integration:
setExternalPickhappy path; deadline rejection. - Integration:
createExternalwith email stampsinviteSentAt(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).