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

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:
Matt
2026-06-05 12:04:13 +02:00
parent f2c8cc1e80
commit 8d4f0bac1e
15 changed files with 1292 additions and 4 deletions

View 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>
)
}

View File

@@ -1,7 +1,11 @@
import { NextResponse, type NextRequest } from 'next/server'
import { prisma } from '@/lib/prisma'
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
@@ -53,6 +57,19 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
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({
where: { id: event.id },
data: { reminderSentAt: new Date() },

View File

@@ -22,7 +22,8 @@ import {
DialogTitle,
DialogFooter,
} 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'
const ALLERGENS = [
@@ -90,6 +91,13 @@ export const LunchExternals = forwardRef<
onSuccess: invalidateAll,
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 =
editing?.mode === 'edit'
@@ -123,7 +131,40 @@ export const LunchExternals = forwardRef<
{e.project?.title ?? 'Standalone'}
</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">
{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
size="sm"
variant="ghost"

View File

@@ -3650,6 +3650,73 @@ ${opts.pickUrl}`
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.
* Caller passes the assembled recap payload from `buildRecapPayload`.

View 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
}

View File

@@ -1,10 +1,12 @@
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, protectedProcedure } from '../trpc'
import { router, adminProcedure, protectedProcedure, publicProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
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 { verifyExternalLunchToken } from '@/lib/external-lunch-token'
import { csvCell } from '@/lib/csv'
// ─── Shared zod schemas ──────────────────────────────────────────────────────
@@ -179,6 +181,19 @@ export const lunchRouter = router({
entityId: ext.id,
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
}),
@@ -229,6 +244,159 @@ export const lunchRouter = router({
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) ────────────────────
/**
@@ -611,6 +779,19 @@ export const lunchRouter = router({
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 }
}),

View 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() },
})
}

View File

@@ -30,3 +30,21 @@ export async function selectUnpickedAttendees(
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: '' } }],
},
})
}