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;
@@ -1,27 +1,33 @@
import { useState, useEffect } from 'react';
import toast from 'react-hot-toast';
import { eventsApi, ticketsApi, emailsApi, Event, Ticket, EmailTemplate } from '@/lib/api';
import { eventsApi, ticketsApi, emailsApi, doorApi, Event, Ticket, EmailTemplate, DoorSummary } from '@/lib/api';
/**
* Loads the core data for the admin event detail page (event, tickets, active
* email templates) and exposes a reload function used after mutations.
* email templates, door takings) and exposes a reload function used after
* mutations.
*/
export function useEventDetailData(eventId: string) {
const [loading, setLoading] = useState(true);
const [event, setEvent] = useState<Event | null>(null);
const [tickets, setTickets] = useState<Ticket[]>([]);
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
const [doorSummary, setDoorSummary] = useState<DoorSummary | null>(null);
const loadEventData = async () => {
try {
const [eventRes, ticketsRes, templatesRes] = await Promise.all([
const [eventRes, ticketsRes, templatesRes, doorRes] = await Promise.all([
eventsApi.getById(eventId),
ticketsApi.getAll({ eventId }),
emailsApi.getTemplates(),
// Door takings split pre-sale from cash/bitcoin/transfer taken on the
// night. It is supporting detail, so a failure here must not blank the page.
doorApi.summary(eventId).catch(() => null),
]);
setEvent(eventRes.event);
setTickets(ticketsRes.tickets);
setTemplates(templatesRes.templates.filter(t => t.isActive));
setDoorSummary(doorRes);
} catch (error) {
toast.error('Failed to load event data');
} finally {
@@ -33,5 +39,5 @@ export function useEventDetailData(eventId: string) {
loadEventData();
}, [eventId]);
return { loading, event, tickets, templates, loadEventData };
return { loading, event, tickets, templates, doorSummary, loadEventData };
}
@@ -1,4 +1,4 @@
import { PaymentOptionsConfig } from '@/lib/api';
import { PaymentOptionsConfig, DOOR_PAYMENT_METHODS, type DoorPaymentMethod, type DoorSummary } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import clsx from 'clsx';
@@ -12,13 +12,85 @@ import {
XCircleIcon,
} from '@heroicons/react/24/outline';
import type { PaymentOverridesController } from '../_hooks/usePaymentOverrides';
import { formatCurrency } from '../_utils/format';
interface PaymentsTabProps {
locale: string;
payments: PaymentOverridesController;
/** Takings recorded on the door check-in screen; null while loading or unavailable. */
doorSummary: DoorSummary | null;
}
export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
const DOOR_METHOD_LABELS: Record<DoorPaymentMethod, { en: string; es: string }> = {
cash: { en: 'Cash', es: 'Efectivo' },
bitcoin: { en: 'Bitcoin', es: 'Bitcoin' },
transfer: { en: 'Transfer', es: 'Transferencia' },
guest: { en: 'Guests', es: 'Invitados' },
};
/**
* End-of-night reconciliation for this event: what staff took on the door, split
* by tender, next to the pre-sale total. Guests are counted, not totalled — they
* are free and carry no revenue.
*/
function DoorTakings({ locale, summary }: { locale: string; summary: DoorSummary }) {
const es = locale === 'es';
return (
<Card>
<div className="p-4 md:p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-emerald-100 rounded-full flex items-center justify-center flex-shrink-0">
<BanknotesIcon className="w-4 h-4 text-emerald-600" />
</div>
<div>
<h4 className="font-semibold text-sm">{es ? 'Ventas en Puerta' : 'Door Sales'}</h4>
<p className="text-[10px] text-gray-500">
{es ? 'Cobrado por el staff en la entrada' : 'Taken by staff at the door'}
</p>
</div>
</div>
<p className="font-bold text-lg">{formatCurrency(summary.door.total, summary.currency)}</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 pt-3 border-t">
{DOOR_PAYMENT_METHODS.map((method) => {
const entry = summary.door.byMethod[method];
return (
<div key={method} className="bg-gray-50 rounded-lg px-3 py-2">
<p className="text-[10px] uppercase tracking-wide text-gray-500">
{es ? DOOR_METHOD_LABELS[method].es : DOOR_METHOD_LABELS[method].en}
</p>
<p className="font-bold text-sm leading-tight">
{method === 'guest'
? `${entry.count}`
: formatCurrency(entry.total, summary.currency)}
</p>
{method !== 'guest' && (
<p className="text-[10px] text-gray-500">
{entry.count} {es ? (entry.count === 1 ? 'pago' : 'pagos') : (entry.count === 1 ? 'payment' : 'payments')}
</p>
)}
</div>
);
})}
</div>
<div className="flex items-center justify-between text-xs text-gray-600 pt-3 mt-3 border-t">
<span>
{es ? 'Preventa' : 'Pre-sale'}: <strong>{formatCurrency(summary.presale.total, summary.currency)}</strong>
{' '}({summary.presale.count})
</span>
<span>
{es ? 'Total' : 'Total'}: <strong>{formatCurrency(summary.total, summary.currency)}</strong>
</span>
</div>
</div>
</Card>
);
}
export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps) {
const {
loadingPayments,
hasPaymentOverrides,
@@ -39,6 +111,11 @@ export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
</div>
) : (
<>
{/* Door takings — reconciliation first, configuration below */}
{doorSummary && (doorSummary.door.count > 0 || doorSummary.presale.count > 0) && (
<DoorTakings locale={locale} summary={doorSummary} />
)}
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
+30 -7
View File
@@ -66,7 +66,7 @@ export default function AdminEventDetailPage() {
const eventId = params.id as string;
const { locale } = useLanguage();
const { loading, event, tickets, templates, loadEventData } = useEventDetailData(eventId);
const { loading, event, tickets, templates, doorSummary, loadEventData } = useEventDetailData(eventId);
const [activeTab, setActiveTab] = useState<TabType>('overview');
// Email state
@@ -401,7 +401,14 @@ export default function AdminEventDetailPage() {
const isRevenueTicket = (t: Ticket) => (t.paymentStatus ? t.paymentStatus === 'paid' : !t.isGuest);
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(isRevenueTicket).length;
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(isRevenueTicket).length;
const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price;
// Door sales can be taken at a custom amount (someone paying for their whole
// group), so once the door summary is loaded it is the authority on the total:
// pre-sale tickets at face value plus whatever was actually taken on the night.
const presaleRevenue = doorSummary
? doorSummary.presale.total
: (paidConfirmedCount + paidCheckedInCount) * event.price;
const doorRevenue = doorSummary?.door.total ?? 0;
const revenue = presaleRevenue + doorRevenue;
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
{ key: 'overview', label: 'Overview', icon: CalendarIcon },
@@ -507,7 +514,15 @@ export default function AdminEventDetailPage() {
{ label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'bg-blue-50 text-blue-600' },
{ label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'bg-green-50 text-green-600' },
{ label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'bg-purple-50 text-purple-600' },
{ label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'bg-gray-50 text-gray-600' },
{
label: 'Revenue',
value: formatCurrency(revenue, event.currency),
icon: CurrencyDollarIcon,
color: 'bg-gray-50 text-gray-600',
detail: doorSummary
? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}`
: undefined,
},
].map((stat) => (
<div key={stat.label} className="flex items-center gap-2.5 bg-white rounded-card shadow-card px-3 py-2.5">
<div className={clsx('w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
@@ -515,7 +530,7 @@ export default function AdminEventDetailPage() {
</div>
<div className="min-w-0">
<p className="text-lg font-bold leading-tight truncate">{stat.value}</p>
<p className="text-xs text-gray-500">{stat.label}</p>
<p className="text-xs text-gray-500 truncate">{('detail' in stat && stat.detail) || stat.label}</p>
</div>
</div>
))}
@@ -547,7 +562,15 @@ export default function AdminEventDetailPage() {
{ label: 'Capacity', value: `${confirmedCount + checkedInCount}/${event.capacity}`, icon: UsersIcon, color: 'text-blue-600 bg-blue-50' },
{ label: 'Confirmed', value: confirmedCount, icon: CheckCircleIcon, color: 'text-green-600 bg-green-50' },
{ label: 'Checked In', value: checkedInCount, icon: TicketIcon, color: 'text-purple-600 bg-purple-50' },
{ label: 'Revenue', value: formatCurrency(revenue, event.currency), icon: CurrencyDollarIcon, color: 'text-gray-600 bg-gray-50' },
{
label: 'Revenue',
value: formatCurrency(revenue, event.currency),
icon: CurrencyDollarIcon,
color: 'text-gray-600 bg-gray-50',
detail: doorSummary
? `Pre-sale ${formatCurrency(presaleRevenue, event.currency)} · Door ${formatCurrency(doorRevenue, event.currency)}`
: undefined,
},
].map((stat) => (
<div key={stat.label} className="flex items-center gap-2 bg-white rounded-card shadow-card px-3 py-2">
<div className={clsx('w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0', stat.color)}>
@@ -555,7 +578,7 @@ export default function AdminEventDetailPage() {
</div>
<div className="min-w-0">
<p className="text-base font-bold leading-tight truncate">{stat.value}</p>
<p className="text-[10px] text-gray-500">{stat.label}</p>
<p className="text-[10px] text-gray-500 truncate">{('detail' in stat && stat.detail) || stat.label}</p>
</div>
</div>
))}
@@ -705,7 +728,7 @@ export default function AdminEventDetailPage() {
)}
{activeTab === 'payments' && (
<PaymentsTab locale={locale} payments={payments} />
<PaymentsTab locale={locale} payments={payments} doorSummary={doorSummary} />
)}
</div>
@@ -0,0 +1,159 @@
'use client';
import clsx from 'clsx';
import {
CheckCircleIcon,
ArrowUturnLeftIcon,
UserGroupIcon,
} from '@heroicons/react/24/outline';
import type { DoorAttendee, DoorPaymentMethod } from '@/lib/api';
import { formatCurrency, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import { PaymentButtons } from './PaymentButtons';
function checkinTime(checkinAt: string | null): string {
if (!checkinAt) return '';
return parseDate(checkinAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
timeZone: EVENT_TIMEZONE,
});
}
const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
cash: 'cash',
bitcoin: 'bitcoin',
transfer: 'transfer',
guest: 'guest',
};
/** The second line of a row: everything staff needs to decide in one glance. */
function statusLine(attendee: DoorAttendee, currency: string): string {
if (attendee.status === 'cancelled') return 'Cancelled';
if (attendee.checkedIn) {
const time = checkinTime(attendee.checkinAt);
const how = attendee.doorMethod ? ` · paid ${METHOD_LABELS[attendee.doorMethod]}` : '';
return time ? `Checked in ${time}${how}` : `Checked in${how}`;
}
const parts: string[] = [];
if (attendee.paymentStatus === 'comp') parts.push('Guest');
else if (attendee.paymentStatus === 'paid') parts.push('Paid');
else parts.push(`Unpaid · ${formatCurrency(attendee.amountDue, currency)} due`);
if (attendee.isGroupBooking) parts.push('group booking');
if (attendee.status === 'pending') parts.push('pending');
return parts.join(' · ');
}
export function AttendeeRow({
attendee,
currency,
price,
expanded,
flashing,
busy,
onTap,
onPay,
}: {
attendee: DoorAttendee;
currency: string;
price: number;
expanded: boolean;
flashing: boolean;
busy: boolean;
onTap: () => void;
onPay: (method: DoorPaymentMethod, amount: number) => void;
}) {
const isCancelled = attendee.status === 'cancelled';
const settled = attendee.paymentStatus === 'paid' || attendee.paymentStatus === 'comp';
// A settled, not-yet-arrived attendee is the one-tap case: the whole row checks
// them in. Everyone else opens the tenders inline instead.
const isOneTap = !isCancelled && !attendee.checkedIn && settled;
return (
<div
className={clsx(
'rounded-2xl border transition-colors',
flashing
? 'bg-emerald-600 border-emerald-400'
: attendee.checkedIn || isCancelled
? 'bg-gray-900 border-gray-800'
: 'bg-gray-800 border-gray-700',
)}
>
<button
onClick={onTap}
disabled={busy}
className="w-full text-left px-4 py-3 min-h-[64px] flex items-center gap-3 active:scale-[0.99] transition-transform disabled:opacity-60"
>
<div className="flex-1 min-w-0">
<p
className={clsx(
'font-bold text-lg truncate',
flashing ? 'text-white' : attendee.checkedIn || isCancelled ? 'text-gray-400' : 'text-white',
)}
>
{attendee.fullName}
</p>
<p
className={clsx(
'text-sm truncate flex items-center gap-1.5',
flashing
? 'text-emerald-50'
: isCancelled
? 'text-red-400'
: attendee.checkedIn
? 'text-gray-500'
: attendee.paymentStatus === 'unpaid'
? 'text-amber-400'
: 'text-gray-400',
)}
>
{attendee.isGroupBooking && !attendee.checkedIn && <UserGroupIcon className="w-4 h-4 flex-shrink-0" />}
{statusLine(attendee, currency)}
</p>
</div>
{flashing ? (
<CheckCircleIcon className="w-8 h-8 text-white flex-shrink-0" />
) : attendee.checkedIn ? (
<CheckCircleIcon className="w-7 h-7 text-emerald-500/60 flex-shrink-0" />
) : isCancelled ? (
<span className="flex-shrink-0 text-[10px] font-bold uppercase tracking-wide px-2 py-1 rounded-full bg-red-950 text-red-400">
Cancelled
</span>
) : isOneTap ? (
<span className="flex-shrink-0 text-xs font-bold uppercase tracking-wide text-primary-yellow">
Check in
</span>
) : (
<span className="flex-shrink-0 text-xs font-bold uppercase tracking-wide text-amber-400">
Collect
</span>
)}
</button>
{expanded && !attendee.checkedIn && (
<div className="px-3 pb-3 pt-1 space-y-2">
{isCancelled && (
<p className="text-xs text-gray-400 px-1 flex items-center gap-1.5">
<ArrowUturnLeftIcon className="w-4 h-4" />
Reactivate as a walk-in pick how they are paying.
</p>
)}
<PaymentButtons price={price} currency={currency} onPay={onPay} disabled={busy} />
</div>
)}
{expanded && attendee.checkedIn && (
<div className="px-4 pb-3 -mt-1">
<p className="text-sm text-gray-400">
Already checked in
{attendee.checkinAt ? ` at ${checkinTime(attendee.checkinAt)}` : ''}
{attendee.checkedInBy ? ` by ${attendee.checkedInBy}` : ''}.
</p>
</div>
)}
</div>
);
}
@@ -0,0 +1,185 @@
'use client';
import { useState } from 'react';
import clsx from 'clsx';
import {
BanknotesIcon,
BoltIcon,
BuildingLibraryIcon,
GiftIcon,
ChevronDownIcon,
} from '@heroicons/react/24/outline';
import type { DoorPaymentMethod } from '@/lib/api';
import { formatCurrency } from '@/lib/utils';
// The four tenders staff can take at the door. One tap settles and checks in;
// long-press (or the chevron) opens multiples for someone paying for their group.
const TENDERS: {
method: DoorPaymentMethod;
label: string;
icon: typeof BanknotesIcon;
className: string;
}[] = [
{ method: 'cash', label: 'Cash', icon: BanknotesIcon, className: 'bg-emerald-600 active:bg-emerald-700' },
{ method: 'bitcoin', label: 'Bitcoin', icon: BoltIcon, className: 'bg-orange-500 active:bg-orange-600' },
{ method: 'transfer', label: 'Transfer', icon: BuildingLibraryIcon, className: 'bg-blue-600 active:bg-blue-700' },
{ method: 'guest', label: 'Guest', icon: GiftIcon, className: 'bg-gray-600 active:bg-gray-700' },
];
const LONG_PRESS_MS = 450;
export function PaymentButtons({
price,
currency,
onPay,
disabled,
}: {
price: number;
currency: string;
onPay: (method: DoorPaymentMethod, amount: number) => void;
disabled?: boolean;
}) {
// Which tender has its quick-amounts open. Guest is always free, so it never opens one.
const [amountsFor, setAmountsFor] = useState<DoorPaymentMethod | null>(null);
const [customOpen, setCustomOpen] = useState(false);
const [customValue, setCustomValue] = useState('');
const [pressTimer, setPressTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
const [longPressed, setLongPressed] = useState(false);
const openAmounts = (method: DoorPaymentMethod) => {
if (method === 'guest') return;
setAmountsFor(method);
setCustomOpen(false);
setCustomValue('');
};
const startPress = (method: DoorPaymentMethod) => {
setLongPressed(false);
const timer = setTimeout(() => {
setLongPressed(true);
openAmounts(method);
}, LONG_PRESS_MS);
setPressTimer(timer);
};
const endPress = (method: DoorPaymentMethod) => {
if (pressTimer) clearTimeout(pressTimer);
setPressTimer(null);
// A long press already opened the multiples; don't also charge 1x on release.
if (longPressed) {
setLongPressed(false);
return;
}
if (disabled) return;
onPay(method, method === 'guest' ? 0 : price);
};
const cancelPress = () => {
if (pressTimer) clearTimeout(pressTimer);
setPressTimer(null);
setLongPressed(false);
};
if (amountsFor) {
const tender = TENDERS.find((t) => t.method === amountsFor)!;
return (
<div className="space-y-2">
<div className="flex items-center justify-between px-1">
<p className="text-sm font-semibold text-white">{tender.label} how many?</p>
<button
onClick={() => { setAmountsFor(null); setCustomOpen(false); }}
className="text-sm text-gray-400 min-h-[48px] px-2 active:text-white"
>
Back
</button>
</div>
<div className="grid grid-cols-4 gap-2">
{[1, 2, 3].map((qty) => (
<button
key={qty}
disabled={disabled}
onClick={() => onPay(tender.method, price * qty)}
className={clsx(
'min-h-[56px] rounded-2xl font-bold text-white text-lg flex flex-col items-center justify-center leading-tight disabled:opacity-50 active:scale-[0.97] transition-transform',
tender.className,
)}
>
{qty}x
<span className="text-[10px] font-medium opacity-80">
{formatCurrency(price * qty, currency)}
</span>
</button>
))}
<button
disabled={disabled}
onClick={() => setCustomOpen((open) => !open)}
className="min-h-[56px] rounded-2xl font-bold text-white text-sm bg-gray-700 active:bg-gray-600 disabled:opacity-50 active:scale-[0.97] transition-transform"
>
Custom
</button>
</div>
{customOpen && (
<div className="flex gap-2">
<input
type="number"
inputMode="numeric"
autoFocus
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
placeholder={`Amount in ${currency}`}
className="flex-1 min-h-[48px] px-4 bg-gray-800 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
<button
disabled={disabled || !customValue || Number(customValue) < 0}
onClick={() => onPay(tender.method, Number(customValue))}
className={clsx(
'min-h-[48px] px-5 rounded-xl font-bold text-white disabled:opacity-50 active:scale-[0.97] transition-transform',
tender.className,
)}
>
Take
</button>
</div>
)}
</div>
);
}
return (
<div className="grid grid-cols-4 gap-2">
{TENDERS.map((tender) => (
<button
key={tender.method}
disabled={disabled}
onPointerDown={() => startPress(tender.method)}
onPointerUp={() => endPress(tender.method)}
onPointerLeave={cancelPress}
onPointerCancel={cancelPress}
onContextMenu={(e) => e.preventDefault()}
className={clsx(
'relative min-h-[64px] rounded-2xl text-white font-bold flex flex-col items-center justify-center gap-1 select-none disabled:opacity-50 active:scale-[0.97] transition-transform',
tender.className,
)}
>
<tender.icon className="w-6 h-6" />
<span className="text-xs">{tender.label}</span>
{tender.method !== 'guest' && (
// Visible affordance for the same thing long-press does: staff who
// never discover the hold still find the multiples.
<span
role="button"
aria-label={`${tender.label} quick amounts`}
onPointerDown={(e) => { e.stopPropagation(); cancelPress(); }}
onPointerUp={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); openAmounts(tender.method); }}
className="absolute top-0.5 right-0.5 w-7 h-7 flex items-center justify-center rounded-full text-white/70"
>
<ChevronDownIcon className="w-4 h-4" />
</span>
)}
</button>
))}
</div>
);
}
@@ -0,0 +1,179 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { QrCodeIcon, XMarkIcon, VideoCameraIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
// The camera is a fullscreen overlay opened from the search row, not a tab. It
// only exists while a scan is happening, so it never holds the camera (or the
// screen) while staff are typing a name.
/** Release any camera stream html5-qrcode left attached to a <video> element. */
function stopAllTracks() {
try {
document.querySelectorAll('video').forEach((video) => {
const stream = video.srcObject as MediaStream | null;
if (stream) {
stream.getTracks().forEach((track) => track.stop());
video.srcObject = null;
}
});
} catch {}
}
export function QRScannerOverlay({
onScan,
onClose,
}: {
onScan: (code: string) => void;
onClose: () => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const scannerRef = useRef<any>(null);
const mountedRef = useRef(true);
const elementId = useRef(`qr-scanner-${Date.now()}`);
const [facingMode, setFacingMode] = useState<'environment' | 'user'>('environment');
const [ready, setReady] = useState(false);
const destroyScanner = useCallback(async () => {
if (scannerRef.current) {
try { await scannerRef.current.stop(); } catch {}
try { scannerRef.current.clear(); } catch {}
scannerRef.current = null;
}
stopAllTracks();
}, []);
useEffect(() => {
mountedRef.current = true;
let cancelled = false;
const init = async () => {
const container = containerRef.current;
if (!container) return;
const id = elementId.current;
container.innerHTML = '';
const div = document.createElement('div');
div.id = id;
div.style.width = '100%';
div.style.height = '100%';
container.appendChild(div);
try {
const { Html5Qrcode } = await import('html5-qrcode');
if (cancelled) return;
const scanner = new Html5Qrcode(id);
scannerRef.current = scanner;
await scanner.start(
{ facingMode },
{ fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1 },
(decodedText: string) => {
if (mountedRef.current) onScan(decodedText);
},
() => {}
);
if (cancelled) {
await destroyScanner();
return;
}
// Force a layout pass: some browsers leave the video mis-sized until reflow.
requestAnimationFrame(() => {
if (container) {
container.style.display = 'none';
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
container.offsetHeight;
container.style.display = '';
}
if (mountedRef.current) setReady(true);
});
} catch (error) {
console.error('Scanner error:', error);
if (!cancelled && mountedRef.current) {
toast.error('Failed to start camera. Check permissions.');
onClose();
}
}
};
init();
return () => {
cancelled = true;
mountedRef.current = false;
destroyScanner();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [facingMode]);
// Backgrounding the browser suspends the camera; drop it and rebuild on return.
useEffect(() => {
const handleVisibility = () => {
if (document.visibilityState === 'hidden') {
destroyScanner();
} else if (document.visibilityState === 'visible' && mountedRef.current) {
setFacingMode((prev) => {
const temp = prev === 'environment' ? 'user' : 'environment';
setTimeout(() => {
if (mountedRef.current) setFacingMode(prev);
}, 100);
return temp;
});
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, [destroyScanner]);
return (
<div className="fixed inset-0 z-50 bg-black flex flex-col">
<div className="flex-shrink-0 flex items-center justify-between px-4 py-3 safe-area-top">
<p className="text-white font-semibold">Scan ticket</p>
<div className="flex items-center gap-2">
{ready && (
<button
onClick={() => setFacingMode((prev) => (prev === 'environment' ? 'user' : 'environment'))}
className="min-w-[48px] min-h-[48px] flex items-center justify-center bg-white/10 text-white rounded-full active:scale-95 transition-transform"
aria-label="Switch camera"
>
<VideoCameraIcon className="w-6 h-6" />
</button>
)}
<button
onClick={onClose}
className="min-w-[48px] min-h-[48px] flex items-center justify-center bg-white/10 text-white rounded-full active:scale-95 transition-transform"
aria-label="Close scanner"
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
</div>
<div className="relative flex-1 min-h-0 overflow-hidden">
<div
ref={containerRef}
className="w-full h-full [&_video]:!object-cover [&_video]:!h-full [&_video]:!w-full"
/>
{!ready && (
<div className="absolute inset-0 flex items-center justify-center text-gray-400">
<div className="text-center">
<QrCodeIcon className="w-16 h-16 mx-auto mb-2 opacity-30" />
<p className="text-sm opacity-60">Starting camera...</p>
</div>
</div>
)}
</div>
<div className="flex-shrink-0 px-6 py-5 pb-safe">
<p className="text-center text-gray-400 text-sm">
Point at the ticket QR it checks in and closes automatically.
</p>
</div>
</div>
);
}
@@ -0,0 +1,232 @@
'use client';
import clsx from 'clsx';
import {
XMarkIcon,
ClockIcon,
QrCodeIcon,
MagnifyingGlassIcon,
UserPlusIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline';
import type { DoorPaymentMethod, DoorSummary } from '@/lib/api';
import { DOOR_PAYMENT_METHODS } from '@/lib/api';
import { formatCurrency } from '@/lib/utils';
export interface SessionEntry {
idempotencyKey: string;
ticketId: string;
name: string;
at: string;
entry: 'scan' | 'search' | 'walkin';
method: DoorPaymentMethod | null;
amount: number;
undone: boolean;
failed: boolean;
}
const ENTRY_ICONS = {
scan: QrCodeIcon,
search: MagnifyingGlassIcon,
walkin: UserPlusIcon,
};
const ENTRY_LABELS = {
scan: 'Scanned',
search: 'Search',
walkin: 'Walk-in',
};
const METHOD_LABELS: Record<DoorPaymentMethod, string> = {
cash: 'Cash',
bitcoin: 'Bitcoin',
transfer: 'Transfer',
guest: 'Guest',
};
/** Totals for the current shift, computed from this session's own entries. */
function sessionTotals(entries: SessionEntry[]) {
const totals: Record<DoorPaymentMethod, { count: number; total: number }> = {
cash: { count: 0, total: 0 },
bitcoin: { count: 0, total: 0 },
transfer: { count: 0, total: 0 },
guest: { count: 0, total: 0 },
};
let grand = 0;
for (const entry of entries) {
if (entry.undone || entry.failed || !entry.method) continue;
totals[entry.method].count += 1;
totals[entry.method].total += entry.amount;
grand += entry.amount;
}
return { totals, grand };
}
function CashUpGrid({
totals,
currency,
}: {
totals: Record<DoorPaymentMethod, { count: number; total: number }>;
currency: string;
}) {
return (
<div className="grid grid-cols-2 gap-2">
{DOOR_PAYMENT_METHODS.map((method) => (
<div key={method} className="bg-gray-800 border border-gray-700 rounded-xl px-3 py-2.5">
<p className="text-[11px] uppercase tracking-wide text-gray-500">{METHOD_LABELS[method]}</p>
<p className="font-bold text-white text-base leading-tight">
{method === 'guest' ? `${totals[method].count} free` : formatCurrency(totals[method].total, currency)}
</p>
{method !== 'guest' && (
<p className="text-[11px] text-gray-500">
{totals[method].count} {totals[method].count === 1 ? 'payment' : 'payments'}
</p>
)}
</div>
))}
</div>
);
}
/**
* The end-of-night view: what this shift took, what the whole event day took,
* and the feed of who came in and how.
*/
export function SessionSheet({
entries,
summary,
summaryLoading,
currency,
onRefresh,
onClose,
}: {
entries: SessionEntry[];
summary: DoorSummary | null;
summaryLoading: boolean;
currency: string;
onRefresh: () => void;
onClose: () => void;
}) {
const { totals, grand } = sessionTotals(entries);
const liveEntries = entries.filter((e) => !e.undone);
return (
<div className="fixed inset-0 z-50 bg-gray-950 flex flex-col" style={{ height: '100dvh' }}>
<header className="flex-shrink-0 bg-gray-900 border-b border-gray-800 px-4 py-3 safe-area-top flex items-center justify-between">
<div>
<p className="font-bold text-white text-lg">Session</p>
<p className="text-xs text-gray-500">{liveEntries.length} checked in from this device</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={onRefresh}
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
aria-label="Refresh totals"
>
<ArrowPathIcon className={clsx('w-5 h-5', summaryLoading && 'animate-spin')} />
</button>
<button
onClick={onClose}
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
aria-label="Close session view"
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
</header>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-5 pb-safe">
{/* This shift */}
<section className="space-y-2">
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-bold text-white uppercase tracking-wide">This session</h2>
<p className="text-primary-yellow font-bold">{formatCurrency(grand, currency)}</p>
</div>
<CashUpGrid totals={totals} currency={currency} />
</section>
{/* Whole event, from the server — the number to reconcile the cash box against */}
<section className="space-y-2">
<div className="flex items-baseline justify-between">
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Door total, whole event</h2>
<p className="text-primary-yellow font-bold">
{summary ? formatCurrency(summary.door.total, summary.currency) : '—'}
</p>
</div>
{summary ? (
<>
<CashUpGrid totals={summary.door.byMethod} currency={summary.currency} />
<div className="flex items-center justify-between bg-gray-800 border border-gray-700 rounded-xl px-3 py-2.5">
<div>
<p className="text-[11px] uppercase tracking-wide text-gray-500">Pre-sale</p>
<p className="font-bold text-white">
{formatCurrency(summary.presale.total, summary.currency)}
</p>
</div>
<div className="text-right">
<p className="text-[11px] uppercase tracking-wide text-gray-500">Event total</p>
<p className="font-bold text-white">{formatCurrency(summary.total, summary.currency)}</p>
</div>
</div>
</>
) : (
<p className="text-sm text-gray-500">
{summaryLoading ? 'Loading totals…' : 'Totals unavailable — pull to refresh.'}
</p>
)}
</section>
{/* Feed */}
<section className="space-y-2">
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Recent check-ins</h2>
{entries.length === 0 ? (
<div className="text-center text-gray-500 py-10">
<ClockIcon className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">No check-ins yet</p>
</div>
) : (
<div className="space-y-2">
{entries.map((entry) => {
const Icon = ENTRY_ICONS[entry.entry];
return (
<div
key={entry.idempotencyKey}
className={clsx(
'rounded-xl border px-3 py-2.5 flex items-center gap-3',
entry.failed
? 'bg-red-950/40 border-red-900'
: entry.undone
? 'bg-gray-900 border-gray-800 opacity-50'
: 'bg-gray-800 border-gray-700',
)}
>
<Icon className="w-5 h-5 text-gray-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p
className={clsx(
'font-medium truncate',
entry.undone ? 'text-gray-500 line-through' : 'text-white',
)}
>
{entry.name}
</p>
<p className="text-xs text-gray-500 truncate">
{ENTRY_LABELS[entry.entry]}
{entry.method ? ` · ${METHOD_LABELS[entry.method]}` : ''}
{entry.method && entry.method !== 'guest'
? ` ${formatCurrency(entry.amount, currency)}`
: ''}
{entry.failed ? ' · failed' : entry.undone ? ' · undone' : ''}
</p>
</div>
<p className="text-sm text-gray-400 flex-shrink-0">{entry.at}</p>
</div>
);
})}
</div>
)}
</section>
</div>
</div>
);
}
@@ -0,0 +1,157 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { UserPlusIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
import type { DoorPaymentMethod } from '@/lib/api';
import { PaymentButtons } from './PaymentButtons';
export interface WalkInDraft {
firstName: string;
lastName: string;
phone: string;
email: string;
ruc: string;
}
export const emptyWalkIn = (firstName = ''): WalkInDraft => ({
firstName,
lastName: '',
phone: '',
email: '',
ruc: '',
});
/**
* The pinned bottom row. Collapsed it is a single tap; expanded it is a first
* name and four tenders. Email, phone and RUC live behind "Add details" so the
* rare person who wants a receipt never slows down the queue behind them.
*/
export function WalkInRow({
typedText,
expanded,
draft,
price,
currency,
busy,
onExpand,
onChange,
onPay,
onCancel,
}: {
typedText: string;
expanded: boolean;
draft: WalkInDraft;
price: number;
currency: string;
busy: boolean;
onExpand: () => void;
onChange: (draft: WalkInDraft) => void;
onPay: (method: DoorPaymentMethod, amount: number) => void;
onCancel: () => void;
}) {
const [detailsOpen, setDetailsOpen] = useState(false);
const firstNameRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (expanded) firstNameRef.current?.focus();
}, [expanded]);
useEffect(() => {
if (!expanded) setDetailsOpen(false);
}, [expanded]);
if (!expanded) {
return (
<button
onClick={onExpand}
className="w-full min-h-[64px] px-4 py-3 rounded-2xl border-2 border-dashed border-primary-yellow/50 bg-primary-yellow/5 text-left flex items-center gap-3 active:scale-[0.99] transition-transform"
>
<UserPlusIcon className="w-6 h-6 text-primary-yellow flex-shrink-0" />
<span className="font-bold text-primary-yellow truncate">
{typedText ? `Add "${typedText}" as walk-in` : 'Add a walk-in'}
</span>
</button>
);
}
const field = (key: keyof WalkInDraft, value: string) => onChange({ ...draft, [key]: value });
return (
<div className="rounded-2xl border-2 border-primary-yellow/50 bg-gray-800 p-3 space-y-2">
<div className="flex items-center justify-between px-1">
<p className="font-bold text-primary-yellow">New walk-in</p>
<button onClick={onCancel} className="text-sm text-gray-400 min-h-[48px] px-2 active:text-white">
Cancel
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<input
ref={firstNameRef}
value={draft.firstName}
onChange={(e) => field('firstName', e.target.value)}
placeholder="First name"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
className="min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
<input
value={draft.lastName}
onChange={(e) => field('lastName', e.target.value)}
placeholder="Last name (optional)"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
className="min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<input
value={draft.phone}
onChange={(e) => field('phone', e.target.value)}
placeholder="Phone (optional)"
inputMode="tel"
autoComplete="off"
className="w-full min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
<button
onClick={() => setDetailsOpen((open) => !open)}
className="w-full min-h-[48px] flex items-center justify-between px-2 text-sm text-gray-400 active:text-white"
>
Add details (email, RUC)
<ChevronDownIcon className={clsx('w-4 h-4 transition-transform', detailsOpen && 'rotate-180')} />
</button>
{detailsOpen && (
<div className="space-y-2">
<input
value={draft.email}
onChange={(e) => field('email', e.target.value)}
placeholder="Email — sends the usual confirmation"
inputMode="email"
autoComplete="off"
autoCapitalize="none"
className="w-full min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
<input
value={draft.ruc}
onChange={(e) => field('ruc', e.target.value)}
placeholder="RUC (for factura)"
autoComplete="off"
className="w-full min-h-[48px] px-4 bg-gray-900 border border-gray-700 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
)}
<PaymentButtons
price={price}
currency={currency}
onPay={onPay}
disabled={busy || !draft.firstName.trim()}
/>
</div>
);
}
@@ -0,0 +1,56 @@
// Firing door actions without ever blocking the queue of people at the door.
//
// The UI flashes green and clears the input the moment staff taps; the write
// happens here, in the background, with retries. Venue wifi drops constantly, so
// every action carries an idempotency key: a retry that actually succeeded the
// first time returns the original result instead of double-charging anyone.
import { doorApi, type DoorCheckinRequest, type DoorCheckinResponse } from '@/lib/api';
/** UUID per action. crypto.randomUUID needs a secure context; fall back when absent. */
export function newIdempotencyKey(): string {
const cryptoRef = typeof crypto !== 'undefined' ? crypto : undefined;
if (cryptoRef?.randomUUID) return cryptoRef.randomUUID();
return `door-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
}
// Roughly 5 seconds of retrying in total — long enough to ride out a wifi blip,
// short enough that staff learn about a real failure while the person is still
// in front of them.
const RETRY_DELAYS_MS = [400, 1200, 3000];
/**
* Errors worth retrying are the ones a retry can fix: network failures, gateway
* errors, rate limits. A 400 "ticket belongs to a different event" will fail
* identically forever, so it surfaces immediately.
*/
function isRetryable(error: any): boolean {
const status = error?.status;
if (typeof status === 'number') return status >= 500 || status === 408 || status === 429;
// No status at all means the request never reached the server (fetch rejects
// with a TypeError when the connection drops) — exactly the case to retry.
return true;
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function submitDoorAction(
eventId: string,
body: DoorCheckinRequest,
): Promise<DoorCheckinResponse> {
let lastError: any;
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
try {
return await doorApi.checkin(eventId, body);
} catch (error: any) {
lastError = error;
if (attempt === RETRY_DELAYS_MS.length || !isRetryable(error)) break;
await sleep(RETRY_DELAYS_MS[attempt]);
}
}
throw lastError;
}
export async function undoDoorAction(eventId: string, idempotencyKey: string): Promise<void> {
await doorApi.undo(eventId, idempotencyKey);
}
@@ -0,0 +1,41 @@
// Haptic + audio confirmation. At a loud, dark door the sound and the buzz are
// what staff actually register — the green flash is confirmation for the person
// standing in front of them.
export function playSuccessSound() {
try {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
osc.type = 'sine';
gain.gain.value = 0.3;
osc.start();
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.15);
osc.stop(ctx.currentTime + 0.15);
} catch {}
}
export function playErrorSound() {
try {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 300;
osc.type = 'square';
gain.gain.value = 0.2;
osc.start();
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
osc.stop(ctx.currentTime + 0.3);
} catch {}
}
export function vibrate(pattern: number | number[]) {
try {
if (navigator.vibrate) navigator.vibrate(pattern);
} catch {}
}
@@ -0,0 +1,170 @@
// Door search: runs entirely in memory over the preloaded attendee list, so
// typing never touches the network.
//
// The rules exist because of who is standing at the door. People say "Jose" for
// José and "Nunez" for Núñez, so both sides are stripped of diacritics. They give
// a surname first as often as a first name, so every word is matched
// independently. Two people called María are told apart by the last digits of a
// phone number, so a mostly-numeric query searches phone digits instead of names.
import type { DoorAttendee } from '@/lib/api';
/** Lowercase and strip combining marks, so "José" and "jose" are the same string. */
export function normalize(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim();
}
export function digitsOnly(value: string): string {
return value.replace(/\D/g, '');
}
/** Precomputed per attendee once per list load; recomputing per keystroke is what makes search feel slow. */
export interface DoorSearchIndex {
words: string[];
full: string;
phoneDigits: string;
email: string;
}
export function buildIndex(attendee: DoorAttendee): DoorSearchIndex {
const first = normalize(attendee.firstName || '');
const last = normalize(attendee.lastName || '');
const full = `${first} ${last}`.trim();
return {
words: full.split(/\s+/).filter(Boolean),
full,
phoneDigits: digitsOnly(attendee.phone || ''),
email: normalize(attendee.email || ''),
};
}
/**
* A query is treated as a phone lookup when it is mostly digits — staff asking
* "what are the last four of your number?" type exactly that and nothing else.
*/
export function isPhoneQuery(query: string): boolean {
const compact = query.replace(/\s/g, '');
if (compact.length < 3) return false;
const digits = digitsOnly(compact);
return digits.length >= 3 && digits.length / compact.length >= 0.6;
}
/**
* Bounded Damerau-Levenshtein: true when one insert, delete, substitution or
* swap of adjacent letters apart. The swap matters — "Jhon" for John is the
* single commonest way a name gets mistyped, and plain Levenshtein scores it 2.
*/
function withinOneEdit(a: string, b: string): boolean {
const la = a.length;
const lb = b.length;
if (Math.abs(la - lb) > 1) return false;
let i = 0;
let j = 0;
let edits = 0;
while (i < la && j < lb) {
if (a[i] === b[j]) {
i++;
j++;
continue;
}
if (++edits > 1) return false;
if (la > lb) i++;
else if (lb > la) j++;
else if (a[i + 1] === b[j] && a[i] === b[j + 1]) {
// Adjacent letters swapped: consume both and count it as the one edit.
i += 2;
j += 2;
} else {
i++;
j++;
}
}
return edits + (la - i) + (lb - j) <= 1;
}
// Lower tier ranks first.
const TIER_PREFIX = 0;
const TIER_SUBSTRING = 1;
const TIER_FUZZY = 2;
/** Best match tier for one attendee, or null when the query does not match at all. */
export function matchTier(index: DoorSearchIndex, query: string, phoneMode: boolean): number | null {
if (phoneMode) {
const digits = digitsOnly(query);
if (!index.phoneDigits || !digits) return null;
// Substring, so a query of the last four digits matches +595 981 234 567.
return index.phoneDigits.includes(digits) ? TIER_PREFIX : null;
}
if (index.full.startsWith(query)) return TIER_PREFIX;
if (index.words.some((word) => word.startsWith(query))) return TIER_PREFIX;
if (index.full.includes(query)) return TIER_SUBSTRING;
// Email is a fallback, not a way staff normally searches, and a one- or
// two-letter query would match almost every address — so it needs 3 characters.
if (query.length >= 3 && index.email && index.email.includes(query)) return TIER_SUBSTRING;
// Typo tolerance is the last resort: "jhon" still finds John, but only after
// every real prefix and substring match has been listed.
if (query.length >= 4 && index.words.some((word) => withinOneEdit(word, query))) return TIER_FUZZY;
return null;
}
/** Attendees who are still waiting to come in, shown when the input is empty. */
const OPEN_STATUSES = new Set(['confirmed', 'pending', 'on_hold']);
export interface IndexedAttendee {
attendee: DoorAttendee;
index: DoorSearchIndex;
}
/**
* Sort weight for equal match quality: people still to come in first, then those
* already inside, then cancelled tickets last — a cancelled row must never sit
* above a valid one that matches just as well.
*/
function stateWeight(attendee: DoorAttendee): number {
if (attendee.status === 'cancelled') return 2;
return attendee.checkedIn ? 1 : 0;
}
function byRank(
a: { attendee: DoorAttendee; tier: number },
b: { attendee: DoorAttendee; tier: number },
): number {
if (a.tier !== b.tier) return a.tier - b.tier;
const stateDiff = stateWeight(a.attendee) - stateWeight(b.attendee);
if (stateDiff !== 0) return stateDiff;
return a.attendee.fullName.localeCompare(b.attendee.fullName, undefined, { sensitivity: 'base' });
}
export function searchAttendees(indexed: IndexedAttendee[], rawQuery: string): DoorAttendee[] {
const query = normalize(rawQuery);
// Empty input is the small-event case: everyone still to come in, alphabetical,
// so staff can scroll and tap without typing anything at all.
if (!query) {
return indexed
.filter(({ attendee }) => OPEN_STATUSES.has(attendee.status) && !attendee.checkedIn)
.map(({ attendee }) => attendee)
.sort((a, b) => a.fullName.localeCompare(b.fullName, undefined, { sensitivity: 'base' }));
}
const phoneMode = isPhoneQuery(rawQuery);
const scored: { attendee: DoorAttendee; tier: number }[] = [];
for (const entry of indexed) {
const tier = matchTier(entry.index, query, phoneMode);
if (tier !== null) scored.push({ attendee: entry.attendee, tier });
}
return scored.sort(byRank).map((s) => s.attendee);
}
/** True when the typed text already names somebody exactly — no walk-in row needed. */
export function hasExactMatch(results: DoorAttendee[], rawQuery: string): boolean {
const query = normalize(rawQuery);
if (!query) return true;
return results.some((a) => normalize(a.fullName) === query || normalize(a.firstName) === query);
}
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -31,8 +31,10 @@ export async function fetchApi<T>(
: (errorData.message || JSON.stringify(errorData) || 'Request failed');
const error = new Error(errorMessage);
// Preserve structured error info (e.g. code: 'EVENT_OVER_CAPACITY') so
// callers can react beyond the message text.
// callers can react beyond the message text. The status lets callers tell a
// retryable server/network fault from a request that will always fail.
(error as any).code = errorData.code;
(error as any).status = res.status;
(error as any).data = errorData;
throw error;
}
+112
View File
@@ -0,0 +1,112 @@
import { fetchApi } from './client';
// ─── Door check-in screen API ────────────────────────────────
// Every write is idempotent on a client-generated key so the door screen can
// fire actions optimistically and retry on flaky venue wifi without ever
// creating a duplicate ticket, payment or check-in.
export const DOOR_PAYMENT_METHODS = ['cash', 'bitcoin', 'transfer', 'guest'] as const;
export type DoorPaymentMethod = (typeof DOOR_PAYMENT_METHODS)[number];
export type DoorEntryMethod = 'scan' | 'search' | 'walkin';
export interface DoorAttendee {
ticketId: string;
firstName: string;
lastName: string | null;
fullName: string;
email: string | null;
phone: string | null;
status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in' | 'on_hold';
paymentStatus: 'paid' | 'unpaid' | 'comp';
isGuest: boolean;
checkedIn: boolean;
checkinAt: string | null;
checkedInBy: string | null;
bookingId: string | null;
isGroupBooking: boolean;
amountDue: number;
doorMethod: DoorPaymentMethod | null;
qrCode: string | null;
createdAt: string | null;
}
export interface DoorAttendeesResponse {
event: { id: string; title: string; price: number; currency: string; capacity: number };
attendees: DoorAttendee[];
stats: { checkedIn: number; totalActive: number; capacity: number };
}
export interface DoorCheckinRequest {
ticketId?: string;
attendee?: {
firstName: string;
lastName?: string;
phone?: string;
email?: string;
ruc?: string;
};
payment?: { method: DoorPaymentMethod; amount?: number };
entryMethod?: DoorEntryMethod;
idempotencyKey: string;
}
export interface DoorCheckinResponse {
ok: true;
action: 'checkin' | 'walkin';
attendee: DoorAttendee;
payment: { id: string; method: DoorPaymentMethod; amount: number; currency: string } | null;
/** 'at_capacity' — the event is full; the attendee was added anyway. */
warnings: string[];
idempotencyKey: string;
processedAt: string;
/** True when this response was replayed from an already-processed key. */
replayed?: boolean;
undone?: boolean;
}
export interface DoorMethodTotal {
count: number;
total: number;
}
export interface DoorSummary {
eventId: string;
currency: string;
price: number;
door: {
count: number;
total: number;
byMethod: Record<DoorPaymentMethod, DoorMethodTotal>;
lines: {
paymentId: string;
ticketId: string;
name: string;
method: DoorPaymentMethod;
amount: number;
paidAt: string | null;
}[];
};
presale: { count: number; total: number };
total: number;
}
export const doorApi = {
// Preloaded once per event, then searched entirely in memory.
attendees: (eventId: string) =>
fetchApi<DoorAttendeesResponse>(`/api/events/${eventId}/door-attendees`),
checkin: (eventId: string, body: DoorCheckinRequest) =>
fetchApi<DoorCheckinResponse>(`/api/events/${eventId}/door-checkin`, {
method: 'POST',
body: JSON.stringify(body),
}),
undo: (eventId: string, idempotencyKey: string) =>
fetchApi<{ ok: true; ticketId?: string; reverted?: string; alreadyUndone?: boolean }>(
`/api/events/${eventId}/door-checkin/undo`,
{ method: 'POST', body: JSON.stringify({ idempotencyKey }) }
),
summary: (eventId: string) => fetchApi<DoorSummary>(`/api/events/${eventId}/door-summary`),
};
+11
View File
@@ -4,6 +4,17 @@ export * from './types';
export { eventsApi } from './events';
export { ticketsApi } from './tickets';
export { doorApi, DOOR_PAYMENT_METHODS } from './door';
export type {
DoorAttendee,
DoorAttendeesResponse,
DoorCheckinRequest,
DoorCheckinResponse,
DoorEntryMethod,
DoorMethodTotal,
DoorPaymentMethod,
DoorSummary,
} from './door';
export { contactsApi } from './contacts';
export { usersApi } from './users';
export { paymentsApi } from './payments';