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

8.7 KiB
Raw Blame History

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.declinepublicProcedures.

We replicate this shape for externals.

Architecture

1. Data model

prisma/schema.prismaExternalAttendee 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, 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 > changeDeadlinePRECONDITION_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/catchnever 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

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).