/** * Check-in routes - /checkin/:tokens * GET: Shows attendee details and check-in/check-out button * POST: Sets check-in status based on explicit check_in form field (PRG pattern) */ import { type TicketMove, ticketCount } from "#booking/ticket-moves.ts"; import { moveTicketsAndLog, ticketsWord } from "#db/attendees/door-moves.ts"; import { getPairBookings } from "#db/attendees/ticket-lines.ts"; import type { DeliveryBookingRef } from "#db/logistics.ts"; import { getAgentRunSheetBookings, runSheetBookingKey, } from "#db/logistics-run-sheet.ts"; import { settings } from "#db/settings.ts"; import { userAgents } from "#db/user-agents.ts"; import { filter, map, requiredMapValue } from "#fp"; import { type AuthSession, DOOR_FORM, getAuthenticatedSession, withAuth, } from "#routes/auth.ts"; import { authFailure } from "#routes/auth-failures.ts"; import { htmlResponse, notFoundResponse, publicPageResponse, redirectResponse, } from "#routes/response.ts"; import { createTokenRoute, decryptTokenEntries, lookupAttendees, resolveEntries, type TokenEntry, type TokenMethodHandler, } from "#routes/tickets/token-utils.ts"; import { getSearchParam } from "#routes/url.ts"; import { getEffectiveDomain } from "#shared/config.ts"; import { addDays } from "#shared/dates.ts"; import type { ResponseHandler } from "#shared/response-steps.ts"; import { requireRequestPrivateKey } from "#shared/session-private-key.ts"; import { todayInTz } from "#shared/timezone.ts"; import { parseDateStringOrThrow } from "#shared/validation/date-string.ts"; import { checkinAdminPage, checkinPublicPage } from "#templates/checkin.tsx"; import { type Attendee, isDoorRole, isStaffRole } from "#types"; const checkinPath = (tokens: string[]): string => `/checkin/${tokens.join("+")}`; /** What "Check In All" tells the door: the tickets it admitted, or that * every ticket on the token was already in. */ const admitMessage = (admitted: number, total: number): string => admitted === 0 ? `Already checked in ${ticketsWord(total)}` : `Checked in ${ticketsWord(admitted)}`; /** Decrypt entries' attendees using the current request's private key */ const decryptEntries = async (entries: TokenEntry[]): Promise => { const privateKey = await requireRequestPrivateKey(); return decryptTokenEntries(entries, privateKey); }; const agentRunSheetDates = (): string[] => { const today = parseDateStringOrThrow( todayInTz(settings.timezone), "the configured timezone's clock", ); return [today, addDays(today, 1)]; }; const entryBookingRef = (entry: TokenEntry): DeliveryBookingRef => ({ attendeeId: entry.attendee.id, listingId: entry.listing.id, }); const entryRunSheetKey = (entry: TokenEntry): string => runSheetBookingKey({ attendeeId: entry.attendee.id, date: entry.attendee.date, listingId: entry.listing.id, packageGroupId: entry.attendee.package_group_id, parentListingId: entry.parentListingId, }); const entriesVisibleToSession = async ( session: AuthSession, entries: TokenEntry[], ): Promise => { if (isDoorRole(session.adminLevel)) return entries; if (session.adminLevel !== "agent") return []; const agentIds = await userAgents.getIds(session.userId); // The agent sees the booking rows whose drop-off or collection leg is on // their run sheet for today or tomorrow — matched at row granularity, so a // multi-row attendee's row outside the run sheet never leaks alongside an // assigned sibling row. const allowedBookings = await getAgentRunSheetBookings( agentIds, agentRunSheetDates(), entries.map(entryBookingRef), ); const allowedKeys = new Set(allowedBookings.map(runSheetBookingKey)); return filter((entry: TokenEntry) => allowedKeys.has(entryRunSheetKey(entry)), )(entries); }; /** The facts a door-only login may read from a ticket row: who, which listing * and day, how many places, and the check-in state. Contact details stay * behind — the ticket itself never shows them either. Blank fields also hide * their table columns outright. */ const doorSafeEntries = (entries: TokenEntry[]): TokenEntry[] => map((entry: TokenEntry) => ({ ...entry, attendee: { ...entry.attendee, address: "", email: "", phone: "", special_instructions: "", }, }))(entries); const renderAdminCheckin = async ( request: Request, tokens: string[], entries: TokenEntry[], page: { canCheckIn: boolean; doorOnly: boolean; linkAdminPages: boolean; }, ): Promise => { const [decrypted, pairBookings] = await Promise.all([ decryptEntries(entries), getPairBookings(entries.map(entryBookingRef)), ]); const shown = page.doorOnly ? doorSafeEntries(decrypted) : decrypted; const message = getSearchParam(request, "message"); return htmlResponse( checkinAdminPage( shown, pairBookings, checkinPath(tokens), message, getEffectiveDomain(), settings.phonePrefix, page, ), ); }; /** Look up attendees by tokens and resolve to entries */ const withLookup = async ( tokens: string[], handler: ResponseHandler<[entries: TokenEntry[]]>, ): Promise => { const lookup = await lookupAttendees(tokens); if (!lookup.ok) return lookup.response; const entries = await resolveEntries(lookup.attendees); return entries.length === 0 ? notFoundResponse() : handler(entries); }; /** Handle GET /checkin/:tokens - show current status */ const handleCheckinGet: TokenMethodHandler = (request, tokens) => withLookup(tokens, async (entries) => { const session = await getAuthenticatedSession(request); if (!session) return publicPageResponse(checkinPublicPage); const visibleEntries = await entriesVisibleToSession(session, entries); // Door roles may toggle check-in; only staff may follow the attendee and // listing links into the admin, which a scanner login cannot open. A // door-only login (a scanner) reads door facts only; an agent's own // delivery rows still show contact details, which the run sheet needs. // The toggle form renders only when the POST has something it can change // — a token whose every row is refunded or no-check-in offers no action. const door = isDoorRole(session.adminLevel); const canCheckIn = door && entries.some((e) => !e.attendee.refunded && !e.listing.purchase_only); return visibleEntries.length === 0 ? authFailure("html", "forbidden") : renderAdminCheckin(request, tokens, visibleEntries, { canCheckIn, doorOnly: door && !isStaffRole(session.adminLevel), linkAdminPages: isStaffRole(session.adminLevel), }); }); /** Move the door's tickets with their activity rows. Each (person, listing) * pair logs only the tickets it really moved, so a line already in the * action's state is neither written nor logged again, and a refunded line * never moves. Answers the tickets that moved. */ const commitDoorMoves = async ( entries: readonly TokenEntry[], moves: readonly TicketMove[], checkedIn: boolean, ): Promise => { const listingNames = new Map( entries.map((e) => [e.listing.id, e.listing.name]), ); const listingName = (listingId: number): string => requiredMapValue( listingNames, listingId, `Listing ${listingId} has no entry on this token`, ); return ticketCount( await moveTicketsAndLog( checkedIn ? "admit" : "release", moves, (move) => `Attendee checked ${checkedIn ? "in" : "out"} ${ticketsWord(move.count)} for '${listingName(move.listingId)}'`, ), ); }; /** Handle POST /checkin/:tokens - set check-in status from form field */ const handleCheckinPost: TokenMethodHandler = (request, tokens) => withAuth(request, DOOR_FORM, (_session, form) => withLookup(tokens, async (entries) => { const checkedIn = form.get("check_in") === "true"; // The write needs no contact details, so the rows stay sealed. // Refunded rows are never touched, and purchase-only ("No Check-In") // listings' rows are excluded too — a package QR shared with a checkable // member must not silently mark the no-check-in member as attended. const eligibleEntries = filter( (e: TokenEntry) => !e.attendee.refunded && !e.listing.purchase_only, )(entries); if (eligibleEntries.length === 0) { return redirectResponse( `${checkinPath(tokens)}?message=${encodeURIComponent( "No tickets on this token can be checked in", )}`, ); } const moves = map((a: Attendee) => ({ attendeeId: a.id, count: a.quantity, listingId: a.listing_id, }))(eligibleEntries.map((e) => e.attendee)); const moved = await commitDoorMoves(eligibleEntries, moves, checkedIn); const message = checkedIn ? admitMessage(moved, ticketCount(moves)) : "Checked out"; return redirectResponse( `${checkinPath(tokens)}?message=${encodeURIComponent(message)}`, ); }), ); /** Route check-in requests */ export const routeCheckin = createTokenRoute("checkin", { GET: handleCheckinGet, POST: handleCheckinPost, });