src/features/checkin.ts
Rama main · réplica de lectura del repositorio original.
/**
* 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<TokenEntry[]> => {
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<TokenEntry[]> => {
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<Response> => {
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<Response> => {
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<number> => {
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,
});