Rebuild the Scanner page into a unified door check-in screen.

Most attendees arrive without their QR open, and taking money at the
door meant leaving the scanner for the event dashboard, where the Add
Ticket modal demanded an email and recorded no payment method. The
screen now leads with manual name search, keeps the camera one tap away
behind a fullscreen overlay, and creates and charges walk-ins inline.

Check-in and payment are one action: anything done here is born
confirmed, paid (or comp) and checked in through a single endpoint,
POST /api/events/:eventId/door-checkin. There are no confirm dialogs
anywhere, because they stall the queue; a ten-second Undo replaces
them, reversing exactly what the action changed via the undo state
recorded alongside its idempotency key. Writes fire in the background
with retries, so venue wifi never blocks the person at the door, and a
capacity limit only warns, since staff at the door are the authority.

Every write carries a client-generated idempotency key, inserted in the
same transaction as the writes it guards, so a double tap or a retry
after a timeout cannot produce a second ticket, payment or check-in.
Search runs entirely in memory over one preloaded list: names are
matched accent- and case-insensitively in both directions, per word,
prefix before substring, with a mostly-numeric query searching phone
digits so two people with the same name can be told apart.

Door money is recorded as payments.source 'door' plus payments.method
(cash, bitcoin, transfer or guest) while provider keeps its existing
value, so capacity counting, the stale-booking sweeps and the admin
payment lists are unaffected and revenue can still be split pre-sale
versus door. Bitcoin records the payment as made, on the same trust
model as cash, with no invoice generated; lib/doorPayments.ts is where
a real Lightning flow slots in later.

