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

{es ? 'Ventas en Puerta' : 'Door Sales'}

+

+ {es ? 'Cobrado por el staff en la entrada' : 'Taken by staff at the door'} +

+
+
+

{formatCurrency(summary.door.total, summary.currency)}

+
+ +
+ {DOOR_PAYMENT_METHODS.map((method) => { + const entry = summary.door.byMethod[method]; + return ( +
+

+ {es ? DOOR_METHOD_LABELS[method].es : DOOR_METHOD_LABELS[method].en} +

+

+ {method === 'guest' + ? `${entry.count}` + : formatCurrency(entry.total, summary.currency)} +

+ {method !== 'guest' && ( +

+ {entry.count} {es ? (entry.count === 1 ? 'pago' : 'pagos') : (entry.count === 1 ? 'payment' : 'payments')} +

+ )} +
+ ); + })} +
+ +
+ + {es ? 'Preventa' : 'Pre-sale'}: {formatCurrency(summary.presale.total, summary.currency)} + {' '}({summary.presale.count}) + + + {es ? 'Total' : 'Total'}: {formatCurrency(summary.total, summary.currency)} + +
+
+
+ ); +} + +export function PaymentsTab({ locale, payments, doorSummary }: PaymentsTabProps) { const { loadingPayments, hasPaymentOverrides, @@ -39,6 +111,11 @@ export function PaymentsTab({ locale, payments }: PaymentsTabProps) { ) : ( <> + {/* Door takings — reconciliation first, configuration below */} + {doorSummary && (doorSummary.door.count > 0 || doorSummary.presale.count > 0) && ( + + )} + {/* Header */}
diff --git a/frontend/src/app/admin/events/[id]/page.tsx b/frontend/src/app/admin/events/[id]/page.tsx index 61c07e9..60554db 100644 --- a/frontend/src/app/admin/events/[id]/page.tsx +++ b/frontend/src/app/admin/events/[id]/page.tsx @@ -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('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) => (
@@ -515,7 +530,7 @@ export default function AdminEventDetailPage() {

{stat.value}

-

{stat.label}

+

{('detail' in stat && stat.detail) || stat.label}

))} @@ -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) => (
@@ -555,7 +578,7 @@ export default function AdminEventDetailPage() {

{stat.value}

-

{stat.label}

+

{('detail' in stat && stat.detail) || stat.label}

))} @@ -705,7 +728,7 @@ export default function AdminEventDetailPage() { )} {activeTab === 'payments' && ( - + )}
diff --git a/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx b/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx new file mode 100644 index 0000000..0381803 --- /dev/null +++ b/frontend/src/app/admin/scanner/_components/AttendeeRow.tsx @@ -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 = { + 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 ( +
+ + + {expanded && !attendee.checkedIn && ( +
+ {isCancelled && ( +

+ + Reactivate as a walk-in — pick how they are paying. +

+ )} + +
+ )} + + {expanded && attendee.checkedIn && ( +
+

+ Already checked in + {attendee.checkinAt ? ` at ${checkinTime(attendee.checkinAt)}` : ''} + {attendee.checkedInBy ? ` by ${attendee.checkedInBy}` : ''}. +

+
+ )} +
+ ); +} diff --git a/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx b/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx new file mode 100644 index 0000000..7c849a2 --- /dev/null +++ b/frontend/src/app/admin/scanner/_components/PaymentButtons.tsx @@ -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(null); + const [customOpen, setCustomOpen] = useState(false); + const [customValue, setCustomValue] = useState(''); + const [pressTimer, setPressTimer] = useState | 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 ( +
+
+

{tender.label} — how many?

+ +
+
+ {[1, 2, 3].map((qty) => ( + + ))} + +
+ {customOpen && ( +
+ 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" + /> + +
+ )} +
+ ); + } + + return ( +
+ {TENDERS.map((tender) => ( + + ))} +
+ ); +} diff --git a/frontend/src/app/admin/scanner/_components/QRScannerOverlay.tsx b/frontend/src/app/admin/scanner/_components/QRScannerOverlay.tsx new file mode 100644 index 0000000..de71eb3 --- /dev/null +++ b/frontend/src/app/admin/scanner/_components/QRScannerOverlay.tsx @@ -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