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:
@@ -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`.
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user