Also fixes the SQLite tickets DDL, which still created the pre-split
attendee_name column with NOT NULL email and phone. Only fresh
databases were affected -- existing ones were relaxed by later ALTERs
-- but on those, door walk-ins (and any other ticket) could not be
inserted at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-08-22 06:09:55 +00:00
co-authored by Claude Opus 5
parent a0161a67d2
commit e296e80e48
22 changed files with 3419 additions and 913 deletions
+51 -3
View File
@@ -133,17 +133,25 @@ async function migrate() {
`);
await (db as any).run(sql`
-- Matches db/schema.ts. The legacy attendee_name / NOT NULL email+phone
-- shape only survives in databases created before the split into
-- first/last name, where the ALTERs below relaxed it; a fresh database
-- must not recreate constraints the app no longer satisfies (door
-- walk-ins have neither an email nor a phone).
CREATE TABLE IF NOT EXISTS tickets (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
event_id TEXT NOT NULL REFERENCES events(id),
attendee_name TEXT NOT NULL,
attendee_email TEXT NOT NULL,
attendee_phone TEXT NOT NULL,
attendee_first_name TEXT NOT NULL,
attendee_last_name TEXT,
attendee_email TEXT,
attendee_phone TEXT,
attendee_ruc TEXT,
preferred_language TEXT,
status TEXT NOT NULL DEFAULT 'pending',
checkin_at TEXT,
qr_code TEXT,
admin_note TEXT,
created_at TEXT NOT NULL
)
`);
@@ -259,6 +267,25 @@ async function migrate() {
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
} catch (e) { /* column may already exist */ }
// Door check-in screen: split pre-sale vs door revenue and record the tender
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN source TEXT NOT NULL DEFAULT 'presale'`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE payments ADD COLUMN method TEXT`);
} catch (e) { /* column may already exist */ }
// Idempotency records for door check-in actions (retries / double taps)
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS idempotency_keys (
key TEXT PRIMARY KEY,
scope TEXT NOT NULL,
result TEXT NOT NULL,
undo_state TEXT,
undone_at TEXT,
created_at TEXT NOT NULL
)
`);
// Invoices table
await (db as any).run(sql`
@@ -836,6 +863,25 @@ async function migrate() {
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN lnbits_amount_sats INTEGER`);
} catch (e) { /* column may already exist */ }
// Door check-in screen: split pre-sale vs door revenue and record the tender
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'presale'`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE payments ADD COLUMN method VARCHAR(20)`);
} catch (e) { /* column may already exist */ }
// Idempotency records for door check-in actions (retries / double taps)
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS idempotency_keys (
key VARCHAR(128) PRIMARY KEY,
scope VARCHAR(64) NOT NULL,
result TEXT NOT NULL,
undo_state TEXT,
undone_at TIMESTAMP,
created_at TIMESTAMP NOT NULL
)
`);
// Invoices table
await (db as any).execute(sql`
@@ -1203,6 +1249,8 @@ async function migrate() {
`CREATE INDEX IF NOT EXISTS tickets_status_idx ON tickets(status)`,
`CREATE INDEX IF NOT EXISTS payments_ticket_id_idx ON payments(ticket_id)`,
`CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`,
`CREATE INDEX IF NOT EXISTS payments_source_idx ON payments(source)`,
`CREATE INDEX IF NOT EXISTS idempotency_keys_created_at_idx ON idempotency_keys(created_at)`,
`CREATE INDEX IF NOT EXISTS email_logs_event_id_idx ON email_logs(event_id)`,
`CREATE INDEX IF NOT EXISTS magic_link_tokens_token_idx ON magic_link_tokens(token)`,
`CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx ON auth_sessions(user_id)`,
+39
View File
@@ -138,10 +138,32 @@ export const sqlitePayments = sqliteTable('payments', {
paidByAdminId: text('paid_by_admin_id'),
adminNote: text('admin_note'), // Internal admin notes
reminderSentAt: text('reminder_sent_at'), // When payment reminder email was sent
// Where the money was taken: 'presale' (online/admin, the default) or 'door'
// (recorded by staff on the door check-in screen). Splits pre-sale vs door revenue.
source: text('source', { enum: ['presale', 'door'] }).notNull().default('presale'),
// Door tender used, for the end-of-night cash-up. Null for pre-sale payments.
// 'guest' is a zero-amount comp entry and carries no revenue.
method: text('method', { enum: ['cash', 'bitcoin', 'transfer', 'guest'] }),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
// Idempotency records for door check-in actions.
//
// The door screen fires check-ins / walk-in creations optimistically and retries
// on flaky venue wifi, so every action carries a client-generated key. The first
// request stores its response here; replays return that stored response instead
// of creating a second ticket, payment or check-in. `undoState` holds exactly
// what the action changed so the 10-second Undo can reverse it precisely.
export const sqliteIdempotencyKeys = sqliteTable('idempotency_keys', {
key: text('key').primaryKey(),
scope: text('scope').notNull(),
result: text('result').notNull(), // JSON response body of the original request
undoState: text('undo_state'), // JSON describing how to reverse the action
undoneAt: text('undone_at'),
createdAt: text('created_at').notNull(),
});
// Payment Options Configuration Table (global settings)
export const sqlitePaymentOptions = sqliteTable('payment_options', {
id: text('id').primaryKey(),
@@ -504,10 +526,26 @@ export const pgPayments = pgTable('payments', {
paidByAdminId: uuid('paid_by_admin_id'),
adminNote: pgText('admin_note'),
reminderSentAt: timestamp('reminder_sent_at'), // When payment reminder email was sent
// Where the money was taken: 'presale' (online/admin, the default) or 'door'
// (recorded by staff on the door check-in screen). Splits pre-sale vs door revenue.
source: varchar('source', { length: 20 }).notNull().default('presale'),
// Door tender used, for the end-of-night cash-up. Null for pre-sale payments.
// 'guest' is a zero-amount comp entry and carries no revenue.
method: varchar('method', { length: 20 }),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
// Idempotency records for door check-in actions (see sqliteIdempotencyKeys).
export const pgIdempotencyKeys = pgTable('idempotency_keys', {
key: varchar('key', { length: 128 }).primaryKey(),
scope: varchar('scope', { length: 64 }).notNull(),
result: pgText('result').notNull(),
undoState: pgText('undo_state'),
undoneAt: timestamp('undone_at'),
createdAt: timestamp('created_at').notNull(),
});
// Payment Options Configuration Table (global settings)
export const pgPaymentOptions = pgTable('payment_options', {
id: uuid('id').primaryKey(),
@@ -734,6 +772,7 @@ export const events = dbType === 'postgres' ? pgEvents : sqliteEvents;
export const eventSlugAliases = dbType === 'postgres' ? pgEventSlugAliases : sqliteEventSlugAliases;
export const tickets = dbType === 'postgres' ? pgTickets : sqliteTickets;
export const payments = dbType === 'postgres' ? pgPayments : sqlitePayments;
export const idempotencyKeys = dbType === 'postgres' ? pgIdempotencyKeys : sqliteIdempotencyKeys;
export const contacts = dbType === 'postgres' ? pgContacts : sqliteContacts;
export const emailSubscribers = dbType === 'postgres' ? pgEmailSubscribers : sqliteEmailSubscribers;
export const media = dbType === 'postgres' ? pgMedia : sqliteMedia;
+114
View File
@@ -12,6 +12,7 @@ import authExtRoutes from './routes/authExt.js';
import { getClientIp } from './lib/rateLimit.js';
import eventsRoutes from './routes/events.js';
import ticketsRoutes from './routes/tickets.js';
import doorRoutes from './routes/door.js';
import usersRoutes from './routes/users.js';
import contactsRoutes from './routes/contacts.js';
import paymentsRoutes from './routes/payments.js';
@@ -767,6 +768,116 @@ const openApiSpec = {
},
},
},
// ==================== Door Check-in Screen ====================
'/api/events/{eventId}/door-attendees': {
get: {
tags: ['Tickets'],
summary: 'Full attendee list for the door check-in screen',
description: 'One payload the door screen searches entirely client-side. Includes cancelled tickets so staff can see and reactivate them.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Event, attendees and check-in stats' },
404: { description: 'Event not found' },
},
},
},
'/api/events/{eventId}/door-checkin': {
post: {
tags: ['Tickets'],
summary: 'Check in, settle payment, or create a walk-in (atomic)',
description: 'Pass ticketId to check in an existing attendee, or attendee to create a walk-in born confirmed, paid and checked in. Idempotent on idempotencyKey: replays return the original response instead of writing again.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['idempotencyKey'],
properties: {
ticketId: { type: 'string' },
attendee: {
type: 'object',
required: ['firstName'],
properties: {
firstName: { type: 'string' },
lastName: { type: 'string' },
phone: { type: 'string' },
email: { type: 'string', format: 'email' },
ruc: { type: 'string' },
},
},
payment: {
type: 'object',
required: ['method'],
properties: {
method: { type: 'string', enum: ['cash', 'bitcoin', 'transfer', 'guest'] },
amount: { type: 'number', description: 'Defaults to the event price; a multiple covers a group paid in one go.' },
},
},
entryMethod: { type: 'string', enum: ['scan', 'search', 'walkin'] },
idempotencyKey: { type: 'string' },
},
},
},
},
},
responses: {
201: { description: 'Attendee checked in; warnings may contain at_capacity' },
200: { description: 'Replay of an already-processed idempotencyKey' },
400: { description: 'Ticket belongs to a different event' },
404: { description: 'Event or ticket not found' },
},
},
},
'/api/events/{eventId}/door-checkin/undo': {
post: {
tags: ['Tickets'],
summary: 'Reverse one door check-in action',
description: 'Reverts exactly what the keyed action did: restores the previous check-in and payment state, or cancels a ticket that was created at the door.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['idempotencyKey'],
properties: { idempotencyKey: { type: 'string' } },
},
},
},
},
responses: {
200: { description: 'Action reversed (or already undone)' },
404: { description: 'No action recorded for this key' },
},
},
},
'/api/events/{eventId}/door-summary': {
get: {
tags: ['Payments'],
summary: 'Door cash-up and pre-sale/door revenue split',
description: 'Totals per door tender (cash, bitcoin, transfer, guest) for end-of-night reconciliation, plus the pre-sale versus door revenue split shown on the event dashboard.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Door totals by method, door lines, and pre-sale totals' },
404: { description: 'Event not found' },
},
},
},
'/api/tickets/{id}/checkin': {
post: {
tags: ['Tickets'],
@@ -1908,6 +2019,9 @@ app.on(['POST', 'GET'], '/api/auth/*', (c) => {
);
});
app.route('/api/auth-ext', authExtRoutes);
// Door check-in screen endpoints live under /api/events/:eventId/door-*.
// Mounted first so the generic /:id routes below can never shadow them.
app.route('/api/events', doorRoutes);
app.route('/api/events', eventsRoutes);
app.route('/api/tickets', ticketsRoutes);
app.route('/api/users', usersRoutes);
+51
View File
@@ -0,0 +1,51 @@
// Door payment tenders.
//
// The door check-in screen offers four one-tap tenders. Each maps onto an
// existing payments.provider so the rest of the app (capacity, sweeps, admin
// payment lists, receipts) keeps working unchanged, while payments.method
// records which tender was actually used for the end-of-night cash-up.
//
// Bitcoin currently maps to the 'lightning' provider but records the payment as
// already made — the same trust model as cash, no invoice generated. When a real
// Lightning flow lands it slots in here: the tender keeps its name and provider,
// only the settlement path in routes/door.ts changes.
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'guest'] as const;
export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
interface DoorTender {
/** Existing payments.provider this tender is stored as. */
provider: 'cash' | 'lightning' | 'bank_transfer';
/** Human label used in payment references and toasts. */
label: string;
/** Comp tenders carry no revenue and always record a zero amount. */
isComp: boolean;
}
export const DOOR_TENDERS: Record<DoorPaymentMethod, DoorTender> = {
cash: { provider: 'cash', label: 'cash', isComp: false },
bitcoin: { provider: 'lightning', label: 'bitcoin', isComp: false },
transfer: { provider: 'bank_transfer', label: 'transfer', isComp: false },
guest: { provider: 'cash', label: 'guest', isComp: true },
};
export function isDoorPaymentMethod(value: unknown): value is DoorPaymentMethod {
return typeof value === 'string' && (DOOR_PAYMENT_METHODS as readonly string[]).includes(value);
}
/** Ticket paymentStatus a tender settles to: comps are 'comp', everything else 'paid'. */
export function paymentStatusForMethod(method: DoorPaymentMethod): 'paid' | 'comp' {
return DOOR_TENDERS[method].isComp ? 'comp' : 'paid';
}
/** Amount actually recorded: comps are always zero regardless of what was requested. */
export function amountForMethod(method: DoorPaymentMethod, requested: number): number {
return DOOR_TENDERS[method].isComp ? 0 : Math.max(0, requested);
}
export function doorReference(method: DoorPaymentMethod): string {
return DOOR_TENDERS[method].isComp
? 'Door — guest (comp)'
: `Door — paid by ${DOOR_TENDERS[method].label}`;
}
+42
View File
@@ -0,0 +1,42 @@
// Engine-neutral transactional writes.
//
// better-sqlite3 transactions take a *synchronous* callback (awaiting inside one
// silently breaks atomicity), while node-postgres takes an async one. Rather than
// fork every multi-write route into two near-identical branches, callers build a
// plain list of operations and hand it here: the business logic stays in one
// place and only the six lines below know which driver is underneath.
import { db, isSqlite } from '../db/index.js';
export type TxOp =
| { kind: 'insert'; table: any; values: any }
| { kind: 'update'; table: any; values: any; where: any }
| { kind: 'delete'; table: any; where: any };
export const insertOp = (table: any, values: any): TxOp => ({ kind: 'insert', table, values });
export const updateOp = (table: any, values: any, where: any): TxOp => ({ kind: 'update', table, values, where });
export const deleteOp = (table: any, where: any): TxOp => ({ kind: 'delete', table, where });
/** Apply every op inside a single transaction; any throw rolls back all of them. */
export async function runOps(ops: TxOp[]): Promise<void> {
if (ops.length === 0) return;
if (isSqlite()) {
(db as any).transaction((tx: any) => {
for (const op of ops) {
if (op.kind === 'insert') tx.insert(op.table).values(op.values).run();
else if (op.kind === 'update') tx.update(op.table).set(op.values).where(op.where).run();
else tx.delete(op.table).where(op.where).run();
}
});
return;
}
await (db as any).transaction(async (tx: any) => {
for (const op of ops) {
if (op.kind === 'insert') await tx.insert(op.table).values(op.values);
else if (op.kind === 'update') await tx.update(op.table).set(op.values).where(op.where);
else await tx.delete(op.table).where(op.where);
}
});
}
+388
View File
@@ -0,0 +1,388 @@
import { describe, it, expect, beforeAll, vi } from 'vitest';
import { execFileSync } from 'child_process';
import { mkdtempSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
// Env must be pinned before the db singleton is imported (dotenv never overrides).
const dir = mkdtempSync(join(tmpdir(), 'door-test-'));
const dbPath = join(dir, 'test.db');
process.env.DB_TYPE = 'sqlite';
process.env.DATABASE_URL = dbPath;
process.env.FRONTEND_URL = 'http://localhost:3002';
process.env.BETTER_AUTH_SECRET = 'door-test-secret-0123456789abcdef';
delete process.env.REDIS_URL;
const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' };
// The door endpoints are behind staff auth; the flows under test are the writes,
// not Better Auth, which has its own integration suite.
vi.mock('../lib/auth.js', () => ({
requireAuth: () => async (c: any, next: any) => {
c.set('user', STAFF);
await next();
},
getAuthUser: async () => STAFF,
}));
// Walk-ins with an email trigger a confirmation send; keep it out of the test.
vi.mock('../lib/email.js', () => ({
default: { sendBookingConfirmation: vi.fn(async () => ({ success: true })) },
}));
let app: any;
let sqlite: any;
const EVENT_ID = 'evt-door-1';
const PRICE = 60000;
/** POST helper that mirrors how the door screen calls the API. */
async function post(path: string, body: unknown) {
const res = await app.request(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json() };
}
async function get(path: string) {
const res = await app.request(path);
return { status: res.status, body: await res.json() };
}
function seedTicket(row: {
id: string;
first: string;
last?: string | null;
status: string;
paymentStatus: string;
phone?: string | null;
bookingId?: string | null;
qr?: string;
}) {
sqlite
.prepare(
`INSERT INTO tickets (id, booking_id, user_id, event_id, attendee_first_name, attendee_last_name,
attendee_email, attendee_phone, status, payment_status, is_guest, qr_code, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`
)
.run(
row.id,
row.bookingId ?? null,
'seed-user',
EVENT_ID,
row.first,
row.last ?? null,
`${row.id}@test.py`,
row.phone ?? null,
row.status,
row.paymentStatus,
row.qr ?? `QR-${row.id}`,
new Date().toISOString()
);
}
beforeAll(() => {
execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' });
return (async () => {
const { Hono } = await import('hono');
const doorRoutes = (await import('./door.js')).default;
app = new Hono();
app.route('/api/events', doorRoutes);
const Database = (await import('better-sqlite3')).default;
sqlite = new Database(dbPath);
const now = new Date().toISOString();
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at)
VALUES (?, ?, ?, 'user', 0, 'unclaimed', ?, ?)`
)
.run('seed-user', 'seed@test.py', 'Seed User', now, now);
sqlite
.prepare(
`INSERT INTO users (id, email, name, role, is_claimed, account_status, created_at, updated_at)
VALUES (?, ?, ?, 'staff', 1, 'active', ?, ?)`
)
.run(STAFF.id, 'staff@test.py', STAFF.name, now, now);
sqlite
.prepare(
`INSERT INTO events (id, title, description, start_datetime, location, price, currency, capacity, status, created_at, updated_at)
VALUES (?, 'Door Night', 'desc', ?, 'Asuncion', ?, 'PYG', 2, 'published', ?, ?)`
)
.run(EVENT_ID, now, PRICE, now, now);
seedTicket({ id: 'tkt-paid', first: 'José', last: 'Núñez', status: 'confirmed', paymentStatus: 'paid', phone: '+595 981 234 567' });
seedTicket({ id: 'tkt-unpaid', first: 'Ana', last: 'Group', status: 'confirmed', paymentStatus: 'unpaid', bookingId: 'bk-1' });
seedTicket({ id: 'tkt-unpaid-2', first: 'Beto', last: 'Group', status: 'confirmed', paymentStatus: 'unpaid', bookingId: 'bk-1' });
seedTicket({ id: 'tkt-cancelled', first: 'Carla', last: 'Gone', status: 'cancelled', paymentStatus: 'unpaid' });
})();
}, 120_000);
describe('door-attendees', () => {
it('returns everyone including cancelled, with group bookings flagged', async () => {
const { status, body } = await get(`/api/events/${EVENT_ID}/door-attendees`);
expect(status).toBe(200);
expect(body.event.price).toBe(PRICE);
expect(body.attendees).toHaveLength(4);
const cancelled = body.attendees.find((a: any) => a.ticketId === 'tkt-cancelled');
expect(cancelled.status).toBe('cancelled');
const grouped = body.attendees.find((a: any) => a.ticketId === 'tkt-unpaid');
expect(grouped.isGroupBooking).toBe(true);
expect(grouped.amountDue).toBe(PRICE);
const solo = body.attendees.find((a: any) => a.ticketId === 'tkt-paid');
expect(solo.isGroupBooking).toBe(false);
expect(solo.amountDue).toBe(0);
});
it('is sorted alphabetically so an empty search is scrollable', async () => {
const { body } = await get(`/api/events/${EVENT_ID}/door-attendees`);
const names = body.attendees.map((a: any) => a.fullName);
expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })));
});
});
describe('door-checkin: existing ticket', () => {
it('checks in a paid attendee with no payment record touched', async () => {
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-paid',
entryMethod: 'search',
idempotencyKey: 'key-paid-checkin',
});
expect(status).toBe(201);
expect(body.attendee.checkedIn).toBe(true);
expect(body.attendee.checkinAt).toBeTruthy();
expect(body.attendee.checkedInBy).toBe(STAFF.name);
expect(body.payment).toBeNull();
const row = sqlite.prepare('SELECT status, checked_in_by_admin_id FROM tickets WHERE id = ?').get('tkt-paid');
expect(row.status).toBe('checked_in');
expect(row.checked_in_by_admin_id).toBe(STAFF.id);
});
it('replays an already-processed key instead of checking in twice', async () => {
const before = sqlite.prepare('SELECT checkin_at FROM tickets WHERE id = ?').get('tkt-paid').checkin_at;
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-paid',
idempotencyKey: 'key-paid-checkin',
});
expect(status).toBe(200);
expect(body.replayed).toBe(true);
const after = sqlite.prepare('SELECT checkin_at FROM tickets WHERE id = ?').get('tkt-paid').checkin_at;
expect(after).toBe(before);
expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-paid').n).toBe(0);
});
it('settles an unpaid group-booking ticket in cash and checks in, in one call', async () => {
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-unpaid',
payment: { method: 'cash', amount: PRICE },
entryMethod: 'search',
idempotencyKey: 'key-unpaid-cash',
});
expect(status).toBe(201);
expect(body.attendee.paymentStatus).toBe('paid');
expect(body.attendee.checkedIn).toBe(true);
expect(body.payment).toMatchObject({ method: 'cash', amount: PRICE });
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-unpaid');
expect(payment.source).toBe('door');
expect(payment.method).toBe('cash');
expect(payment.provider).toBe('cash');
expect(payment.status).toBe('paid');
expect(payment.paid_by_admin_id).toBe(STAFF.id);
});
it('takes a group payment at a multiple of the ticket price', async () => {
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-unpaid-2',
payment: { method: 'transfer', amount: PRICE * 2 },
idempotencyKey: 'key-unpaid-2-transfer',
});
expect(body.payment.amount).toBe(PRICE * 2);
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-unpaid-2');
expect(payment.provider).toBe('bank_transfer');
expect(payment.method).toBe('transfer');
expect(payment.amount).toBe(PRICE * 2);
});
it('reactivates a cancelled ticket through the same payment flow', async () => {
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-cancelled',
payment: { method: 'bitcoin' },
idempotencyKey: 'key-cancelled-reactivate',
});
expect(body.attendee.status).toBe('checked_in');
expect(body.attendee.paymentStatus).toBe('paid');
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get('tkt-cancelled');
// Bitcoin is recorded as already-paid Lightning: same trust model as cash,
// no invoice generated (see lib/doorPayments.ts).
expect(payment.provider).toBe('lightning');
expect(payment.method).toBe('bitcoin');
expect(payment.amount).toBe(PRICE);
});
it('rejects a ticket from another event', async () => {
const { status, body } = await post('/api/events/other-event/door-checkin', {
ticketId: 'tkt-paid',
idempotencyKey: 'key-wrong-event',
});
expect(status).toBe(404);
expect(body.error).toMatch(/Event not found/);
});
});
describe('door-checkin: walk-ins', () => {
it('creates a cash walk-in confirmed, paid and checked in with no email', async () => {
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Walk' },
payment: { method: 'cash' },
entryMethod: 'walkin',
idempotencyKey: 'key-walkin-cash',
});
expect(status).toBe(201);
expect(body.action).toBe('walkin');
expect(body.attendee.fullName).toBe('Walk');
expect(body.attendee.checkedIn).toBe(true);
expect(body.attendee.paymentStatus).toBe('paid');
expect(body.attendee.email).toBeNull();
const ticket = sqlite.prepare('SELECT * FROM tickets WHERE id = ?').get(body.attendee.ticketId);
expect(ticket.status).toBe('checked_in');
expect(ticket.qr_code).toBeTruthy();
// A placeholder account keeps users.email unique without mailing anyone.
const account = sqlite.prepare('SELECT email FROM users WHERE id = ?').get(ticket.user_id);
expect(account.email).toMatch(/@doorentry\.local$/);
});
it('records a guest walk-in as a zero-amount comp', async () => {
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Free', lastName: 'Guest' },
payment: { method: 'guest', amount: PRICE },
entryMethod: 'walkin',
idempotencyKey: 'key-walkin-guest',
});
expect(body.attendee.paymentStatus).toBe('comp');
expect(body.attendee.isGuest).toBe(true);
expect(body.payment.amount).toBe(0);
const payment = sqlite.prepare('SELECT * FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
expect(payment.amount).toBe(0);
expect(payment.method).toBe('guest');
});
it('does not create a second ticket when the same walk-in key is retried', async () => {
const before = sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n;
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Walk' },
payment: { method: 'cash' },
idempotencyKey: 'key-walkin-cash',
});
expect(status).toBe(200);
expect(body.replayed).toBe(true);
expect(sqlite.prepare('SELECT COUNT(*) n FROM tickets').get().n).toBe(before);
});
it('warns rather than blocks once the event is over capacity', async () => {
// Capacity is 2 and several tickets already hold seats.
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Overflow' },
payment: { method: 'cash' },
idempotencyKey: 'key-walkin-overflow',
});
expect(body.ok).toBe(true);
expect(body.warnings).toContain('at_capacity');
});
});
describe('undo', () => {
it('reverts a plain check-in to its previous state', async () => {
seedTicket({ id: 'tkt-undo', first: 'Undo', last: 'Me', status: 'confirmed', paymentStatus: 'paid' });
await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-undo',
idempotencyKey: 'key-undo-checkin',
});
expect(sqlite.prepare('SELECT status FROM tickets WHERE id = ?').get('tkt-undo').status).toBe('checked_in');
const { status, body } = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, {
idempotencyKey: 'key-undo-checkin',
});
expect(status).toBe(200);
expect(body.reverted).toBe('existing');
const row = sqlite.prepare('SELECT status, checkin_at FROM tickets WHERE id = ?').get('tkt-undo');
expect(row.status).toBe('confirmed');
expect(row.checkin_at).toBeNull();
});
it('removes the payment it created and restores the unpaid balance', async () => {
seedTicket({ id: 'tkt-undo-pay', first: 'Undo', last: 'Pay', status: 'confirmed', paymentStatus: 'unpaid' });
await post(`/api/events/${EVENT_ID}/door-checkin`, {
ticketId: 'tkt-undo-pay',
payment: { method: 'cash' },
idempotencyKey: 'key-undo-pay',
});
expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-undo-pay').n).toBe(1);
await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-pay' });
const row = sqlite.prepare('SELECT status, payment_status FROM tickets WHERE id = ?').get('tkt-undo-pay');
expect(row.status).toBe('confirmed');
expect(row.payment_status).toBe('unpaid');
expect(sqlite.prepare('SELECT COUNT(*) n FROM payments WHERE ticket_id = ?').get('tkt-undo-pay').n).toBe(0);
});
it('cancels a walk-in it created', async () => {
const { body } = await post(`/api/events/${EVENT_ID}/door-checkin`, {
attendee: { firstName: 'Mistake' },
payment: { method: 'cash' },
idempotencyKey: 'key-undo-walkin',
});
await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-walkin' });
const ticket = sqlite.prepare('SELECT status FROM tickets WHERE id = ?').get(body.attendee.ticketId);
expect(ticket.status).toBe('cancelled');
const payment = sqlite.prepare('SELECT status FROM payments WHERE ticket_id = ?').get(body.attendee.ticketId);
expect(payment.status).toBe('cancelled');
});
it('is safe to call twice and rejects an unknown key', async () => {
const repeat = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'key-undo-walkin' });
expect(repeat.body.alreadyUndone).toBe(true);
const unknown = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, { idempotencyKey: 'never-happened' });
expect(unknown.status).toBe(404);
});
});
describe('door-summary', () => {
it('totals door takings by tender and splits them from pre-sale', async () => {
const { status, body } = await get(`/api/events/${EVENT_ID}/door-summary`);
expect(status).toBe(200);
// Cash: tkt-unpaid + the 'Walk' and 'Overflow' walk-ins (the undone ones are
// cancelled and no longer count).
expect(body.door.byMethod.cash.count).toBe(3);
expect(body.door.byMethod.cash.total).toBe(PRICE * 3);
expect(body.door.byMethod.transfer).toEqual({ count: 1, total: PRICE * 2 });
expect(body.door.byMethod.bitcoin).toEqual({ count: 1, total: PRICE });
expect(body.door.byMethod.guest).toEqual({ count: 1, total: 0 });
expect(body.door.total).toBe(PRICE * 6);
// Settled tickets with no door payment against them: tkt-paid, plus tkt-undo,
// whose door check-in was undone and which is a pre-paid ticket again.
expect(body.presale.count).toBe(2);
expect(body.presale.total).toBe(PRICE * 2);
expect(body.total).toBe(PRICE * 8);
expect(body.door.lines.length).toBe(body.door.count);
expect(body.door.lines[0]).toHaveProperty('name');
});
});
+651
View File
@@ -0,0 +1,651 @@
// 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<string>; adminNames: Map<string, string>; 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<any>(
(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<Map<string, string>> {
const unique = [...new Set(adminIds.filter(Boolean))];
if (unique.length === 0) return new Map();
const rows = await dbAll<any>(
(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<number> {
const row = await dbGet<any>(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<any>(
(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<string, number>();
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<string, string>();
const doorPayments = await dbAll<any>(
(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<any>(
(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<any>(
(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<string, any> = {};
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<any>(
(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<any>((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<any>(
(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<string, { count: number; total: number }> = {};
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<any>(
(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;