feat(logistics): external attendees self-select lunch dish via tokenized page
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m51s
All checks were successful
Build and Push Docker Image / build (push) Successful in 7m51s
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>
This commit is contained in:
@@ -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<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).
|
||||||
|
```
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add inviteSentAt to ExternalAttendee for dish-selection email tracking
|
||||||
|
ALTER TABLE "ExternalAttendee" ADD COLUMN "inviteSentAt" TIMESTAMP(3);
|
||||||
@@ -2983,6 +2983,7 @@ model ExternalAttendee {
|
|||||||
dishId String?
|
dishId String?
|
||||||
allergens Allergen[] @default([])
|
allergens Allergen[] @default([])
|
||||||
allergenOther String?
|
allergenOther String?
|
||||||
|
inviteSentAt DateTime? // when the dish-selection email was last sent (null = never invited)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
327
src/app/(public)/lunch/pick/[token]/page.tsx
Normal file
327
src/app/(public)/lunch/pick/[token]/page.tsx
Normal file
@@ -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<number>(Date.now())
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(Date.now()), 1000)
|
||||||
|
return () => clearInterval(id)
|
||||||
|
}, [])
|
||||||
|
const ms = deadline.getTime() - now
|
||||||
|
if (ms <= 0) return <span className="text-destructive font-medium">closed</span>
|
||||||
|
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 (
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{days}d {hours % 24}h remaining
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{hours.toString().padStart(2, '0')}:{minutes.toString().padStart(2, '0')}:
|
||||||
|
{seconds.toString().padStart(2, '0')} remaining
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FriendlyError({ title, message }: { title: string; message: string }) {
|
||||||
|
return (
|
||||||
|
<Card className="mx-auto max-w-xl">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="text-muted-foreground h-5 w-5" />
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground">{message}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string>('')
|
||||||
|
const [allergens, setAllergens] = useState<Allergen[]>([])
|
||||||
|
const [allergenOther, setAllergenOther] = useState<string>('')
|
||||||
|
const [hydrated, setHydrated] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(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 (
|
||||||
|
<div className="mx-auto max-w-xl space-y-4">
|
||||||
|
<Skeleton className="h-8 w-2/3" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
<Skeleton className="h-48 w-full" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const msg = error.message ?? ''
|
||||||
|
if (/expired/i.test(msg)) {
|
||||||
|
return (
|
||||||
|
<FriendlyError
|
||||||
|
title="This link has expired"
|
||||||
|
message="Please contact us at info@monaco-opc.com and we'll sort out your lunch."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (/signature|malformed|parseable/i.test(msg)) {
|
||||||
|
return (
|
||||||
|
<FriendlyError
|
||||||
|
title="This link is not valid"
|
||||||
|
message="Please check your email or contact us at info@monaco-opc.com."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<FriendlyError
|
||||||
|
title="Something went wrong"
|
||||||
|
message={msg || 'Please try again or contact us at info@monaco-opc.com.'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<FriendlyError
|
||||||
|
title="Not found"
|
||||||
|
message="Please check your email link or contact us at info@monaco-opc.com."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = (
|
||||||
|
<Card className="border-primary/40 bg-primary/5">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UtensilsCrossed className="text-primary h-5 w-5" />
|
||||||
|
<CardTitle>
|
||||||
|
{data.event.venue ? `Lunch at ${data.event.venue}` : 'Lunch'}
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-1 text-sm">
|
||||||
|
<p>
|
||||||
|
Hi <strong>{data.external.name}</strong>, please choose your dish below.
|
||||||
|
</p>
|
||||||
|
{eventAt && (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
<strong>When:</strong> {formatWhen(eventAt)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{data.event.notes && (
|
||||||
|
<p className="text-muted-foreground">{data.event.notes}</p>
|
||||||
|
)}
|
||||||
|
{deadline && !deadlinePassed && (
|
||||||
|
<p className="text-muted-foreground pt-1">
|
||||||
|
Choose by {formatWhen(deadline)} · <CountdownLabel deadline={deadline} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Past the change deadline → read-only.
|
||||||
|
if (deadlinePassed) {
|
||||||
|
const chosen = data.dishes.find((d) => d.id === data.external.dishId)
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl space-y-6">
|
||||||
|
{eventCard}
|
||||||
|
<FriendlyError
|
||||||
|
title="Dish selection is now closed"
|
||||||
|
message={
|
||||||
|
chosen
|
||||||
|
? `Your choice is "${chosen.name}". To change it, please contact us at info@monaco-opc.com.`
|
||||||
|
: 'The deadline to choose a dish has passed. Please contact us at info@monaco-opc.com.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl space-y-6">
|
||||||
|
{eventCard}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Salad className="h-4 w-4 text-emerald-600" /> Your dish
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<RadioGroup value={dishId} onValueChange={setDishId} className="gap-2">
|
||||||
|
{data.dishes.map((d) => (
|
||||||
|
<label
|
||||||
|
key={d.id}
|
||||||
|
htmlFor={`dish-${d.id}`}
|
||||||
|
className="hover:bg-muted/50 flex cursor-pointer items-start gap-3 rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<RadioGroupItem id={`dish-${d.id}`} value={d.id} className="mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{d.name}</div>
|
||||||
|
{d.dietaryTags.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{d.dietaryTags.map((t) => (
|
||||||
|
<Badge key={t} variant="secondary" className="text-xs">
|
||||||
|
{formatTag(t)}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
{data.dishes.length === 0 && (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
No dishes have been published yet. Please check back later.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">Allergens</Label>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{ALLERGENS.map((a) => (
|
||||||
|
<label key={a} className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={allergens.includes(a)}
|
||||||
|
onCheckedChange={(v) =>
|
||||||
|
setAllergens(
|
||||||
|
v ? [...allergens, a] : allergens.filter((x) => x !== a),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{formatTag(a)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">Other allergens / dietary notes</Label>
|
||||||
|
<Textarea
|
||||||
|
value={allergenOther}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAllergenOther(e.target.value)
|
||||||
|
setSaved(false)
|
||||||
|
}}
|
||||||
|
rows={2}
|
||||||
|
className="mt-1"
|
||||||
|
placeholder="e.g. severe nut allergy, no shellfish"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{submitError && (
|
||||||
|
<div className="border-destructive bg-destructive/10 text-destructive rounded-md border p-3 text-sm">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
{saved && !setPick.isPending ? (
|
||||||
|
<span className="flex items-center gap-2 text-sm text-emerald-600">
|
||||||
|
<CheckCircle2 className="h-4 w-4" /> Saved — you can change it until the deadline.
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<Button size="lg" onClick={handleSave} disabled={setPick.isPending}>
|
||||||
|
{setPick.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Saving…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="mr-2 h-4 w-4" /> Save my choice
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LunchPickPage({ params }: PageProps) {
|
||||||
|
const { token } = use(params)
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Skeleton className="h-64 w-full" />}>
|
||||||
|
<DishPickContent token={token} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { NextResponse, type NextRequest } from 'next/server'
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { sendLunchReminderEmail } from '@/lib/email'
|
import { sendLunchReminderEmail } from '@/lib/email'
|
||||||
import { selectUnpickedAttendees } from '@/server/services/lunch-reminders'
|
import {
|
||||||
|
selectUnpickedAttendees,
|
||||||
|
selectUnpickedExternals,
|
||||||
|
} from '@/server/services/lunch-reminders'
|
||||||
|
import { sendExternalDishInvite } from '@/server/services/lunch-external-invite'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cron: send a single reminder email per attending member who hasn't picked
|
* Cron: send a single reminder email per attending member who hasn't picked
|
||||||
@@ -53,6 +57,19 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
|
|||||||
console.error('[lunch-reminders] send failed for', am.user.email, e)
|
console.error('[lunch-reminders] send failed for', am.user.email, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// External attendees: emailed + no dish yet → their tokenized pick page.
|
||||||
|
const externals = await selectUnpickedExternals(prisma, { id: event.id })
|
||||||
|
for (const ext of externals) {
|
||||||
|
if (!ext.email) continue
|
||||||
|
try {
|
||||||
|
await sendExternalDishInvite(prisma, ext, event)
|
||||||
|
sent++
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[lunch-reminders] external send failed for', ext.email, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.lunchEvent.update({
|
await prisma.lunchEvent.update({
|
||||||
where: { id: event.id },
|
where: { id: event.id },
|
||||||
data: { reminderSentAt: new Date() },
|
data: { reminderSentAt: new Date() },
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Plus, Pencil, Trash2, Mail, MailCheck, Utensils } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
const ALLERGENS = [
|
const ALLERGENS = [
|
||||||
@@ -90,6 +91,13 @@ export const LunchExternals = forwardRef<
|
|||||||
onSuccess: invalidateAll,
|
onSuccess: invalidateAll,
|
||||||
onError: (e) => toast.error(e.message),
|
onError: (e) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
|
const sendInvite = trpc.lunch.sendExternalInvite.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateAll()
|
||||||
|
toast.success('Dish invite sent')
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
const editingRow =
|
const editingRow =
|
||||||
editing?.mode === 'edit'
|
editing?.mode === 'edit'
|
||||||
@@ -123,7 +131,40 @@ export const LunchExternals = forwardRef<
|
|||||||
{e.project?.title ?? 'Standalone'}
|
{e.project?.title ?? 'Standalone'}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-muted-foreground">{e.roleNote ?? ''}</td>
|
<td className="text-muted-foreground">{e.roleNote ?? ''}</td>
|
||||||
|
<td>
|
||||||
|
{e.dishId ? (
|
||||||
|
<Badge variant="secondary" className="gap-1">
|
||||||
|
<Utensils className="h-3 w-3" /> Picked
|
||||||
|
</Badge>
|
||||||
|
) : !e.email ? (
|
||||||
|
<Badge variant="outline" className="text-muted-foreground">
|
||||||
|
No email
|
||||||
|
</Badge>
|
||||||
|
) : e.inviteSentAt ? (
|
||||||
|
<Badge variant="outline" className="gap-1">
|
||||||
|
<MailCheck className="h-3 w-3" /> Invited
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-muted-foreground">
|
||||||
|
Not invited
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="text-right">
|
<td className="text-right">
|
||||||
|
{e.email && !e.dishId && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
title={e.inviteSentAt ? 'Resend dish invite' : 'Send dish invite'}
|
||||||
|
disabled={
|
||||||
|
sendInvite.isPending &&
|
||||||
|
sendInvite.variables?.externalId === e.id
|
||||||
|
}
|
||||||
|
onClick={() => sendInvite.mutate({ externalId: e.id })}
|
||||||
|
>
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -3650,6 +3650,73 @@ ${opts.pickUrl}`
|
|||||||
await sendEmail({ to: opts.to, subject, text, html })
|
await sendEmail({ to: opts.to, subject, text, html })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invite an external lunch attendee to choose their dish via a tokenized,
|
||||||
|
* no-login page. Used for the initial invite (auto on add + admin resend) and
|
||||||
|
* for reminder sweeps — one template serves both.
|
||||||
|
*/
|
||||||
|
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> {
|
||||||
|
const fmt = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: 'Europe/Monaco', dateStyle: 'long', timeStyle: 'short',
|
||||||
|
})
|
||||||
|
const subject = 'Choose your lunch dish — Monaco Ocean Protection Challenge'
|
||||||
|
const venuePhrase = opts.venue
|
||||||
|
? `lunch at ${escapeHtml(opts.venue)}`
|
||||||
|
: 'the Monaco Ocean Protection Challenge lunch'
|
||||||
|
const eventLine = opts.eventAt
|
||||||
|
? `<strong>When:</strong> ${escapeHtml(fmt.format(opts.eventAt))} (Europe/Monaco)`
|
||||||
|
: ''
|
||||||
|
const notesLine = opts.notes ? escapeHtml(opts.notes) : ''
|
||||||
|
const content = `
|
||||||
|
${sectionTitle('Choose your lunch dish')}
|
||||||
|
${paragraph(`Hi ${escapeHtml(opts.name)},`)}
|
||||||
|
${paragraph(
|
||||||
|
`You are joining us for ${venuePhrase}. Please pick your dish and let us know ` +
|
||||||
|
'about any allergies so the kitchen can cater for you.',
|
||||||
|
)}
|
||||||
|
${eventLine ? paragraph(eventLine) : ''}
|
||||||
|
${notesLine ? paragraph(notesLine) : ''}
|
||||||
|
${
|
||||||
|
opts.changeDeadline
|
||||||
|
? infoBox(
|
||||||
|
`<strong>Please choose by ${escapeHtml(fmt.format(opts.changeDeadline))}.</strong>`,
|
||||||
|
'warning',
|
||||||
|
)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
${ctaButton(opts.pickUrl, 'Choose my dish')}
|
||||||
|
${paragraph(
|
||||||
|
`<span style="color:#64748b;font-size:13px;">If you have any questions, reply to this email and we'll help.</span>`,
|
||||||
|
)}
|
||||||
|
`
|
||||||
|
const html = getEmailWrapper(content)
|
||||||
|
const text = [
|
||||||
|
`Choose your lunch dish — Monaco Ocean Protection Challenge`,
|
||||||
|
``,
|
||||||
|
`Hi ${opts.name},`,
|
||||||
|
``,
|
||||||
|
`Please pick your dish for ${
|
||||||
|
opts.venue ? `lunch at ${opts.venue}` : 'the Monaco Ocean Protection Challenge lunch'
|
||||||
|
}.`,
|
||||||
|
opts.eventAt ? `When: ${fmt.format(opts.eventAt)} (Europe/Monaco)` : '',
|
||||||
|
opts.notes ? opts.notes : '',
|
||||||
|
opts.changeDeadline ? `Please choose by: ${fmt.format(opts.changeDeadline)}` : '',
|
||||||
|
``,
|
||||||
|
`Choose your dish: ${opts.pickUrl}`,
|
||||||
|
]
|
||||||
|
.filter((l) => l !== '')
|
||||||
|
.join('\n')
|
||||||
|
await sendEmail({ to: opts.to, subject, text, html })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send the lunch recap manifest to admins + extra recipients.
|
* Send the lunch recap manifest to admins + extra recipients.
|
||||||
* Caller passes the assembled recap payload from `buildRecapPayload`.
|
* Caller passes the assembled recap payload from `buildRecapPayload`.
|
||||||
|
|||||||
45
src/lib/external-lunch-token.ts
Normal file
45
src/lib/external-lunch-token.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'crypto'
|
||||||
|
|
||||||
|
export type ExternalLunchTokenPayload = {
|
||||||
|
externalId: string
|
||||||
|
/** Unix seconds. Token is rejected after this. */
|
||||||
|
exp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSecret(): string {
|
||||||
|
const s = process.env.NEXTAUTH_SECRET
|
||||||
|
if (!s) throw new Error('NEXTAUTH_SECRET is not set; cannot sign external lunch tokens')
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function hmac(payloadB64: string): string {
|
||||||
|
return createHmac('sha256', getSecret()).update(payloadB64).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signExternalLunchToken(payload: ExternalLunchTokenPayload): string {
|
||||||
|
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||||
|
const sig = hmac(payloadB64)
|
||||||
|
return `${payloadB64}.${sig}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyExternalLunchToken(token: string): ExternalLunchTokenPayload {
|
||||||
|
const parts = token.split('.')
|
||||||
|
if (parts.length !== 2) throw new Error('Invalid external lunch token: malformed')
|
||||||
|
const [payloadB64, sig] = parts
|
||||||
|
const expected = hmac(payloadB64)
|
||||||
|
const a = Buffer.from(sig, 'hex')
|
||||||
|
const b = Buffer.from(expected, 'hex')
|
||||||
|
if (a.length !== b.length || !timingSafeEqual(a, b)) {
|
||||||
|
throw new Error('Invalid external lunch token: signature mismatch')
|
||||||
|
}
|
||||||
|
let payload: ExternalLunchTokenPayload
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf-8'))
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid external lunch token: payload not parseable')
|
||||||
|
}
|
||||||
|
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(Date.now() / 1000)) {
|
||||||
|
throw new Error('Invalid external lunch token: expired')
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { router, adminProcedure, protectedProcedure } from '../trpc'
|
import { router, adminProcedure, protectedProcedure, publicProcedure } from '../trpc'
|
||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
import { buildManifest, buildRecapPayload } from '../services/lunch-recap'
|
import { buildManifest, buildRecapPayload } from '../services/lunch-recap'
|
||||||
import { selectUnpickedAttendees } from '../services/lunch-reminders'
|
import { selectUnpickedAttendees, selectUnpickedExternals } from '../services/lunch-reminders'
|
||||||
|
import { sendExternalDishInvite } from '../services/lunch-external-invite'
|
||||||
import { sendLunchRecapEmail, sendLunchReminderEmail } from '@/lib/email'
|
import { sendLunchRecapEmail, sendLunchReminderEmail } from '@/lib/email'
|
||||||
|
import { verifyExternalLunchToken } from '@/lib/external-lunch-token'
|
||||||
import { csvCell } from '@/lib/csv'
|
import { csvCell } from '@/lib/csv'
|
||||||
|
|
||||||
// ─── Shared zod schemas ──────────────────────────────────────────────────────
|
// ─── Shared zod schemas ──────────────────────────────────────────────────────
|
||||||
@@ -179,6 +181,19 @@ export const lunchRouter = router({
|
|||||||
entityId: ext.id,
|
entityId: ext.id,
|
||||||
detailsJson: { name: ext.name, projectId: ext.projectId },
|
detailsJson: { name: ext.name, projectId: ext.projectId },
|
||||||
})
|
})
|
||||||
|
// Auto-send the dish-selection invite when an email is on file. Never throws
|
||||||
|
// — a failed send leaves inviteSentAt null so the admin can resend.
|
||||||
|
if (ext.email) {
|
||||||
|
try {
|
||||||
|
const event = await ctx.prisma.lunchEvent.findUnique({
|
||||||
|
where: { id: input.lunchEventId },
|
||||||
|
select: { eventAt: true, venue: true, notes: true, changeCutoffHours: true },
|
||||||
|
})
|
||||||
|
if (event) await sendExternalDishInvite(ctx.prisma, ext, event)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[lunch.createExternal] dish invite send failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
return ext
|
return ext
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -229,6 +244,159 @@ export const lunchRouter = router({
|
|||||||
return { ok: true as const }
|
return { ok: true as const }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send (or resend) the dish-selection invite to one external attendee.
|
||||||
|
* Stamps `inviteSentAt` and audit-logs. Fails if the attendee has no email.
|
||||||
|
*/
|
||||||
|
sendExternalInvite: adminProcedure
|
||||||
|
.input(z.object({ externalId: z.string() }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const external = await ctx.prisma.externalAttendee.findUnique({
|
||||||
|
where: { id: input.externalId },
|
||||||
|
include: {
|
||||||
|
lunchEvent: {
|
||||||
|
select: { eventAt: true, venue: true, notes: true, changeCutoffHours: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!external) throw new TRPCError({ code: 'NOT_FOUND' })
|
||||||
|
if (!external.email) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'This attendee has no email address. Add one first.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await sendExternalDishInvite(ctx.prisma, external, external.lunchEvent)
|
||||||
|
} catch (e) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: `Failed to send invite: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
cause: e,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await logAudit({
|
||||||
|
prisma: ctx.prisma,
|
||||||
|
userId: ctx.user.id,
|
||||||
|
action: 'LUNCH_EXTERNAL_INVITE_SENT',
|
||||||
|
entityType: 'ExternalAttendee',
|
||||||
|
entityId: external.id,
|
||||||
|
detailsJson: { email: external.email },
|
||||||
|
})
|
||||||
|
return { ok: true as const }
|
||||||
|
}),
|
||||||
|
|
||||||
|
// ─── Public tokenized external dish picker ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read an external attendee + their event + dishes by a signed, no-login token.
|
||||||
|
* Token verification throws on bad signature / expiry; the page maps those to
|
||||||
|
* friendly states.
|
||||||
|
*/
|
||||||
|
getExternalByToken: publicProcedure
|
||||||
|
.input(z.object({ token: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const payload = verifyExternalLunchToken(input.token) // throws on bad sig / expired
|
||||||
|
const external = await ctx.prisma.externalAttendee.findUnique({
|
||||||
|
where: { id: payload.externalId },
|
||||||
|
include: {
|
||||||
|
lunchEvent: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
eventAt: true,
|
||||||
|
endAt: true,
|
||||||
|
venue: true,
|
||||||
|
notes: true,
|
||||||
|
changeCutoffHours: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!external) throw new TRPCError({ code: 'NOT_FOUND' })
|
||||||
|
const dishes = await ctx.prisma.dish.findMany({
|
||||||
|
where: { lunchEventId: external.lunchEventId },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
|
||||||
|
})
|
||||||
|
const eventAt = external.lunchEvent.eventAt
|
||||||
|
const changeDeadline = eventAt
|
||||||
|
? new Date(eventAt.getTime() - external.lunchEvent.changeCutoffHours * 3_600_000)
|
||||||
|
: null
|
||||||
|
return {
|
||||||
|
external: {
|
||||||
|
id: external.id,
|
||||||
|
name: external.name,
|
||||||
|
dishId: external.dishId,
|
||||||
|
allergens: external.allergens,
|
||||||
|
allergenOther: external.allergenOther,
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
eventAt,
|
||||||
|
endAt: external.lunchEvent.endAt,
|
||||||
|
venue: external.lunchEvent.venue,
|
||||||
|
notes: external.lunchEvent.notes,
|
||||||
|
},
|
||||||
|
dishes,
|
||||||
|
changeDeadline,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save an external attendee's dish pick via their signed token. Enforces the
|
||||||
|
* lunch change cutoff (eventAt − changeCutoffHours); past it, the attendee must
|
||||||
|
* contact an admin.
|
||||||
|
*/
|
||||||
|
setExternalPick: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
token: z.string(),
|
||||||
|
dishId: z.string().nullable(),
|
||||||
|
allergens,
|
||||||
|
allergenOther: z.string().max(500).nullable(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const payload = verifyExternalLunchToken(input.token)
|
||||||
|
const external = await ctx.prisma.externalAttendee.findUnique({
|
||||||
|
where: { id: payload.externalId },
|
||||||
|
include: {
|
||||||
|
lunchEvent: { select: { eventAt: true, changeCutoffHours: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!external) throw new TRPCError({ code: 'NOT_FOUND' })
|
||||||
|
|
||||||
|
const eventAt = external.lunchEvent.eventAt
|
||||||
|
if (eventAt) {
|
||||||
|
const deadline = new Date(
|
||||||
|
eventAt.getTime() - external.lunchEvent.changeCutoffHours * 3_600_000,
|
||||||
|
)
|
||||||
|
if (new Date() > deadline) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'Past the lunch change deadline. Please contact an admin.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dish must belong to this attendee's event (defends against cross-event ids).
|
||||||
|
if (input.dishId) {
|
||||||
|
const dish = await ctx.prisma.dish.findFirst({
|
||||||
|
where: { id: input.dishId, lunchEventId: external.lunchEventId },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!dish) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Unknown dish' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await ctx.prisma.externalAttendee.update({
|
||||||
|
where: { id: external.id },
|
||||||
|
data: {
|
||||||
|
dishId: input.dishId,
|
||||||
|
allergens: input.allergens,
|
||||||
|
allergenOther: input.allergenOther,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { ok: true as const, dishId: updated.dishId }
|
||||||
|
}),
|
||||||
|
|
||||||
// ─── Single-row pick read (used by per-row picker UI) ────────────────────
|
// ─── Single-row pick read (used by per-row picker UI) ────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -611,6 +779,19 @@ export const lunchRouter = router({
|
|||||||
console.error('[lunch.sendReminders] send failed for', am.user.email, e)
|
console.error('[lunch.sendReminders] send failed for', am.user.email, e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// External attendees: chase the ones with an email and no dish yet, via
|
||||||
|
// their own tokenized pick page.
|
||||||
|
const externals = await selectUnpickedExternals(ctx.prisma, { id: event.id })
|
||||||
|
for (const ext of externals) {
|
||||||
|
if (!ext.email) continue
|
||||||
|
try {
|
||||||
|
await sendExternalDishInvite(ctx.prisma, ext, event)
|
||||||
|
sent++
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[lunch.sendReminders] external send failed for', ext.email, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
return { sent }
|
return { sent }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
61
src/server/services/lunch-external-invite.ts
Normal file
61
src/server/services/lunch-external-invite.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Prisma, PrismaClient } from '@prisma/client'
|
||||||
|
import { signExternalLunchToken } from '@/lib/external-lunch-token'
|
||||||
|
import { getBaseUrl, sendExternalDishInviteEmail } from '@/lib/email'
|
||||||
|
|
||||||
|
type PrismaLike = PrismaClient | Prisma.TransactionClient
|
||||||
|
|
||||||
|
export type InvitableExternal = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InviteLunchEvent = {
|
||||||
|
eventAt: Date | null
|
||||||
|
venue: string | null
|
||||||
|
notes: string | null
|
||||||
|
changeCutoffHours: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the public, tokenized dish-pick URL for an external attendee.
|
||||||
|
* The token is a stateless HMAC signature (see external-lunch-token) carrying the
|
||||||
|
* externalId; `exp` is generous (event day + 1, or 30 days when no event date)
|
||||||
|
* so the link outlives the change deadline, which is enforced separately at write.
|
||||||
|
*/
|
||||||
|
export function buildExternalPickUrl(externalId: string, eventAt: Date | null): string {
|
||||||
|
const exp = eventAt
|
||||||
|
? Math.floor(eventAt.getTime() / 1000) + 86_400
|
||||||
|
: Math.floor(Date.now() / 1000) + 30 * 86_400
|
||||||
|
const token = signExternalLunchToken({ externalId, exp })
|
||||||
|
return `${getBaseUrl()}/lunch/pick/${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send (or resend) the dish-selection invite to one external attendee and stamp
|
||||||
|
* `inviteSentAt`. Throws if the external has no email or the email send fails;
|
||||||
|
* callers that must not fail (e.g. createExternal) wrap this in try/catch.
|
||||||
|
*/
|
||||||
|
export async function sendExternalDishInvite(
|
||||||
|
prisma: PrismaLike,
|
||||||
|
external: InvitableExternal,
|
||||||
|
event: InviteLunchEvent,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!external.email) throw new Error('External attendee has no email address')
|
||||||
|
const changeDeadline = event.eventAt
|
||||||
|
? new Date(event.eventAt.getTime() - event.changeCutoffHours * 3_600_000)
|
||||||
|
: null
|
||||||
|
await sendExternalDishInviteEmail({
|
||||||
|
to: external.email,
|
||||||
|
name: external.name,
|
||||||
|
eventAt: event.eventAt,
|
||||||
|
venue: event.venue,
|
||||||
|
notes: event.notes,
|
||||||
|
changeDeadline,
|
||||||
|
pickUrl: buildExternalPickUrl(external.id, event.eventAt),
|
||||||
|
})
|
||||||
|
await prisma.externalAttendee.update({
|
||||||
|
where: { id: external.id },
|
||||||
|
data: { inviteSentAt: new Date() },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -30,3 +30,21 @@ export async function selectUnpickedAttendees(
|
|||||||
include: { user: { select: { name: true, email: true } } },
|
include: { user: { select: { name: true, email: true } } },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return external attendees for a LunchEvent that can still be chased for a dish
|
||||||
|
* pick: they have an email on file and have not yet been assigned a dish.
|
||||||
|
* Externals with no email (un-emailable) or an existing dish are excluded.
|
||||||
|
*/
|
||||||
|
export async function selectUnpickedExternals(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
event: { id: string },
|
||||||
|
) {
|
||||||
|
return prisma.externalAttendee.findMany({
|
||||||
|
where: {
|
||||||
|
lunchEventId: event.id,
|
||||||
|
dishId: null,
|
||||||
|
AND: [{ email: { not: null } }, { email: { not: '' } }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
36
tests/unit/external-lunch-token.test.ts
Normal file
36
tests/unit/external-lunch-token.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect, beforeAll } from 'vitest'
|
||||||
|
import {
|
||||||
|
signExternalLunchToken,
|
||||||
|
verifyExternalLunchToken,
|
||||||
|
} from '../../src/lib/external-lunch-token'
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.NEXTAUTH_SECRET = 'test-secret-for-external-lunch-tokens'
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('external lunch token', () => {
|
||||||
|
it('round-trips a payload', () => {
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + 86400
|
||||||
|
const token = signExternalLunchToken({ externalId: 'cmx_test', exp })
|
||||||
|
const verified = verifyExternalLunchToken(token)
|
||||||
|
expect(verified.externalId).toBe('cmx_test')
|
||||||
|
expect(verified.exp).toBe(exp)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects tampered tokens', () => {
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + 86400
|
||||||
|
const token = signExternalLunchToken({ externalId: 'cmx_test', exp })
|
||||||
|
const tampered = token.slice(0, -2) + 'xx'
|
||||||
|
expect(() => verifyExternalLunchToken(tampered)).toThrow(/signature/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects expired tokens', () => {
|
||||||
|
const exp = Math.floor(Date.now() / 1000) - 1
|
||||||
|
const token = signExternalLunchToken({ externalId: 'cmx_test', exp })
|
||||||
|
expect(() => verifyExternalLunchToken(token)).toThrow(/expired/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed tokens', () => {
|
||||||
|
expect(() => verifyExternalLunchToken('not-a-token')).toThrow(/malformed/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -14,6 +14,7 @@ vi.mock('@/lib/email', async () => {
|
|||||||
...actual,
|
...actual,
|
||||||
sendLunchReminderEmail: vi.fn(async () => undefined),
|
sendLunchReminderEmail: vi.fn(async () => undefined),
|
||||||
sendLunchRecapEmail: vi.fn(async () => undefined),
|
sendLunchRecapEmail: vi.fn(async () => undefined),
|
||||||
|
sendExternalDishInviteEmail: vi.fn(async () => undefined),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ afterAll(async () => {
|
|||||||
where: { confirmation: { project: { programId } } },
|
where: { confirmation: { project: { programId } } },
|
||||||
})
|
})
|
||||||
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
|
await prisma.finalistConfirmation.deleteMany({ where: { project: { programId } } })
|
||||||
|
await prisma.externalAttendee.deleteMany({ where: { lunchEvent: { programId } } })
|
||||||
await prisma.dish.deleteMany({ where: { lunchEvent: { programId } } })
|
await prisma.dish.deleteMany({ where: { lunchEvent: { programId } } })
|
||||||
await prisma.lunchEvent.deleteMany({ where: { programId } })
|
await prisma.lunchEvent.deleteMany({ where: { programId } })
|
||||||
await cleanupTestData(programId, [])
|
await cleanupTestData(programId, [])
|
||||||
@@ -58,6 +60,7 @@ describe('GET /api/cron/lunch-reminders', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
if (!process.env.CRON_SECRET) process.env.CRON_SECRET = 'test-secret'
|
if (!process.env.CRON_SECRET) process.env.CRON_SECRET = 'test-secret'
|
||||||
|
if (!process.env.NEXTAUTH_SECRET) process.env.NEXTAUTH_SECRET = 'test-secret-nextauth'
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects without CRON_SECRET', async () => {
|
it('rejects without CRON_SECRET', async () => {
|
||||||
@@ -119,6 +122,33 @@ describe('GET /api/cron/lunch-reminders', () => {
|
|||||||
expect(row?.reminderSentAt).not.toBeNull()
|
expect(row?.reminderSentAt).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('sends dish invites to unpicked external attendees inside the window', async () => {
|
||||||
|
const program = await createTestProgram({ name: `cron-rmd-ext-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
const eventAt = new Date(Date.now() + 25 * 3_600_000)
|
||||||
|
const event = await prisma.lunchEvent.create({
|
||||||
|
data: {
|
||||||
|
programId: program.id,
|
||||||
|
enabled: true,
|
||||||
|
eventAt,
|
||||||
|
venue: 'La Terrasse',
|
||||||
|
changeCutoffHours: 24,
|
||||||
|
reminderHoursBeforeDeadline: 4,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// emailed + no dish → invited; no-email → skipped
|
||||||
|
await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'Emailed Guest', email: `ext-${uid()}@x.test` },
|
||||||
|
})
|
||||||
|
await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'No Email Guest' },
|
||||||
|
})
|
||||||
|
const res = await callCron('lunch-reminders')
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const { sendExternalDishInviteEmail } = await import('@/lib/email')
|
||||||
|
expect(sendExternalDishInviteEmail).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('is idempotent — re-running with reminderSentAt set does not resend', async () => {
|
it('is idempotent — re-running with reminderSentAt set does not resend', async () => {
|
||||||
const program = await createTestProgram({ name: `cron-rmd-idem-${uid()}` })
|
const program = await createTestProgram({ name: `cron-rmd-idem-${uid()}` })
|
||||||
programIds.push(program.id)
|
programIds.push(program.id)
|
||||||
|
|||||||
200
tests/unit/lunch-external-pick.test.ts
Normal file
200
tests/unit/lunch-external-pick.test.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { prisma, createCaller } from '../setup'
|
||||||
|
import { createTestUser, createTestProgram, cleanupTestData, uid } from '../helpers'
|
||||||
|
|
||||||
|
vi.mock('@/lib/email', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/email')>('@/lib/email')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
sendExternalDishInviteEmail: vi.fn(async () => undefined),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import { lunchRouter } from '@/server/routers/lunch'
|
||||||
|
import { signExternalLunchToken } from '@/lib/external-lunch-token'
|
||||||
|
|
||||||
|
const programIds: string[] = []
|
||||||
|
const userIds: string[] = []
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.NEXTAUTH_SECRET = 'test-secret-for-external-lunch-tokens'
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const programId of programIds) {
|
||||||
|
await prisma.externalAttendee.deleteMany({ where: { lunchEvent: { programId } } })
|
||||||
|
await prisma.dish.deleteMany({ where: { lunchEvent: { programId } } })
|
||||||
|
await prisma.lunchEvent.deleteMany({ where: { programId } })
|
||||||
|
await cleanupTestData(programId, [])
|
||||||
|
}
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
await prisma.auditLog.deleteMany({ where: { userId: { in: userIds } } })
|
||||||
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function newAdminCaller() {
|
||||||
|
const admin = await createTestUser('SUPER_ADMIN')
|
||||||
|
userIds.push(admin.id)
|
||||||
|
return createCaller(lunchRouter, { id: admin.id, email: admin.email, role: 'SUPER_ADMIN' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicCaller() {
|
||||||
|
return lunchRouter.createCaller({
|
||||||
|
session: null,
|
||||||
|
prisma,
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
userAgent: 'vitest',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupEvent(opts: { eventAt?: Date | null; changeCutoffHours?: number } = {}) {
|
||||||
|
const program = await createTestProgram({ name: `xpick-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
const event = await prisma.lunchEvent.create({
|
||||||
|
data: {
|
||||||
|
programId: program.id,
|
||||||
|
enabled: true,
|
||||||
|
eventAt: opts.eventAt ?? new Date(Date.now() + 30 * 86_400_000),
|
||||||
|
venue: 'La Terrasse',
|
||||||
|
changeCutoffHours: opts.changeCutoffHours ?? 48,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const dish = await prisma.dish.create({
|
||||||
|
data: { lunchEventId: event.id, name: `Risotto-${uid()}`, dietaryTags: ['VEGETARIAN'] },
|
||||||
|
})
|
||||||
|
return { program, event, dish }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('lunch.createExternal auto-invite', () => {
|
||||||
|
it('sends a dish invite and stamps inviteSentAt when an email is present', async () => {
|
||||||
|
const { event } = await setupEvent()
|
||||||
|
const caller = await newAdminCaller()
|
||||||
|
const ext = await caller.createExternal({
|
||||||
|
lunchEventId: event.id,
|
||||||
|
name: 'Marine',
|
||||||
|
email: `marine-${uid()}@example.org`,
|
||||||
|
})
|
||||||
|
const { sendExternalDishInviteEmail } = await import('@/lib/email')
|
||||||
|
expect(sendExternalDishInviteEmail).toHaveBeenCalledTimes(1)
|
||||||
|
const row = await prisma.externalAttendee.findUnique({ where: { id: ext.id } })
|
||||||
|
expect(row?.inviteSentAt).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not send or stamp when no email is present', async () => {
|
||||||
|
const { event } = await setupEvent()
|
||||||
|
const caller = await newAdminCaller()
|
||||||
|
const ext = await caller.createExternal({ lunchEventId: event.id, name: 'No Email' })
|
||||||
|
const { sendExternalDishInviteEmail } = await import('@/lib/email')
|
||||||
|
expect(sendExternalDishInviteEmail).not.toHaveBeenCalled()
|
||||||
|
const row = await prisma.externalAttendee.findUnique({ where: { id: ext.id } })
|
||||||
|
expect(row?.inviteSentAt).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lunch.getExternalByToken', () => {
|
||||||
|
it('returns the external, event, and dishes for a valid token', async () => {
|
||||||
|
const { event, dish } = await setupEvent()
|
||||||
|
const ext = await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'Guest', email: `g-${uid()}@x.test` },
|
||||||
|
})
|
||||||
|
const token = signExternalLunchToken({
|
||||||
|
externalId: ext.id,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 86_400,
|
||||||
|
})
|
||||||
|
const res = await publicCaller().getExternalByToken({ token })
|
||||||
|
expect(res.external.id).toBe(ext.id)
|
||||||
|
expect(res.external.name).toBe('Guest')
|
||||||
|
expect(res.event.venue).toBe('La Terrasse')
|
||||||
|
expect(res.dishes.map((d) => d.id)).toContain(dish.id)
|
||||||
|
expect(res.changeDeadline).toBeInstanceOf(Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a tampered token', async () => {
|
||||||
|
const { event } = await setupEvent()
|
||||||
|
const ext = await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'Guest', email: `g-${uid()}@x.test` },
|
||||||
|
})
|
||||||
|
const token = signExternalLunchToken({
|
||||||
|
externalId: ext.id,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 86_400,
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
publicCaller().getExternalByToken({ token: token.slice(0, -2) + 'xx' }),
|
||||||
|
).rejects.toThrow(/signature/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lunch.setExternalPick', () => {
|
||||||
|
it('sets the dish before the deadline', async () => {
|
||||||
|
const { event, dish } = await setupEvent()
|
||||||
|
const ext = await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'Guest', email: `g-${uid()}@x.test` },
|
||||||
|
})
|
||||||
|
const token = signExternalLunchToken({
|
||||||
|
externalId: ext.id,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 86_400,
|
||||||
|
})
|
||||||
|
await publicCaller().setExternalPick({
|
||||||
|
token,
|
||||||
|
dishId: dish.id,
|
||||||
|
allergens: ['GLUTEN'],
|
||||||
|
allergenOther: 'wine',
|
||||||
|
})
|
||||||
|
const row = await prisma.externalAttendee.findUnique({ where: { id: ext.id } })
|
||||||
|
expect(row?.dishId).toBe(dish.id)
|
||||||
|
expect(row?.allergens).toEqual(['GLUTEN'])
|
||||||
|
expect(row?.allergenOther).toBe('wine')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a pick after the change deadline', async () => {
|
||||||
|
// eventAt 1h out, 24h cutoff → deadline is in the past
|
||||||
|
const { event, dish } = await setupEvent({
|
||||||
|
eventAt: new Date(Date.now() + 3_600_000),
|
||||||
|
changeCutoffHours: 24,
|
||||||
|
})
|
||||||
|
const ext = await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'Guest', email: `g-${uid()}@x.test` },
|
||||||
|
})
|
||||||
|
const token = signExternalLunchToken({
|
||||||
|
externalId: ext.id,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 86_400,
|
||||||
|
})
|
||||||
|
await expect(
|
||||||
|
publicCaller().setExternalPick({
|
||||||
|
token,
|
||||||
|
dishId: dish.id,
|
||||||
|
allergens: [],
|
||||||
|
allergenOther: null,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/deadline/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lunch.sendExternalInvite', () => {
|
||||||
|
it('sends and stamps inviteSentAt for an emailed external', async () => {
|
||||||
|
const { event } = await setupEvent()
|
||||||
|
const caller = await newAdminCaller()
|
||||||
|
const ext = await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'Guest', email: `g-${uid()}@x.test` },
|
||||||
|
})
|
||||||
|
await caller.sendExternalInvite({ externalId: ext.id })
|
||||||
|
const { sendExternalDishInviteEmail } = await import('@/lib/email')
|
||||||
|
expect(sendExternalDishInviteEmail).toHaveBeenCalledTimes(1)
|
||||||
|
const row = await prisma.externalAttendee.findUnique({ where: { id: ext.id } })
|
||||||
|
expect(row?.inviteSentAt).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects when the external has no email', async () => {
|
||||||
|
const { event } = await setupEvent()
|
||||||
|
const caller = await newAdminCaller()
|
||||||
|
const ext = await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'No Email' },
|
||||||
|
})
|
||||||
|
await expect(caller.sendExternalInvite({ externalId: ext.id })).rejects.toThrow(/email/i)
|
||||||
|
})
|
||||||
|
})
|
||||||
63
tests/unit/lunch-external-reminder-filter.test.ts
Normal file
63
tests/unit/lunch-external-reminder-filter.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* selectUnpickedExternals returns external attendees that:
|
||||||
|
* - belong to the given LunchEvent
|
||||||
|
* - have an email on file
|
||||||
|
* - have NOT picked a dish (dishId is null)
|
||||||
|
*
|
||||||
|
* Externals without an email (can't be reminded) and externals who already have
|
||||||
|
* a dish must be excluded.
|
||||||
|
*/
|
||||||
|
import { afterAll, describe, expect, it } from 'vitest'
|
||||||
|
import { prisma } from '../setup'
|
||||||
|
import { createTestProgram, cleanupTestData, uid } from '../helpers'
|
||||||
|
import { selectUnpickedExternals } from '@/server/services/lunch-reminders'
|
||||||
|
|
||||||
|
const programIds: string[] = []
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const programId of programIds) {
|
||||||
|
await prisma.externalAttendee.deleteMany({ where: { lunchEvent: { programId } } })
|
||||||
|
await prisma.dish.deleteMany({ where: { lunchEvent: { programId } } })
|
||||||
|
await prisma.lunchEvent.deleteMany({ where: { programId } })
|
||||||
|
await cleanupTestData(programId, [])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('selectUnpickedExternals', () => {
|
||||||
|
it('returns only emailed externals with no dish', async () => {
|
||||||
|
const program = await createTestProgram({ name: `xfilter-${uid()}` })
|
||||||
|
programIds.push(program.id)
|
||||||
|
|
||||||
|
const event = await prisma.lunchEvent.create({
|
||||||
|
data: { programId: program.id, enabled: true },
|
||||||
|
})
|
||||||
|
const dish = await prisma.dish.create({
|
||||||
|
data: { lunchEventId: event.id, name: `dish-${uid()}` },
|
||||||
|
})
|
||||||
|
|
||||||
|
// emailed + no dish → INCLUDED
|
||||||
|
const x1 = await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'Has email, no dish', email: `a-${uid()}@x.test` },
|
||||||
|
})
|
||||||
|
// emailed + has dish → excluded
|
||||||
|
await prisma.externalAttendee.create({
|
||||||
|
data: {
|
||||||
|
lunchEventId: event.id,
|
||||||
|
name: 'Has email, has dish',
|
||||||
|
email: `b-${uid()}@x.test`,
|
||||||
|
dishId: dish.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// no email + no dish → excluded (can't email them)
|
||||||
|
await prisma.externalAttendee.create({
|
||||||
|
data: { lunchEventId: event.id, name: 'No email', email: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await selectUnpickedExternals(prisma, { id: event.id })
|
||||||
|
|
||||||
|
const ids = result.map((x) => x.id)
|
||||||
|
expect(ids).toContain(x1.id)
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].email).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user