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

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