200 lines
8.7 KiB
Markdown
200 lines
8.7 KiB
Markdown
|
|
# 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).
|
|||
|
|
```
|