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:
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 } } },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: '' } }],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user