From 8d4f0bac1e793d447e711fa9b73c7e7a1a809956 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 5 Jun 2026 12:04:13 +0200 Subject: [PATCH] feat(logistics): external attendees self-select lunch dish via tokenized page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...external-attendee-dish-selection-design.md | 199 +++++++++++ .../migration.sql | 2 + prisma/schema.prisma | 1 + src/app/(public)/lunch/pick/[token]/page.tsx | 327 ++++++++++++++++++ src/app/api/cron/lunch-reminders/route.ts | 19 +- .../admin/logistics/lunch-externals.tsx | 43 ++- src/lib/email.ts | 67 ++++ src/lib/external-lunch-token.ts | 45 +++ src/server/routers/lunch.ts | 185 +++++++++- src/server/services/lunch-external-invite.ts | 61 ++++ src/server/services/lunch-reminders.ts | 18 + tests/unit/external-lunch-token.test.ts | 36 ++ tests/unit/lunch-cron.test.ts | 30 ++ tests/unit/lunch-external-pick.test.ts | 200 +++++++++++ .../lunch-external-reminder-filter.test.ts | 63 ++++ 15 files changed, 1292 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-05-external-attendee-dish-selection-design.md create mode 100644 prisma/migrations/20260605114905_add_external_attendee_invite_sent_at/migration.sql create mode 100644 src/app/(public)/lunch/pick/[token]/page.tsx create mode 100644 src/lib/external-lunch-token.ts create mode 100644 src/server/services/lunch-external-invite.ts create mode 100644 tests/unit/external-lunch-token.test.ts create mode 100644 tests/unit/lunch-external-pick.test.ts create mode 100644 tests/unit/lunch-external-reminder-filter.test.ts diff --git a/docs/superpowers/specs/2026-06-05-external-attendee-dish-selection-design.md b/docs/superpowers/specs/2026-06-05-external-attendee-dish-selection-design.md new file mode 100644 index 0000000..31f7113 --- /dev/null +++ b/docs/superpowers/specs/2026-06-05-external-attendee-dish-selection-design.md @@ -0,0 +1,199 @@ +# 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). +``` diff --git a/prisma/migrations/20260605114905_add_external_attendee_invite_sent_at/migration.sql b/prisma/migrations/20260605114905_add_external_attendee_invite_sent_at/migration.sql new file mode 100644 index 0000000..16bcdc0 --- /dev/null +++ b/prisma/migrations/20260605114905_add_external_attendee_invite_sent_at/migration.sql @@ -0,0 +1,2 @@ +-- Add inviteSentAt to ExternalAttendee for dish-selection email tracking +ALTER TABLE "ExternalAttendee" ADD COLUMN "inviteSentAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1e95b84..d4c76a2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2983,6 +2983,7 @@ model ExternalAttendee { dishId String? allergens Allergen[] @default([]) allergenOther String? + inviteSentAt DateTime? // when the dish-selection email was last sent (null = never invited) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/(public)/lunch/pick/[token]/page.tsx b/src/app/(public)/lunch/pick/[token]/page.tsx new file mode 100644 index 0000000..b93821e --- /dev/null +++ b/src/app/(public)/lunch/pick/[token]/page.tsx @@ -0,0 +1,327 @@ +'use client' + +import { Suspense, use, useEffect, useState } from 'react' +import { trpc } from '@/lib/trpc/client' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' +import { Skeleton } from '@/components/ui/skeleton' +import { Textarea } from '@/components/ui/textarea' +import { Badge } from '@/components/ui/badge' +import { Label } from '@/components/ui/label' +import { AlertCircle, CheckCircle2, Loader2, Salad, UtensilsCrossed } from 'lucide-react' + +const ALLERGENS = [ + 'GLUTEN', 'CRUSTACEANS', 'EGGS', 'FISH', 'PEANUTS', 'SOYBEANS', 'MILK', + 'TREE_NUTS', 'CELERY', 'MUSTARD', 'SESAME', 'SULPHITES', 'LUPIN', 'MOLLUSCS', +] as const +type Allergen = (typeof ALLERGENS)[number] + +interface PageProps { + params: Promise<{ token: string }> +} + +function formatTag(t: string): string { + return t.replace('_', ' ').toLowerCase() +} + +function formatWhen(d: Date): string { + return new Intl.DateTimeFormat(undefined, { + dateStyle: 'long', + timeStyle: 'short', + }).format(d) +} + +function CountdownLabel({ deadline }: { deadline: Date }) { + const [now, setNow] = useState(Date.now()) + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000) + return () => clearInterval(id) + }, []) + const ms = deadline.getTime() - now + if (ms <= 0) return closed + const totalSec = Math.floor(ms / 1000) + const hours = Math.floor(totalSec / 3600) + const minutes = Math.floor((totalSec % 3600) / 60) + const seconds = totalSec % 60 + if (hours >= 24) { + const days = Math.floor(hours / 24) + return ( + + {days}d {hours % 24}h remaining + + ) + } + return ( + + {hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}: + {seconds.toString().padStart(2, '0')} remaining + + ) +} + +function FriendlyError({ title, message }: { title: string; message: string }) { + return ( + + +
+ + {title} +
+
+ +

