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:
co-authored by
Claude Opus 5
parent
a0161a67d2
commit
e296e80e48
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user