// Door check-in screen (admin/scanner) API. // // At the door, check-in and ticket creation are the same action, so everything // here is written for one-tap speed on a phone with unreliable venue wifi: // // GET /:eventId/door-attendees full attendee list, fetched once and searched // client-side so typing never hits the network // POST /:eventId/door-checkin the single write endpoint — checks in, settles // payment, or creates a walk-in, atomically // POST /:eventId/door-checkin/undo reverses exactly what one keyed action did // GET /:eventId/door-summary end-of-night cash-up + pre-sale/door revenue split // // Every write carries a client-generated idempotencyKey. The key is inserted in // the same transaction as the writes, so a double tap or a retry after a timeout // can never produce a second ticket, a second payment or a double check-in — the // replay returns the original response instead. import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { eq, and, inArray, sql } from 'drizzle-orm'; import { db, dbGet, dbAll, tickets, events, users, payments, idempotencyKeys, } from '../db/index.js'; import { requireAuth } from '../lib/auth.js'; import { generateId, generateTicketCode, getNow, toDbBool, toDbDate } from '../lib/utils.js'; import { runOps, insertOp, updateOp, deleteOp, type TxOp } from '../lib/txOps.js'; import { seatHolderCountQuery } from '../lib/capacity.js'; import { DOOR_PAYMENT_METHODS, DOOR_TENDERS, amountForMethod, doorReference, paymentStatusForMethod, type DoorPaymentMethod, } from '../lib/doorPayments.js'; import emailService from '../lib/email.js'; const doorRouter = new Hono(); const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const; const IDEMPOTENCY_SCOPE = 'door-checkin'; // ==================== Shared helpers ==================== const num = (v: any): number => { const n = typeof v === 'string' ? parseFloat(v) : Number(v); return Number.isFinite(n) ? n : 0; }; const iso = (v: any): string | null => { if (!v) return null; return v instanceof Date ? v.toISOString() : String(v); }; function fullName(ticket: any): string { return `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(); } /** * The row shape the door screen renders. Returned both by the preload list and * by every write, so the client can splice an updated attendee straight back * into its in-memory list without a refetch. */ function toDoorAttendee( ticket: any, opts: { price: number; groupBookingIds: Set; adminNames: Map; doorMethod?: string | null } , ) { return { ticketId: ticket.id, firstName: ticket.attendeeFirstName, lastName: ticket.attendeeLastName || null, fullName: fullName(ticket), email: ticket.attendeeEmail || null, phone: ticket.attendeePhone || null, status: ticket.status, paymentStatus: ticket.paymentStatus, isGuest: !!ticket.isGuest, checkedIn: ticket.status === 'checked_in', checkinAt: iso(ticket.checkinAt), checkedInBy: ticket.checkedInByAdminId ? opts.adminNames.get(ticket.checkedInByAdminId) || null : null, bookingId: ticket.bookingId || null, isGroupBooking: !!(ticket.bookingId && opts.groupBookingIds.has(ticket.bookingId)), amountDue: ticket.paymentStatus === 'unpaid' ? opts.price : 0, doorMethod: opts.doorMethod ?? null, qrCode: ticket.qrCode || null, createdAt: iso(ticket.createdAt), }; } async function loadEvent(eventId: string) { const event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, eventId)) ); if (!event) return null; return { ...event, price: num(event.price), capacity: Number(event.capacity), }; } /** Names of the admins/staff referenced by the given check-in rows, in one query. */ async function loadAdminNames(adminIds: string[]): Promise> { const unique = [...new Set(adminIds.filter(Boolean))]; if (unique.length === 0) return new Map(); const rows = await dbAll( (db as any) .select({ id: (users as any).id, name: (users as any).name }) .from(users) .where(inArray((users as any).id, unique)) ); return new Map(rows.map((r: any) => [r.id, r.name])); } /** Seats currently held for an event, used only to warn (never to block) at the door. */ async function seatsHeld(eventId: string): Promise { const row = await dbGet(seatHolderCountQuery(db, eventId)); return Number(row?.count || 0); } // ==================== GET /:eventId/door-attendees ==================== // One payload, fetched on load and refreshed every ~30s by the client. Cancelled // tickets are included on purpose: staff must be able to see and reactivate them. doorRouter.get('/:eventId/door-attendees', requireAuth([...STAFF_ROLES]), async (c) => { const eventId = c.req.param('eventId'); const event = await loadEvent(eventId); if (!event) return c.json({ error: 'Event not found' }, 404); const rows = await dbAll( (db as any).select().from(tickets).where(eq((tickets as any).eventId, eventId)) ); // A booking id shared by more than one ticket marks a group booking, which is // the usual reason an otherwise-confirmed attendee still shows as unpaid. const bookingCounts = new Map(); for (const t of rows) { if (t.bookingId) bookingCounts.set(t.bookingId, (bookingCounts.get(t.bookingId) || 0) + 1); } const groupBookingIds = new Set( [...bookingCounts.entries()].filter(([, n]) => n > 1).map(([id]) => id) ); const adminNames = await loadAdminNames(rows.map((t: any) => t.checkedInByAdminId)); // Door tender per ticket, so a row already settled at the door shows how. // Joined on the event rather than on a list of ticket ids: the id list would // grow with the guest list and eventually blow the statement parameter limit. const doorMethods = new Map(); const doorPayments = await dbAll( (db as any) .select({ ticketId: (payments as any).ticketId, method: (payments as any).method }) .from(payments) .innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id)) .where(and( eq((tickets as any).eventId, eventId), eq((payments as any).source, 'door') )) ); for (const p of doorPayments) if (p.method) doorMethods.set(p.ticketId, p.method); const attendees = rows .map((t: any) => toDoorAttendee(t, { price: event.price, groupBookingIds, adminNames, doorMethod: doorMethods.get(t.id) || null, })) .sort((a, b) => a.fullName.localeCompare(b.fullName, undefined, { sensitivity: 'base' })); const checkedIn = attendees.filter((a) => a.checkedIn).length; const totalActive = attendees.filter((a) => a.status === 'confirmed' || a.status === 'checked_in').length; return c.json({ event: { id: event.id, title: event.title, price: event.price, currency: event.currency, capacity: event.capacity, }, attendees, stats: { checkedIn, totalActive, capacity: event.capacity }, }); }); // ==================== POST /:eventId/door-checkin ==================== const doorCheckinSchema = z.object({ // Existing ticket to check in (and optionally settle), or… ticketId: z.string().optional(), // …a walk-in to create. Only a first name is ever required. attendee: z.object({ firstName: z.string().trim().min(1).max(255), lastName: z.string().trim().max(255).optional().or(z.literal('')), phone: z.string().trim().max(50).optional().or(z.literal('')), email: z.string().trim().email().optional().or(z.literal('')), ruc: z.string().trim().max(15).optional().or(z.literal('')), }).optional(), payment: z.object({ method: z.enum(DOOR_PAYMENT_METHODS), // Omitted means "one ticket at event price"; a multiple covers someone // paying for their whole group in one go. amount: z.number().min(0).optional(), }).optional(), // How the attendee reached this action, for the session feed. entryMethod: z.enum(['scan', 'search', 'walkin']).optional(), idempotencyKey: z.string().min(8).max(128), }).refine((d) => !!d.ticketId || !!d.attendee, { message: 'Either ticketId or attendee is required', path: ['ticketId'], }); /** Undo instructions recorded alongside each processed idempotency key. */ type UndoState = | { kind: 'created'; ticketId: string; paymentId: string; } | { kind: 'existing'; ticketId: string; prevTicket: { status: string; checkinAt: string | null; checkedInByAdminId: string | null; paymentStatus: string; isGuest: boolean }; createdPaymentId?: string; prevPayment?: { id: string; provider: string; amount: number; status: string; reference: string | null; paidAt: string | null; paidByAdminId: string | null; source: string; method: string | null; }; }; /** A replay of a key we already processed returns the original response verbatim. */ async function findProcessedKey(key: string) { return dbGet( (db as any).select().from(idempotencyKeys).where(eq((idempotencyKeys as any).key, key)) ); } doorRouter.post( '/:eventId/door-checkin', requireAuth([...STAFF_ROLES]), zValidator('json', doorCheckinSchema), async (c) => { const eventId = c.req.param('eventId'); const data = c.req.valid('json'); const adminUser = (c as any).get('user'); const existingKey = await findProcessedKey(data.idempotencyKey); if (existingKey) { return c.json({ ...JSON.parse(existingKey.result), replayed: true, undone: !!existingKey.undoneAt }); } const event = await loadEvent(eventId); if (!event) return c.json({ error: 'Event not found' }, 404); const now = getNow(); const nowIso = new Date().toISOString(); const method = data.payment?.method as DoorPaymentMethod | undefined; const requestedAmount = data.payment?.amount ?? event.price; const ops: TxOp[] = []; let undoState: UndoState; let action: 'checkin' | 'walkin'; let ticketRow: any; let paymentSummary: { id: string; method: DoorPaymentMethod; amount: number; currency: string } | null = null; let emailTicketId: string | null = null; if (data.ticketId) { // ---- Existing ticket: settle (optionally) and check in ---- const ticket = await dbGet( (db as any).select().from(tickets).where(eq((tickets as any).id, data.ticketId)) ); if (!ticket) return c.json({ error: 'Ticket not found' }, 404); if (ticket.eventId !== eventId) { return c.json({ error: 'Ticket belongs to a different event', code: 'WRONG_EVENT' }, 400); } action = 'checkin'; const prevTicket = { status: ticket.status, checkinAt: iso(ticket.checkinAt), checkedInByAdminId: ticket.checkedInByAdminId || null, paymentStatus: ticket.paymentStatus, isGuest: !!ticket.isGuest, }; const undo: UndoState = { kind: 'existing', ticketId: ticket.id, prevTicket }; const ticketUpdate: Record = {}; if (method) { const amount = amountForMethod(method, requestedAmount); const tender = DOOR_TENDERS[method]; ticketUpdate.paymentStatus = paymentStatusForMethod(method); if (method === 'guest') ticketUpdate.isGuest = toDbBool(true); const existingPayment = await dbGet( (db as any).select().from(payments).where(eq((payments as any).ticketId, ticket.id)) ); if (existingPayment) { undo.prevPayment = { id: existingPayment.id, provider: existingPayment.provider, amount: num(existingPayment.amount), status: existingPayment.status, reference: existingPayment.reference || null, paidAt: iso(existingPayment.paidAt), paidByAdminId: existingPayment.paidByAdminId || null, source: existingPayment.source || 'presale', method: existingPayment.method || null, }; ops.push(updateOp(payments, { provider: tender.provider, amount, currency: event.currency, status: 'paid', reference: doorReference(method), paidAt: now, paidByAdminId: adminUser?.id || null, source: 'door', method, updatedAt: now, }, eq((payments as any).id, existingPayment.id))); paymentSummary = { id: existingPayment.id, method, amount, currency: event.currency }; } else { const paymentId = generateId(); undo.createdPaymentId = paymentId; ops.push(insertOp(payments, { id: paymentId, ticketId: ticket.id, provider: tender.provider, amount, currency: event.currency, status: 'paid', reference: doorReference(method), paidAt: now, paidByAdminId: adminUser?.id || null, source: 'door', method, createdAt: now, updatedAt: now, })); paymentSummary = { id: paymentId, method, amount, currency: event.currency }; } } // Check in. An already-checked-in ticket keeps its original timestamp so // staff can still tell the person when they actually entered. if (ticket.status !== 'checked_in') { ticketUpdate.status = 'checked_in'; ticketUpdate.checkinAt = now; ticketUpdate.checkedInByAdminId = adminUser?.id || null; } if (Object.keys(ticketUpdate).length > 0) { ops.push(updateOp(tickets, ticketUpdate, eq((tickets as any).id, ticket.id))); } undoState = undo; ticketRow = { ...ticket, ...ticketUpdate, checkinAt: ticketUpdate.checkinAt ?? ticket.checkinAt }; } else { // ---- Walk-in: born confirmed, settled and checked in, in one write ---- const attendee = data.attendee!; action = 'walkin'; const tenderMethod: DoorPaymentMethod = method || 'cash'; const tender = DOOR_TENDERS[tenderMethod]; const amount = amountForMethod(tenderMethod, requestedAmount); const hasEmail = !!(attendee.email && attendee.email.trim()); const firstNameValue = attendee.firstName.trim(); const lastNameValue = attendee.lastName?.trim() || null; const displayName = lastNameValue ? `${firstNameValue} ${lastNameValue}` : firstNameValue; // No email is the fast path; a placeholder keeps the users.email unique // constraint satisfied without ever mailing anyone. const accountEmail = hasEmail ? attendee.email!.trim() : `${tenderMethod === 'guest' ? 'guest' : 'door'}-${generateId()}@doorentry.local`; let user = hasEmail ? await dbGet((db as any).select().from(users).where(eq((users as any).email, accountEmail))) : null; if (!user) { const userId = generateId(); user = { id: userId, email: accountEmail }; ops.push(insertOp(users, { id: userId, email: accountEmail, password: null, name: displayName, phone: attendee.phone?.trim() || null, role: 'user', languagePreference: null, isClaimed: toDbBool(false), accountStatus: 'unclaimed', emailVerified: false, createdAt: now, updatedAt: now, })); } const ticketId = generateId(); const paymentId = generateId(); const newTicket = { id: ticketId, bookingId: null, userId: user.id, eventId, attendeeFirstName: firstNameValue, attendeeLastName: lastNameValue, attendeeEmail: hasEmail ? attendee.email!.trim() : null, attendeePhone: attendee.phone?.trim() || null, attendeeRuc: attendee.ruc?.trim() || null, preferredLanguage: null, status: 'checked_in', paymentStatus: paymentStatusForMethod(tenderMethod), isGuest: toDbBool(tenderMethod === 'guest'), qrCode: generateTicketCode(), checkinAt: now, checkedInByAdminId: adminUser?.id || null, adminNote: null, createdAt: now, }; ops.push(insertOp(tickets, newTicket)); ops.push(insertOp(payments, { id: paymentId, ticketId, provider: tender.provider, amount, currency: event.currency, status: 'paid', reference: doorReference(tenderMethod), paidAt: now, paidByAdminId: adminUser?.id || null, source: 'door', method: tenderMethod, createdAt: now, updatedAt: now, })); paymentSummary = { id: paymentId, method: tenderMethod, amount, currency: event.currency }; undoState = { kind: 'created', ticketId, paymentId }; ticketRow = newTicket; // Only mail people who actually gave an address; no QR for the rest. if (hasEmail) emailTicketId = ticketId; } // Staff at the door is the authority: a full event is a warning, never a block. const held = await seatsHeld(eventId); const atCapacity = event.capacity > 0 && held >= event.capacity; const adminNames = await loadAdminNames([ticketRow.checkedInByAdminId]); const responseBody = { ok: true, action, attendee: toDoorAttendee(ticketRow, { price: event.price, groupBookingIds: new Set(ticketRow.bookingId ? [ticketRow.bookingId] : []), adminNames, doorMethod: paymentSummary?.method || null, }), payment: paymentSummary, warnings: atCapacity ? ['at_capacity'] : [], idempotencyKey: data.idempotencyKey, processedAt: nowIso, }; // The key row goes in with the writes, so two concurrent replays of the same // key cannot both commit — the loser hits the primary-key conflict below. ops.unshift(insertOp(idempotencyKeys, { key: data.idempotencyKey, scope: IDEMPOTENCY_SCOPE, result: JSON.stringify(responseBody), undoState: JSON.stringify(undoState), undoneAt: null, createdAt: now, })); try { await runOps(ops); } catch (err: any) { const replay = await findProcessedKey(data.idempotencyKey); if (replay) { return c.json({ ...JSON.parse(replay.result), replayed: true, undone: !!replay.undoneAt }); } throw err; } if (emailTicketId) { emailService.sendBookingConfirmation(emailTicketId).catch((err) => { console.error('[Email] Failed to send door walk-in confirmation:', err); }); } return c.json(responseBody, 201); } ); // ==================== POST /:eventId/door-checkin/undo ==================== // Reverses exactly what the keyed action did — nothing more. This is what makes // the door screen safe to run without a single confirm dialog. doorRouter.post( '/:eventId/door-checkin/undo', requireAuth([...STAFF_ROLES]), zValidator('json', z.object({ idempotencyKey: z.string().min(8).max(128) })), async (c) => { const { idempotencyKey } = c.req.valid('json'); const record = await findProcessedKey(idempotencyKey); if (!record) return c.json({ error: 'Nothing to undo for this action' }, 404); if (record.undoneAt) return c.json({ ok: true, alreadyUndone: true }); const undo = JSON.parse(record.undoState || 'null') as UndoState | null; if (!undo) return c.json({ error: 'This action cannot be undone' }, 400); const now = getNow(); const ops: TxOp[] = []; if (undo.kind === 'created') { // Walk-ins created here are cancelled, not deleted: the row stays as an // audit trail and can be reactivated from the same screen. ops.push(updateOp(tickets, { status: 'cancelled', checkinAt: null, checkedInByAdminId: null, }, eq((tickets as any).id, undo.ticketId))); ops.push(updateOp(payments, { status: 'cancelled', paidAt: null, updatedAt: now, }, eq((payments as any).id, undo.paymentId))); } else { ops.push(updateOp(tickets, { status: undo.prevTicket.status, checkinAt: undo.prevTicket.checkinAt ? toDbDate(undo.prevTicket.checkinAt) : null, checkedInByAdminId: undo.prevTicket.checkedInByAdminId, paymentStatus: undo.prevTicket.paymentStatus, isGuest: toDbBool(undo.prevTicket.isGuest), }, eq((tickets as any).id, undo.ticketId))); if (undo.createdPaymentId) { ops.push(deleteOp(payments, eq((payments as any).id, undo.createdPaymentId))); } else if (undo.prevPayment) { const prev = undo.prevPayment; ops.push(updateOp(payments, { provider: prev.provider, amount: prev.amount, status: prev.status, reference: prev.reference, paidAt: prev.paidAt ? toDbDate(prev.paidAt) : null, paidByAdminId: prev.paidByAdminId, source: prev.source, method: prev.method, updatedAt: now, }, eq((payments as any).id, prev.id))); } } ops.push(updateOp(idempotencyKeys, { undoneAt: now }, eq((idempotencyKeys as any).key, idempotencyKey))); await runOps(ops); return c.json({ ok: true, ticketId: undo.ticketId, reverted: undo.kind }); } ); // ==================== GET /:eventId/door-summary ==================== // End-of-night reconciliation: what was taken at the door, by tender, plus the // pre-sale/door split the event dashboard shows. doorRouter.get('/:eventId/door-summary', requireAuth([...STAFF_ROLES]), async (c) => { const eventId = c.req.param('eventId'); const event = await loadEvent(eventId); if (!event) return c.json({ error: 'Event not found' }, 404); // Door payments settled for this event, with the attendee attached so the // session feed can show who each line belongs to. const rows = await dbAll( (db as any) .select({ paymentId: (payments as any).id, ticketId: (tickets as any).id, method: (payments as any).method, amount: (payments as any).amount, paidAt: (payments as any).paidAt, firstName: (tickets as any).attendeeFirstName, lastName: (tickets as any).attendeeLastName, ticketStatus: (tickets as any).status, }) .from(payments) .innerJoin(tickets, eq((payments as any).ticketId, (tickets as any).id)) .where(and( eq((tickets as any).eventId, eventId), eq((payments as any).source, 'door'), eq((payments as any).status, 'paid') )) ); const byMethod: Record = {}; for (const m of DOOR_PAYMENT_METHODS) byMethod[m] = { count: 0, total: 0 }; let doorTotal = 0; for (const r of rows) { const key = (r.method && byMethod[r.method]) ? r.method : 'cash'; const amount = num(r.amount); byMethod[key].count += 1; byMethod[key].total += amount; doorTotal += amount; } // Pre-sale revenue keeps the dashboard's existing definition — settled tickets // at event price — minus anything that was actually taken at the door. const doorTicketIds = new Set(rows.map((r: any) => r.ticketId)); const settled = await dbAll( (db as any) .select({ id: (tickets as any).id }) .from(tickets) .where(and( eq((tickets as any).eventId, eventId), eq((tickets as any).paymentStatus, 'paid'), sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` )) ); const presaleCount = settled.filter((t: any) => !doorTicketIds.has(t.id)).length; const presaleTotal = presaleCount * event.price; return c.json({ eventId, currency: event.currency, price: event.price, door: { count: rows.length, total: doorTotal, byMethod, lines: rows .map((r: any) => ({ paymentId: r.paymentId, ticketId: r.ticketId, name: `${r.firstName} ${r.lastName || ''}`.trim(), method: r.method || 'cash', amount: num(r.amount), paidAt: iso(r.paidAt), })) .sort((a: any, b: any) => (b.paidAt || '').localeCompare(a.paidAt || '')), }, presale: { count: presaleCount, total: presaleTotal }, total: presaleTotal + doorTotal, }); }); export default doorRouter;