{message}

+
+
+ ) +} + +function DishPickContent({ token }: { token: string }) { + const { data, isLoading, error } = trpc.lunch.getExternalByToken.useQuery( + { token }, + { retry: false }, + ) + const setPick = trpc.lunch.setExternalPick.useMutation() + + const [dishId, setDishId] = useState('') + const [allergens, setAllergens] = useState([]) + const [allergenOther, setAllergenOther] = useState('') + const [hydrated, setHydrated] = useState(false) + const [saved, setSaved] = useState(false) + const [submitError, setSubmitError] = useState(null) + + useEffect(() => { + if (!hydrated && data) { + setDishId(data.external.dishId ?? '') + setAllergens((data.external.allergens as Allergen[]) ?? []) + setAllergenOther(data.external.allergenOther ?? '') + setHydrated(true) + } + }, [data, hydrated]) + + if (isLoading) { + return ( +
+ + + +
+ ) + } + + if (error) { + const msg = error.message ?? '' + if (/expired/i.test(msg)) { + return ( + + ) + } + if (/signature|malformed|parseable/i.test(msg)) { + return ( + + ) + } + return ( + + ) + } + if (!data) { + return ( + + ) + } + + const deadline = data.changeDeadline ? new Date(data.changeDeadline) : null + const deadlinePassed = deadline ? new Date() > deadline : false + const eventAt = data.event.eventAt ? new Date(data.event.eventAt) : null + + const handleSave = async () => { + setSubmitError(null) + try { + await setPick.mutateAsync({ + token, + dishId: dishId || null, + allergens, + allergenOther: allergenOther.trim() || null, + }) + setSaved(true) + } catch (err) { + setSubmitError(err instanceof Error ? err.message : 'Failed to save') + } + } + + const eventCard = ( + + +
+ + + {data.event.venue ? `Lunch at ${data.event.venue}` : 'Lunch'} + +
+
+ +

+ Hi {data.external.name}, please choose your dish below. +

+ {eventAt && ( +

+ When: {formatWhen(eventAt)} +

+ )} + {data.event.notes && ( +

{data.event.notes}

+ )} + {deadline && !deadlinePassed && ( +

+ Choose by {formatWhen(deadline)} · +

+ )} +
+
+ ) + + // Past the change deadline → read-only. + if (deadlinePassed) { + const chosen = data.dishes.find((d) => d.id === data.external.dishId) + return ( +
+ {eventCard} + +
+ ) + } + + return ( +
+ {eventCard} + + + + + Your dish + + + + + {data.dishes.map((d) => ( + + ))} + {data.dishes.length === 0 && ( +

+ No dishes have been published yet. Please check back later. +

+ )} +
+ +
+ +
+ {ALLERGENS.map((a) => ( + + ))} +
+
+ +
+ +