diff --git a/backend/assets/logo-spanglish.png b/backend/assets/logo-spanglish.png new file mode 100644 index 0000000..e2865c0 Binary files /dev/null and b/backend/assets/logo-spanglish.png differ diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 344fa30..a08fbfe 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -1,6 +1,6 @@ import 'dotenv/config'; -import { db, dbAll, events } from './index.js'; -import { sql, eq } from 'drizzle-orm'; +import { db, dbAll, dbGet, events, users } from './index.js'; +import { sql, eq, ne } from 'drizzle-orm'; import { uniqueSlug } from '../lib/slugify.js'; const dbType = process.env.DB_TYPE || 'sqlite'; @@ -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)`, @@ -1296,6 +1344,57 @@ async function migrate() { `); } + // ==================== users.email normalization ==================== + // Better Auth lowercases the address on every lookup and write it performs, + // but the users.email unique index is case-sensitive on both dialects. Rows + // written outside Better Auth (guest bookings, door sales, admin-added + // tickets) used to keep the address exactly as typed, so a buyer who entered + // "John@Gmail.com" was invisible to sign-in and to Google account linking: + // signing in with Google minted a SECOND user row and left their tickets + // stranded on the first. lib/utils.ts normalizeEmail() fixes new writes; this + // fixes the rows already in the table. + // + // Idempotent, and deliberately conservative: a row is only lowercased when + // nothing already occupies the lowercase address. A genuine collision means + // two user rows for the same person, each with its own tickets, invoices and + // payments — merging those is a judgement call, not a migration, so they are + // reported for manual review instead. + const lowercaseEmailsSql = ` + UPDATE users SET email = LOWER(email) + WHERE email <> LOWER(email) + AND NOT EXISTS ( + SELECT 1 FROM users u2 WHERE u2.id <> users.id AND u2.email = LOWER(users.email) + ) + `; + if (dbType === 'sqlite') { + await (db as any).run(sql.raw(lowercaseEmailsSql)); + } else { + await (db as any).execute(sql.raw(lowercaseEmailsSql)); + } + + // Whatever still differs from its own lowercase form is exactly the set the + // UPDATE refused to touch, i.e. the collisions. + const collisions = await dbAll<{ id: string; email: string }>( + (db as any) + .select({ id: (users as any).id, email: (users as any).email }) + .from(users) + .where(ne((users as any).email, sql`LOWER(${(users as any).email})`)) + ); + if (collisions.length > 0) { + console.warn( + `WARNING: ${collisions.length} users row(s) keep a mixed-case email because the ` + + `lowercase address is already taken. Sign-in and Google linking only ever reach ` + + `the lowercase row, so these need a manual merge:` + ); + for (const row of collisions) { + const canonical = row.email.toLowerCase(); + const existing = await dbGet<{ id: string }>( + (db as any).select({ id: (users as any).id }).from(users).where(eq((users as any).email, canonical)) + ); + console.warn(` ${row.id} (${row.email}) -> keeps losing to ${existing?.id} (${canonical})`); + } + } + // Backfill slugs for any events that don't have one yet (shared across DB types). // Ordered by creation so duplicate titles get deterministic -2, -3 suffixes. const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>( 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/betterAuth.integration.test.ts b/backend/src/lib/betterAuth.integration.test.ts index a976a00..afaa013 100644 --- a/backend/src/lib/betterAuth.integration.test.ts +++ b/backend/src/lib/betterAuth.integration.test.ts @@ -3,6 +3,8 @@ import { execFileSync } from 'child_process'; import { mkdtempSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; +import { exportJWK, generateKeyPair, SignJWT } from 'jose'; +import { normalizeEmail } from './utils.js'; // Environment must be pinned BEFORE the db/betterAuth singletons are imported // (dotenv never overrides pre-set values). @@ -13,7 +15,13 @@ process.env.DATABASE_URL = dbPath; process.env.FRONTEND_URL = 'http://localhost:3002'; process.env.BETTER_AUTH_SECRET = 'integration-test-secret-0123456789abcdef'; delete process.env.REDIS_URL; // memory lockout/rate-limit backends -delete process.env.GOOGLE_CLIENT_ID; + +// Google IS configured here: account linking is the whole point of the tests at +// the bottom of this file, and betterAuth.ts omits `socialProviders` entirely +// when this is unset. No real credentials are involved — the id tokens are +// signed with a throwaway keypair and Google's JWKS endpoint is stubbed below. +const GOOGLE_CLIENT_ID = 'spanglish-test.apps.googleusercontent.com'; +process.env.GOOGLE_CLIENT_ID = GOOGLE_CLIENT_ID; // Capture outgoing auth emails (magic links, password resets) const sentEmails: Array<{ to: string; subject: string; html: string }> = []; @@ -41,6 +49,85 @@ function extractToken(html: string, param = 'token'): string { return decodeURIComponent(match![1]); } +// ---- Google Identity Services stub ------------------------------------- +// verifyGoogleIdToken() checks signature, issuer, audience and max age against +// Google's published JWKS; its only network call is that JWKS fetch. Signing +// with our own key and serving our own JWKS exercises the real verification +// path without touching the network or needing OAuth credentials. +const GOOGLE_KID = 'spanglish-test-key'; +let googlePrivateKey: CryptoKey; + +async function installGoogleStub() { + const { publicKey, privateKey } = await generateKeyPair('RS256', { extractable: true }); + googlePrivateKey = privateKey as CryptoKey; + const jwk = { ...(await exportJWK(publicKey)), kid: GOOGLE_KID, alg: 'RS256', use: 'sig' }; + + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: any, init?: any) => { + const url = typeof input === 'string' ? input : (input?.url ?? String(input)); + if (url.startsWith('https://www.googleapis.com/oauth2/v3/certs')) { + return new Response(JSON.stringify({ keys: [jwk] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + } + return realFetch(input, init); + }) as typeof fetch; +} + +function googleIdToken(opts: { email: string; sub: string; name?: string; emailVerified?: boolean }) { + return new SignJWT({ + email: opts.email, + email_verified: opts.emailVerified ?? true, + name: opts.name ?? 'Google User', + picture: 'https://example.test/avatar.png', + }) + .setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID }) + .setIssuer('https://accounts.google.com') + .setAudience(GOOGLE_CLIENT_ID) + .setSubject(opts.sub) + .setIssuedAt() + .setExpirationTime('10m') + .sign(googlePrivateKey); +} + +async function signInWithGoogle( + opts: Parameters[0], + returnHeaders = false +): Promise { + const token = await googleIdToken(opts); + return auth.api.signInSocial({ + body: { provider: 'google', idToken: { token } }, + headers: new Headers(), + ...(returnHeaders ? { returnHeaders: true } : {}), + } as any); +} + +/** Insert a user the way a guest booking does (routes/tickets.ts, routes/door.ts): + * unclaimed, unverified, and with no auth_accounts row at all. */ +function insertBookingUser(id: string, email: string, name = 'Ticket Buyer') { + const now = new Date().toISOString(); + sqlite + .prepare( + `INSERT INTO users (id, email, password, name, role, is_claimed, account_status, email_verified, created_at, updated_at) + VALUES (?, ?, NULL, ?, 'user', 0, 'unclaimed', 0, ?, ?)` + ) + .run(id, email, name, now, now); + return id; +} + +function userRow(email: string) { + return sqlite + .prepare('SELECT id, email, is_claimed, account_status, email_verified FROM users WHERE email = ?') + .get(email); +} + +function googleAccountsFor(userId: string) { + return sqlite + .prepare("SELECT id, account_id FROM auth_accounts WHERE user_id = ? AND provider_id = 'google'") + .all(userId); +} + function cookieHeaders(setCookie: string | null): Headers { const sessionPart = (setCookie || '') .split(/,(?=[^ ;]+=)/) @@ -60,6 +147,7 @@ beforeAll(() => { ({ db } = await import('../db/index.js')); const Database = (await import('better-sqlite3')).default; sqlite = new Database(dbPath); + await installGoogleStub(); })(); }, 120_000); @@ -291,3 +379,141 @@ describe('Better Auth integration', () => { expect(days).toBeLessThan(7.5); }); }); + + +describe('Google sign-in and account linking', () => { + it('links Google onto a guest-booking user instead of failing with "account not linked"', async () => { + const id = insertBookingUser('booking-user-1', 'buyer@test.py'); + expect(userRow('buyer@test.py').email_verified).toBe(0); + + const res = await signInWithGoogle({ email: 'buyer@test.py', sub: 'google-sub-buyer' }); + + expect(res.user.id).toBe(id); + expect(googleAccountsFor(id)).toHaveLength(1); + expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('buyer@test.py').n).toBe(1); + }); + + it('claims the booking account so the resulting session is actually accepted', async () => { + // getAuthUser() (lib/auth.ts) rejects any session whose user is not + // 'active', so linking alone would leave the user looking logged out. + const id = insertBookingUser('booking-user-2', 'buyer2@test.py'); + + const { headers, response } = await signInWithGoogle( + { email: 'buyer2@test.py', sub: 'google-sub-buyer2' }, + true + ); + expect(response.user.id).toBe(id); + + const row = userRow('buyer2@test.py'); + expect(row.account_status).toBe('active'); + expect(row.is_claimed).toBe(1); + expect(row.email_verified).toBe(1); + + const session = await auth.api.getSession({ + headers: cookieHeaders(headers.get('set-cookie')), + }); + expect(session?.user.id).toBe(id); + expect((session?.user as any).accountStatus).toBe('active'); + }); + + it('never reactivates a suspended account through a Google link', async () => { + const now = new Date().toISOString(); + sqlite + .prepare( + `INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, banned, created_at, updated_at) + VALUES (?, ?, 'Suspended', 'user', 1, 'suspended', 1, 1, ?, ?)` + ) + .run('suspended-google', 'suspended-google@test.py', now, now); + + await expect( + signInWithGoogle({ email: 'suspended-google@test.py', sub: 'google-sub-suspended' }) + ).rejects.toThrow(); + expect(userRow('suspended-google@test.py').account_status).toBe('suspended'); + }); + + it('links Google onto an email/password account created after the Better Auth migration', async () => { + // Better Auth writes email_verified = 0 on sign-up (requireEmailVerification + // is off), which used to be enough to block linking on its own. + await auth.api.signUpEmail({ + body: { email: 'pwuser@test.py', password: 'PwUserPass1!x', name: 'Pw User' }, + }); + expect(userRow('pwuser@test.py').email_verified).toBe(0); + const id = userRow('pwuser@test.py').id; + + const res = await signInWithGoogle({ email: 'pwuser@test.py', sub: 'google-sub-pwuser' }); + + expect(res.user.id).toBe(id); + expect(googleAccountsFor(id)).toHaveLength(1); + // The credential account survives: they can still sign in with a password. + const after = await auth.api.signInEmail({ + body: { email: 'pwuser@test.py', password: 'PwUserPass1!x' }, + }); + expect(after.user.id).toBe(id); + }); + + it('creates exactly one user for a brand-new Google address and reuses it on the next sign-in', async () => { + const first = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' }); + const row = userRow('fresh@test.py'); + expect(row.id).toBe(first.user.id); + expect(row.email_verified).toBe(1); + expect(row.account_status).toBe('active'); + + const second = await signInWithGoogle({ email: 'fresh@test.py', sub: 'google-sub-fresh' }); + expect(second.user.id).toBe(first.user.id); + expect(sqlite.prepare('SELECT COUNT(*) AS n FROM users WHERE email = ?').get('fresh@test.py').n).toBe(1); + expect(googleAccountsFor(first.user.id)).toHaveLength(1); + }); + + it('rejects an id token minted for a different client id', async () => { + const token = await new SignJWT({ email: 'forged@test.py', email_verified: true, name: 'F' }) + .setProtectedHeader({ alg: 'RS256', kid: GOOGLE_KID }) + .setIssuer('https://accounts.google.com') + .setAudience('some-other-app.apps.googleusercontent.com') + .setSubject('google-sub-forged') + .setIssuedAt() + .setExpirationTime('10m') + .sign(googlePrivateKey); + + await expect( + auth.api.signInSocial({ + body: { provider: 'google', idToken: { token } }, + headers: new Headers(), + } as any) + ).rejects.toThrow(); + expect(userRow('forged@test.py')).toBeUndefined(); + }); +}); + +describe('users.email normalization', () => { + it('normalizes an address to its canonical stored form', () => { + expect(normalizeEmail(' John@Example.COM ')).toBe('john@example.com'); + }); + + it('lowercases legacy mixed-case rows on migrate, and reports collisions instead of merging', async () => { + const now = new Date().toISOString(); + const insert = (id: string, email: string, status = 'unclaimed') => + sqlite + .prepare( + `INSERT INTO users (id, email, name, role, is_claimed, account_status, email_verified, created_at, updated_at) + VALUES (?, ?, 'Legacy', 'user', 0, ?, 0, ?, ?)` + ) + .run(id, email, status, now, now); + + insert('legacy-mixed', 'John@Example.com'); + // A pair that genuinely collides: the migration must leave both alone. + insert('legacy-dup-lower', 'dup@example.com'); + insert('legacy-dup-mixed', 'Dup@Example.com'); + + // The backfill lives in migrate.ts and is idempotent, so just re-run it. + execFileSync('npx', ['tsx', 'src/db/migrate.ts'], { env: { ...process.env }, stdio: 'pipe' }); + + expect(userRow('john@example.com').id).toBe('legacy-mixed'); + expect(userRow('John@Example.com')).toBeUndefined(); + expect(userRow('Dup@Example.com').id).toBe('legacy-dup-mixed'); + expect(userRow('dup@example.com').id).toBe('legacy-dup-lower'); + + // ...and the lowercased row is now reachable by Google sign-in. + const res = await signInWithGoogle({ email: 'john@example.com', sub: 'google-sub-legacy' }); + expect(res.user.id).toBe('legacy-mixed'); + }, 120_000); +}); diff --git a/backend/src/lib/betterAuth.ts b/backend/src/lib/betterAuth.ts index df5eacf..998711e 100644 --- a/backend/src/lib/betterAuth.ts +++ b/backend/src/lib/betterAuth.ts @@ -209,6 +209,25 @@ export const auth = betterAuth({ // Google verifies email ownership, so linking by email is safe — this // matches the legacy /api/auth/google auto-link behavior. trustedProviders: ['google'], + // `trustedProviders` alone is NOT enough: better-auth ORs a second, + // independent gate — `requireLocalEmailVerified` (default true) — which + // refuses the link whenever the LOCAL users.email_verified is false. + // That is the state of every guest-booking user (routes/tickets.ts, + // routes/door.ts insert email_verified = false) and of every + // email/password signup made after the Better Auth migration, so Google + // sign-in failed for them with "account not linked". + // + // The local flag adds nothing here: the Google ID token is signature- + // verified against Google's JWKS with issuer/audience/max-age checks and + // carries its own `email_verified`, so Google — not our column — is what + // proves ownership of the address. + // + // NOTE: upstream marks this option deprecated ("the gate will become + // unconditional"). better-auth is pinned exactly at 1.6.25 in both + // workspaces, and betterAuth.integration.test.ts covers this path, so an + // upgrade that drops the option fails CI rather than silently locking + // ticket buyers out again. + requireLocalEmailVerified: false, }, }, @@ -246,6 +265,45 @@ export const auth = betterAuth({ }, }, }, + account: { + create: { + // Fires for both branches of the OAuth path: createOAuthUser (new user) + // and linkAccount (existing user), since both go through the adapter's + // createWithHooks(..., 'account'). + after: async (account) => { + if (account.providerId !== 'google') return; + // Attaching a Google account proves ownership of the address, so a + // row created during guest booking is now a real, claimed account. + // Without this, getAuthUser() (lib/auth.ts) rejects the brand-new + // session because accountStatus is still 'unclaimed' — the user gets + // a cookie and still looks logged out. Mirrors the tail of the + // magic-link claim flow in routes/authExt.ts. + // + // Scoped to 'unclaimed' in the WHERE clause so a suspended account is + // never silently reactivated by linking Google to it. + try { + await (db as any) + .update(authUsers) + // `emailVerified` is deliberately left alone: better-auth's link + // branch sets it right after this hook, but only when Google's + // id_token actually asserted email_verified. + .set({ + isClaimed: true, + accountStatus: 'active', + updatedAt: new Date(), + }) + .where( + and( + eq((authUsers as any).id, account.userId), + eq((authUsers as any).accountStatus, 'unclaimed') + ) + ); + } catch (err: any) { + console.error('[auth] Failed to claim account on Google link:', err?.message || err); + } + }, + }, + }, }, hooks: { 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/pdf.ts b/backend/src/lib/pdf.ts index b25f4c4..153afe2 100644 --- a/backend/src/lib/pdf.ts +++ b/backend/src/lib/pdf.ts @@ -1,6 +1,8 @@ // PDF Ticket Generation Service import PDFDocument from 'pdfkit'; import QRCode from 'qrcode'; +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; interface TicketData { id: string; @@ -15,235 +17,390 @@ interface TicketData { locationUrl?: string; }; timezone?: string; + /** 'en' | 'es' - drives the labels and the date/time format on the ticket */ + locale?: string; + /** Optional perk line shown under the ticket holder (falls back to the terms line) */ + note?: string; +} + +// ==================== Brand ==================== + +const COLORS = { + navy: '#002F44', + orange: '#F5821F', + cream: '#FDF8F0', + card: '#FFFFFF', + cardBorder: '#EFE6D8', + divider: '#E7DFD1', + label: '#9AA3AC', + muted: '#6B7580', + footerMuted: '#7FA3B5', +}; + +const PAGE_W = 595.28; +const PAGE_H = 841.89; +const MARGIN = 48; +const CONTENT_W = PAGE_W - MARGIN * 2; +const ACCENT_H = 10; +const FOOTER_H = 48; + +const LOGO_RATIO = 1158 / 324; + +const STRINGS = { + en: { + scan: 'SCAN AT THE ENTRANCE', + venue: 'VENUE', + holder: 'TICKET HOLDER', + terms: 'This ticket is non-transferable. One scan per entry.', + }, + es: { + scan: 'ESCANEÁ AL INGRESAR', + venue: 'LUGAR', + holder: 'TITULAR', + terms: 'Esta entrada es personal e intransferible. Un escaneo por ingreso.', + }, +} as const; + +function strings(locale?: string) { + return locale === 'es' ? STRINGS.es : STRINGS.en; } /** - * Generate a QR code as a data URL + * Locate the logo. `../../assets` resolves to backend/assets from both + * src/lib (tsx) and dist/lib (compiled), with the frontend copy as a fallback. + */ +function loadLogo(): Buffer | null { + const candidates = [ + new URL('../../assets/logo-spanglish.png', import.meta.url), + new URL('../../../frontend/public/images/logo-spanglish.png', import.meta.url), + ].map((u) => fileURLToPath(u)); + + for (const path of candidates) { + if (existsSync(path)) return readFileSync(path); + } + return null; +} + +let logoCache: Buffer | null | undefined; +function getLogo(): Buffer | null { + if (logoCache === undefined) logoCache = loadLogo(); + return logoCache; +} + +/** + * Generate a QR code as a PNG buffer */ async function generateQRCode(data: string): Promise { return QRCode.toBuffer(data, { type: 'png', - width: 200, - margin: 2, + width: 600, + margin: 1, errorCorrectionLevel: 'M', + color: { dark: '#000000', light: '#FFFFFF' }, }); } /** - * Format date for display using site timezone + * Short date + time as shown in the ticket header: + * en -> "JUL 25 · 4:30 PM" es -> "25 JUL · 16:30" */ -function formatDate(dateStr: string, timezone: string = 'America/Asuncion'): string { - const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - timeZone: timezone, - }); +function formatWhen( + startStr: string, + endStr: string | undefined, + timezone: string, + locale: string +): string { + const isEs = locale === 'es'; + const start = new Date(startStr); + const tag = isEs ? 'es-ES' : 'en-US'; + + const day = start.toLocaleDateString(tag, { day: 'numeric', timeZone: timezone }); + const month = start + .toLocaleDateString(tag, { month: 'short', timeZone: timezone }) + .replace(/\.$/, '') + .toUpperCase(); + + const time = (d: Date) => + d + .toLocaleTimeString(tag, { + hour: isEs ? '2-digit' : 'numeric', + minute: '2-digit', + hour12: !isEs, + timeZone: timezone, + }) + .toUpperCase(); + + const date = isEs ? `${day} ${month}` : `${month} ${day}`; + const end = endStr ? new Date(endStr) : null; + const when = end ? `${time(start)} – ${time(end)}` : time(start); + + return `${date} · ${when}`; } /** - * Format time for display using site timezone + * Events store the venue as a single string; the part before the first comma + * reads as the venue name and the remainder as its address. */ -function formatTime(dateStr: string, timezone: string = 'America/Asuncion'): string { - const date = new Date(dateStr); - return date.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - hour12: true, - timeZone: timezone, +function splitLocation(location: string): { name: string; address?: string } { + const idx = location.indexOf(','); + if (idx === -1) return { name: location.trim() }; + return { + name: location.slice(0, idx).trim(), + address: location.slice(idx + 1).trim() || undefined, + }; +} + +// ==================== Drawing helpers ==================== + +function drawLabel(doc: PDFKit.PDFDocument, text: string, y: number, width = CONTENT_W, x = MARGIN) { + doc + .font('Helvetica-Bold') + .fontSize(8) + .fillColor(COLORS.label) + .text(text.toUpperCase(), x, y, { width, characterSpacing: 1.6 }); +} + +function drawDivider(doc: PDFKit.PDFDocument, y: number) { + doc + .moveTo(MARGIN, y) + .lineTo(PAGE_W - MARGIN, y) + .lineWidth(1) + .strokeColor(COLORS.divider) + .stroke(); +} + +/** Centered text with letter spacing: pdfkit also spaces the last glyph, so nudge it back. */ +function drawSpacedCentered( + doc: PDFKit.PDFDocument, + text: string, + x: number, + y: number, + width: number, + spacing: number +) { + doc.text(text, x - spacing / 2, y, { width, align: 'center', characterSpacing: spacing }); +} + +interface DetailBlock { + label: string; + value: string; + sub?: string; +} + +/** + * Draw (or, with `measureOnly`, just measure) the venue / ticket holder / note + * block. Returns its total height so the caller can anchor it above the footer. + */ +function renderDetails( + doc: PDFKit.PDFDocument, + blocks: DetailBlock[], + note: string, + yStart: number, + measureOnly: boolean +): number { + let y = yStart; + + blocks.forEach((block, i) => { + if (i > 0) { + y += 14; + if (!measureOnly) drawDivider(doc, y); + y += 18; + } + + if (!measureOnly) drawLabel(doc, block.label, y); + y += 15; + + doc.font('Helvetica-Bold').fontSize(13); + if (!measureOnly) doc.fillColor(COLORS.navy).text(block.value, MARGIN, y, { width: CONTENT_W }); + y += doc.heightOfString(block.value, { width: CONTENT_W }) + 3; + + if (block.sub) { + doc.font('Helvetica').fontSize(10.5); + if (!measureOnly) doc.fillColor(COLORS.muted).text(block.sub, MARGIN, y, { width: CONTENT_W }); + y += doc.heightOfString(block.sub, { width: CONTENT_W }) + 3; + } }); + + y += 16; + doc.font('Helvetica').fontSize(10.5); + if (!measureOnly) doc.fillColor(COLORS.muted).text(note, MARGIN, y, { width: CONTENT_W }); + y += doc.heightOfString(note, { width: CONTENT_W }); + + return y - yStart; +} + +/** + * Render one full-page ticket. Assumes the page is already added. + */ +function renderTicketPage( + doc: PDFKit.PDFDocument, + ticket: TicketData, + qrBuffer: Buffer, + siteDomain: string, + index = 0, + total = 1 +) { + const locale = ticket.locale === 'es' ? 'es' : 'en'; + const t = strings(locale); + const tz = ticket.timezone || 'America/Asuncion'; + const footerY = PAGE_H - FOOTER_H; + + // ==================== Background ==================== + doc.rect(0, 0, PAGE_W, PAGE_H).fill(COLORS.cream); + doc.rect(0, 0, PAGE_W, ACCENT_H).fill(COLORS.orange); + + // ==================== Logo ==================== + const logo = getLogo(); + let headerY = MARGIN + 6; + + if (logo) { + const logoW = 158; + doc.image(logo, MARGIN, headerY, { width: logoW }); + headerY += logoW / LOGO_RATIO; + } else { + doc.font('Helvetica-Bold').fontSize(21).fillColor(COLORS.navy).text('spanglish social', MARGIN, headerY); + headerY += 26; + } + + // ==================== Title + date ==================== + const titleY = headerY + 30; + const when = formatWhen(ticket.event.startDatetime, ticket.event.endDatetime, tz, locale); + + doc.font('Helvetica-Bold').fontSize(11.5); + const whenW = Math.min(doc.widthOfString(when) + 2, CONTENT_W * 0.5); + const titleW = CONTENT_W - whenW - 20; + + doc.font('Helvetica-Bold').fontSize(26); + if (doc.widthOfString(ticket.event.title) > titleW) doc.fontSize(20); + doc.fillColor(COLORS.navy).text(ticket.event.title, MARGIN, titleY, { width: titleW }); + const titleBottom = doc.y; + + doc + .font('Helvetica-Bold') + .fontSize(11.5) + .fillColor(COLORS.orange) + .text(when, PAGE_W - MARGIN - whenW, titleY + 9, { width: whenW, align: 'right' }); + + // ==================== Layout: card fills what the detail block leaves ==================== + const venue = splitLocation(ticket.event.location); + const note = ticket.note || t.terms; + const blocks: DetailBlock[] = [ + { label: t.venue, value: venue.name, sub: venue.address }, + { label: t.holder, value: ticket.attendeeName, sub: ticket.attendeeEmail }, + ]; + + const detailsH = renderDetails(doc, blocks, note, 0, true); + const detailsY = footerY - 46 - detailsH; + + const cardY = Math.max(titleBottom, titleY + 36) + 24; + const cardX = MARGIN; + const cardW = CONTENT_W; + const cardH = Math.max(300, Math.min(detailsY - 32 - cardY, 430)); + + doc + .roundedRect(cardX, cardY, cardW, cardH, 14) + .lineWidth(1) + .fillAndStroke(COLORS.card, COLORS.cardBorder); + + // ==================== QR card contents ==================== + const labelH = 12; + const codeH = 24; + const qrSize = Math.min(236, cardH - (labelH + 20 + 22 + codeH + 44)); + const stackH = labelH + 20 + qrSize + 22 + codeH; + let inner = cardY + (cardH - stackH) / 2; + + doc.font('Helvetica-Bold').fontSize(8.5).fillColor(COLORS.label); + drawSpacedCentered(doc, t.scan, cardX, inner, cardW, 2); + + if (total > 1) { + doc + .font('Helvetica-Bold') + .fontSize(8.5) + .fillColor(COLORS.label) + .text(`${index + 1} / ${total}`, cardX, inner, { width: cardW - 22, align: 'right', characterSpacing: 1 }); + } + + inner += labelH + 20; + doc.image(qrBuffer, (PAGE_W - qrSize) / 2, inner, { width: qrSize, height: qrSize }); + inner += qrSize + 22; + + const code = ticket.qrCode || ticket.id.slice(0, 8).toUpperCase(); + doc.font('Courier-Bold').fontSize(19).fillColor(COLORS.navy); + drawSpacedCentered(doc, code, cardX, inner, cardW, 3); + + // ==================== Venue / ticket holder / note ==================== + renderDetails(doc, blocks, note, detailsY, false); + + // ==================== Footer ==================== + doc.rect(0, footerY, PAGE_W, FOOTER_H).fill(COLORS.navy); + + doc + .font('Courier') + .fontSize(7.5) + .fillColor(COLORS.footerMuted) + .text(ticket.id, MARGIN, footerY + FOOTER_H / 2 - 4, { width: CONTENT_W * 0.6, lineBreak: false }); + + doc + .font('Helvetica') + .fontSize(10) + .fillColor('#FFFFFF') + .text(siteDomain, MARGIN, footerY + FOOTER_H / 2 - 5.5, { width: CONTENT_W, align: 'right' }); +} + +function createDoc(): PDFKit.PDFDocument { + return new PDFDocument({ size: 'A4', margin: 0 }); +} + +function collect(doc: PDFKit.PDFDocument): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + doc.on('data', (chunk: Buffer) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + }); +} + +function siteUrl(): { base: string; domain: string } { + const base = process.env.FRONTEND_URL || 'https://spanglishcommunity.com'; + let domain = base; + try { + domain = new URL(base).host.replace(/^www\./, ''); + } catch { + domain = base.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, ''); + } + return { base, domain }; } /** * Generate a PDF ticket for a single ticket */ export async function generateTicketPDF(ticket: TicketData): Promise { - return new Promise(async (resolve, reject) => { - try { - const doc = new PDFDocument({ - size: 'A4', - margin: 50, - }); - - const chunks: Buffer[] = []; - doc.on('data', (chunk: Buffer) => chunks.push(chunk)); - doc.on('end', () => resolve(Buffer.concat(chunks))); - doc.on('error', reject); - - const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com'; - - // Generate QR code with ticket URL - const qrUrl = `${frontendUrl}/ticket/${ticket.id}`; - const qrBuffer = await generateQRCode(qrUrl); - - // ==================== Header ==================== - doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' }); - doc.moveDown(0.5); - doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' }); - - // Divider line - doc.moveDown(1); - doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke(); - doc.moveDown(1); - - // ==================== Event Info ==================== - doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' }); - doc.moveDown(0.5); - - // Date and time (using site timezone) - const tz = ticket.timezone || 'America/Asuncion'; - doc.fontSize(14).fillColor('#333'); - doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' }); - - const startTime = formatTime(ticket.event.startDatetime, tz); - const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null; - const timeRange = endTime ? `${startTime} - ${endTime}` : startTime; - doc.text(timeRange, { align: 'center' }); - - doc.moveDown(0.5); - doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' }); - - // ==================== QR Code ==================== - doc.moveDown(2); - - // Center the QR code - const qrSize = 180; - const pageWidth = 595; // A4 width in points - const qrX = (pageWidth - qrSize) / 2; - - doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize }); - doc.y += qrSize + 10; - - // ==================== Attendee Info ==================== - doc.moveDown(1); - doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' }); - - if (ticket.attendeeEmail) { - doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' }); - } - - // ==================== Ticket ID ==================== - doc.moveDown(1); - doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' }); - doc.text(`Code: ${ticket.qrCode}`, { align: 'center' }); - - // ==================== Footer ==================== - doc.moveDown(2); - doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke(); - doc.moveDown(0.5); - - doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' }); - doc.moveDown(0.3); - doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' }); - - doc.end(); - } catch (error) { - reject(error); - } - }); + return generateCombinedTicketsPDF([ticket]); } /** - * Generate a combined PDF with multiple tickets + * Generate a combined PDF with multiple tickets (one page each) */ export async function generateCombinedTicketsPDF(tickets: TicketData[]): Promise { - return new Promise(async (resolve, reject) => { - try { - const doc = new PDFDocument({ - size: 'A4', - margin: 50, - }); + const doc = createDoc(); + const done = collect(doc); + const { base, domain } = siteUrl(); - const chunks: Buffer[] = []; - doc.on('data', (chunk: Buffer) => chunks.push(chunk)); - doc.on('end', () => resolve(Buffer.concat(chunks))); - doc.on('error', reject); + try { + for (let i = 0; i < tickets.length; i++) { + const ticket = tickets[i]; + if (i > 0) doc.addPage(); - const frontendUrl = process.env.FRONTEND_URL || 'https://spanglishcommunity.com'; - - for (let i = 0; i < tickets.length; i++) { - const ticket = tickets[i]; - - if (i > 0) { - doc.addPage(); - } - - // Generate QR code - const qrUrl = `${frontendUrl}/ticket/${ticket.id}`; - const qrBuffer = await generateQRCode(qrUrl); - - // ==================== Header ==================== - doc.fontSize(28).fillColor('#1a1a1a').text('Spanglish', { align: 'center' }); - doc.moveDown(0.5); - doc.fontSize(12).fillColor('#666').text('Language Exchange Community', { align: 'center' }); - - // Divider line - doc.moveDown(1); - doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke(); - doc.moveDown(1); - - // ==================== Event Info ==================== - doc.fontSize(22).fillColor('#1a1a1a').text(ticket.event.title, { align: 'center' }); - doc.moveDown(0.5); - - // Date and time (using site timezone) - const tz = ticket.timezone || 'America/Asuncion'; - doc.fontSize(14).fillColor('#333'); - doc.text(formatDate(ticket.event.startDatetime, tz), { align: 'center' }); - - const startTime = formatTime(ticket.event.startDatetime, tz); - const endTime = ticket.event.endDatetime ? formatTime(ticket.event.endDatetime, tz) : null; - const timeRange = endTime ? `${startTime} - ${endTime}` : startTime; - doc.text(timeRange, { align: 'center' }); - - doc.moveDown(0.5); - doc.fontSize(12).fillColor('#666').text(ticket.event.location, { align: 'center' }); - - // ==================== QR Code ==================== - doc.moveDown(2); - - const qrSize = 180; - const pageWidth = 595; - const qrX = (pageWidth - qrSize) / 2; - - doc.image(qrBuffer, qrX, doc.y, { width: qrSize, height: qrSize }); - doc.y += qrSize + 10; - - // ==================== Attendee Info ==================== - doc.moveDown(1); - doc.fontSize(16).fillColor('#1a1a1a').text(ticket.attendeeName, { align: 'center' }); - - if (ticket.attendeeEmail) { - doc.fontSize(10).fillColor('#888').text(ticket.attendeeEmail, { align: 'center' }); - } - - // ==================== Ticket ID ==================== - doc.moveDown(1); - doc.fontSize(9).fillColor('#aaa').text(`Ticket ID: ${ticket.id}`, { align: 'center' }); - doc.text(`Code: ${ticket.qrCode}`, { align: 'center' }); - - // Ticket number for multi-ticket bookings - if (tickets.length > 1) { - doc.text(`Ticket ${i + 1} of ${tickets.length}`, { align: 'center' }); - } - - // ==================== Footer ==================== - doc.moveDown(2); - doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e0e0e0').stroke(); - doc.moveDown(0.5); - - doc.fontSize(10).fillColor('#888').text('Scan this QR code at the entrance', { align: 'center' }); - doc.moveDown(0.3); - doc.fontSize(8).fillColor('#aaa').text('This ticket is non-transferable. One scan per entry.', { align: 'center' }); - } - - doc.end(); - } catch (error) { - reject(error); + const qrBuffer = await generateQRCode(`${base}/ticket/${ticket.id}`); + renderTicketPage(doc, ticket, qrBuffer, domain, i, tickets.length); } - }); + doc.end(); + } catch (error) { + doc.end(); + throw error; + } + + return done; } export default { 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/lib/utils.ts b/backend/src/lib/utils.ts index 103ded7..7736ffc 100644 --- a/backend/src/lib/utils.ts +++ b/backend/src/lib/utils.ts @@ -22,6 +22,19 @@ export function generateTicketCode(): string { return `TKT-${nanoid(8).toUpperCase()}`; } +/** + * Canonical form for `users.email`. + * + * Better Auth lowercases the address on every lookup and write it performs, + * but the `users.email` unique index is case-sensitive on both dialects. Any + * row written outside Better Auth (guest bookings, door sales, admin-added + * tickets) must therefore be normalized the same way, or the row becomes + * invisible to sign-in / Google linking and a duplicate person gets created. + */ +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + /** * Get current timestamp in the format appropriate for the database type. * - SQLite: returns ISO string diff --git a/backend/src/routes/authExt.ts b/backend/src/routes/authExt.ts index f834017..ea545c8 100644 --- a/backend/src/routes/authExt.ts +++ b/backend/src/routes/authExt.ts @@ -5,7 +5,7 @@ import { eq } from 'drizzle-orm'; import { auth } from '../lib/betterAuth.js'; import { validatePassword } from '../lib/passwordPolicy.js'; import { db, dbGet, users } from '../db/index.js'; -import { getNow, toDbBool } from '../lib/utils.js'; +import { getNow, toDbBool, normalizeEmail } from '../lib/utils.js'; import { rateLimitMiddleware } from '../lib/rateLimit.js'; // Custom auth flows that Better Auth doesn't provide out of the box. Mounted @@ -83,8 +83,9 @@ authExt.get('/claim-eligibility', authExtRateLimit, async (c) => { return c.json({ canClaim: false }); } + // Normalized to match how the row is stored (see lib/utils.ts normalizeEmail) const user = await dbGet( - (db as any).select().from(users).where(eq((users as any).email, email)) + (db as any).select().from(users).where(eq((users as any).email, normalizeEmail(email))) ); const canClaim = !!user && !user.banned && user.accountStatus !== 'suspended' 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..d856e0f --- /dev/null +++ b/backend/src/routes/door.ts @@ -0,0 +1,656 @@ +// 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, normalizeEmail } 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. + // Normalized: Better Auth lowercases every lookup it makes, and the + // users.email unique index is case-sensitive, so a mixed-case address + // written here would be invisible to sign-in and Google linking. + const accountEmail = normalizeEmail( + hasEmail + ? attendee.email! + : `${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/backend/src/routes/events.ts b/backend/src/routes/events.ts index c032b84..589c1c8 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -172,6 +172,13 @@ const updateEventSchema = baseEventSchema.partial().refine( eventsRouter.get('/', async (c) => { const status = c.req.query('status'); const upcoming = c.req.query('upcoming'); + // Pagination is opt-in: callers that pass neither page nor pageSize (public + // pages, admin filter dropdowns) still get the full list. + const pageParam = c.req.query('page'); + const pageSizeParam = c.req.query('pageSize'); + const paginated = pageParam !== undefined || pageSizeParam !== undefined; + const page = Math.max(parseInt(pageParam || '1', 10) || 1, 1); + const pageSize = Math.min(Math.max(parseInt(pageSizeParam || '25', 10) || 25, 1), 200); // Only privileged users may see non-public events (drafts, archived, etc.). // Anonymous/regular callers are restricted to published events regardless of @@ -195,12 +202,24 @@ eventsRouter.get('/', async (c) => { conditions.push(eq((events as any).status, 'published')); } + const whereClause = conditions.length === 0 + ? undefined + : conditions.length === 1 ? conditions[0] : and(...conditions); + let query = (db as any).select().from(events); - if (conditions.length > 0) { - query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions)); + if (whereClause) query = query.where(whereClause); + query = query.orderBy(desc((events as any).startDatetime)); + + let total: number | undefined; + if (paginated) { + let countQuery = (db as any).select({ count: sql`count(*)` }).from(events); + if (whereClause) countQuery = countQuery.where(whereClause); + const totalRow = await dbGet(countQuery); + total = Number(totalRow?.count || 0); + query = query.limit(pageSize).offset((page - 1) * pageSize); } - - const result = await dbAll(query.orderBy(desc((events as any).startDatetime))); + + const result = await dbAll(query); // Single grouped query for seat counts across all events (avoids N+1: previously // this ran one COUNT query per event). bookedCount = paid (confirmed/checked_in); @@ -227,7 +246,9 @@ eventsRouter.get('/', async (c) => { }; }); - return c.json({ events: eventsWithCounts }); + return paginated + ? c.json({ events: eventsWithCounts, total, page, pageSize }) + : c.json({ events: eventsWithCounts }); }); // Get single event (public) - resolves by id, canonical slug, or historical alias diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 776dbfa..3333b59 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js'; import { eq, and, or, sql, inArray } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; -import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; +import { generateId, generateTicketCode, getNow, toDbDate, toDbBool, normalizeEmail, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; import { createInvoice, isLNbitsConfigured, LNBITS_INVOICE_EXPIRY_SECONDS } from '../lib/lnbits.js'; import { rateLimitMiddleware } from '../lib/rateLimit.js'; import emailService from '../lib/email.js'; @@ -148,9 +148,12 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { }, 400); } - // Find or create user + // Find or create user. The account row is keyed on the normalized address so + // it stays reachable from Better Auth (which lowercases every lookup) — + // tickets.attendeeEmail below keeps the address exactly as the buyer typed it. + const accountEmail = normalizeEmail(data.email); let user = await dbGet( - (db as any).select().from(users).where(eq((users as any).email, data.email)) + (db as any).select().from(users).where(eq((users as any).email, accountEmail)) ); const now = getNow(); @@ -163,7 +166,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { const userId = generateId(); user = { id: userId, - email: data.email, + email: accountEmail, password: null, // No password for guest bookings; set on claim (Better Auth credential account) name: fullName, phone: data.phone || null, @@ -548,20 +551,24 @@ ticketsRouter.get('/booking/:bookingId/pdf', async (c) => { ); const timezone = settings?.timezone || 'America/Asuncion'; - const ticketsData = confirmedTickets.map((ticket: any) => ({ - id: ticket.id, - qrCode: ticket.qrCode, - attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(), - attendeeEmail: ticket.attendeeEmail, - event: { - title: event.title, - startDatetime: event.startDatetime, - endDatetime: event.endDatetime, - location: event.location, - locationUrl: event.locationUrl, - }, - timezone, - })); + const ticketsData = confirmedTickets.map((ticket: any) => { + const locale = ticket.preferredLanguage === 'es' ? 'es' : 'en'; + return { + id: ticket.id, + qrCode: ticket.qrCode, + attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(), + attendeeEmail: ticket.attendeeEmail, + event: { + title: locale === 'es' && event.titleEs ? event.titleEs : event.title, + startDatetime: event.startDatetime, + endDatetime: event.endDatetime, + location: event.location, + locationUrl: event.locationUrl, + }, + timezone, + locale, + }; + }); const pdfBuffer = await generateCombinedTicketsPDF(ticketsData); @@ -625,19 +632,22 @@ ticketsRouter.get('/:id/pdf', async (c) => { ); const timezone = settings?.timezone || 'America/Asuncion'; + const locale = ticket.preferredLanguage === 'es' ? 'es' : 'en'; + const pdfBuffer = await generateTicketPDF({ id: ticket.id, qrCode: ticket.qrCode, attendeeName: `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(), attendeeEmail: ticket.attendeeEmail, event: { - title: event.title, + title: locale === 'es' && event.titleEs ? event.titleEs : event.title, startDatetime: event.startDatetime, endDatetime: event.endDatetime, location: event.location, locationUrl: event.locationUrl, }, timezone, + locale, }); // Set response headers for PDF download @@ -1418,9 +1428,10 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) ? data.email.trim() : `door-${generateId()}@doorentry.local`; - // Find or create user + // Find or create user (see the note on `accountEmail` in the booking route) + const accountEmail = normalizeEmail(attendeeEmail); let user = await dbGet( - (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)) + (db as any).select().from(users).where(eq((users as any).email, accountEmail)) ); const adminFullName = data.lastName && data.lastName.trim() @@ -1431,7 +1442,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) const userId = generateId(); user = { id: userId, - email: attendeeEmail, + email: accountEmail, password: null, name: adminFullName, phone: data.phone || null, @@ -1529,14 +1540,18 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) // Unified admin add-attendee endpoint backing the single Add Ticket modal. // type drives payment handling: // paid — email required; paid cash payment; confirmation email + QR sent +// door — paid in cash at the door; all fields optional; counts toward revenue; +// confirmation email only when an email is provided // unpaid — QR issued with balance due (collect at door); pending tpago payment; // pay-link (Bancard/TPago) email sent when an email is provided // guest — free comp ticket, not counted in revenue; confirmation email only // when an email is provided ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({ eventId: z.string(), - type: z.enum(['paid', 'unpaid', 'guest']), - firstName: z.string().min(1), + type: z.enum(['paid', 'door', 'unpaid', 'guest']), + // Door walk-ins can be logged with nothing filled in, so firstName is only + // required for the other types + firstName: z.string().optional().or(z.literal('')), lastName: z.string().optional().or(z.literal('')), email: z.string().email().optional().or(z.literal('')), phone: z.string().optional().or(z.literal('')), @@ -1546,6 +1561,9 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z }).refine((d) => d.type !== 'paid' || !!(d.email && d.email.trim()), { message: 'Email is required for paid tickets', path: ['email'], +}).refine((d) => d.type === 'door' || !!(d.firstName && d.firstName.trim()), { + message: 'First name is required', + path: ['firstName'], })), async (c) => { const data = c.req.valid('json'); @@ -1565,20 +1583,23 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z ? data.email!.trim() : `${data.type === 'guest' ? 'guest' : 'door'}-${generateId()}@${data.type === 'guest' ? 'guestinvite' : 'doorentry'}.local`; + // Nameless door walk-ins still need a display name on the ticket + const firstName = (data.firstName && data.firstName.trim()) || 'Walk-in'; const fullName = data.lastName && data.lastName.trim() - ? `${data.firstName} ${data.lastName}`.trim() - : data.firstName; + ? `${firstName} ${data.lastName.trim()}` + : firstName; - // Find or create user + // Find or create user (see the note on `accountEmail` in the booking route) + const accountEmail = normalizeEmail(attendeeEmail); let user = await dbGet( - (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)) + (db as any).select().from(users).where(eq((users as any).email, accountEmail)) ); if (!user) { const userId = generateId(); user = { id: userId, - email: attendeeEmail, + email: accountEmail, password: null, name: fullName, phone: data.phone || null, @@ -1613,13 +1634,13 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z const ticketId = generateId(); const qrCode = generateTicketCode(); - const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'paid' ? 'paid' : 'unpaid'; + const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'unpaid' ? 'unpaid' : 'paid'; const newTicket = { id: ticketId, userId: user.id, eventId: data.eventId, - attendeeFirstName: data.firstName, + attendeeFirstName: firstName, attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, attendeeEmail: hasEmail ? data.email!.trim() : null, attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, @@ -1636,7 +1657,7 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z await (db as any).insert(tickets).values(newTicket); - // Payment record: paid cash for paid/guest ($0 for guest), pending tpago for unpaid + // Payment record: paid cash for paid/door/guest ($0 for guest), pending tpago for unpaid const paymentId = generateId(); const newPayment = data.type === 'unpaid' ? { @@ -1659,7 +1680,11 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z amount: data.type === 'guest' ? 0 : event.price, currency: event.currency, status: 'paid', - reference: data.type === 'guest' ? 'Guest invite' : 'Manual ticket', + reference: data.type === 'guest' + ? 'Guest invite' + : data.type === 'door' + ? 'Paid at door' + : 'Manual ticket', paidAt: now, paidByAdminId: adminUser?.id || null, createdAt: now, @@ -1668,8 +1693,8 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z await (db as any).insert(payments).values(newPayment); - // Emails (asynchronous): paid always confirms; guest confirms when an email - // exists; unpaid sends the TPago (Bancard) pay-link instructions instead + // Emails (asynchronous): paid always confirms; door/guest confirm only when an + // email exists; unpaid sends the TPago (Bancard) pay-link instructions instead if (data.type === 'unpaid') { if (hasEmail) { emailService.sendPaymentInstructions(ticketId).then(result => { @@ -1692,6 +1717,9 @@ ticketsRouter.post('/admin/add', requireAuth(['admin', 'organizer', 'staff']), z const messages: Record = { paid: 'Ticket created — confirmation email sent', + door: hasEmail + ? 'Ticket created — paid at the door, confirmation email sent' + : 'Ticket created — paid at the door', unpaid: hasEmail ? 'Unpaid ticket created — payment link sent' : 'Unpaid ticket created — collect payment at the door', diff --git a/frontend/src/app/admin/bookings/page.tsx b/frontend/src/app/admin/bookings/page.tsx index a1ecda1..0de3de6 100644 --- a/frontend/src/app/admin/bookings/page.tsx +++ b/frontend/src/app/admin/bookings/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useLanguage } from '@/context/LanguageContext'; import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api'; import { parseDate, formatRucDisplay } from '@/lib/utils'; @@ -8,6 +8,7 @@ import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { AdminPageSkeleton } from '@/components/ui/Skeleton'; import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents'; +import Pagination, { usePaginatedList } from '@/components/admin/Pagination'; import { TicketIcon, CheckCircleIcon, @@ -51,6 +52,8 @@ export default function AdminBookingsPage() { const [selectedPaymentStatus, setSelectedPaymentStatus] = useState(''); const [searchQuery, setSearchQuery] = useState(''); const [mobileFilterOpen, setMobileFilterOpen] = useState(false); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); useEffect(() => { loadData(); @@ -203,6 +206,19 @@ export default function AdminBookingsPage() { (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); + // Bookings are paginated client-side: the page already loads every ticket so + // that the stat cards, the group-booking totals and the sibling payment-method + // lookup can see the whole set, and those would break on a server-side slice. + const filterKey = JSON.stringify([selectedEvent, selectedStatus, selectedPaymentStatus, searchQuery]); + const prevFilterKey = useRef(filterKey); + useEffect(() => { + if (prevFilterKey.current !== filterKey) { + prevFilterKey.current = filterKey; + setPage(1); + } + }, [filterKey]); + const pagedTickets = usePaginatedList(sortedTickets, page, pageSize, setPage); + const stats = { total: tickets.length, pending: tickets.filter(t => t.status === 'pending').length, @@ -408,7 +424,7 @@ export default function AdminBookingsPage() { ) : ( - sortedTickets.map((ticket) => { + pagedTickets.map((ticket) => { const bookingInfo = getBookingInfo(ticket); return ( @@ -502,7 +518,7 @@ export default function AdminBookingsPage() { No bookings found. ) : ( - sortedTickets.map((ticket) => { + pagedTickets.map((ticket) => { const bookingInfo = getBookingInfo(ticket); const primary = getPrimaryAction(ticket); const eventTitle = ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'; @@ -580,6 +596,15 @@ export default function AdminBookingsPage() { )} + + {/* Mobile Filter BottomSheet */} setMobileFilterOpen(false)} title="Filters">
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]/_modals/AddTicketModal.tsx b/frontend/src/app/admin/events/[id]/_modals/AddTicketModal.tsx index d971606..e676d41 100644 --- a/frontend/src/app/admin/events/[id]/_modals/AddTicketModal.tsx +++ b/frontend/src/app/admin/events/[id]/_modals/AddTicketModal.tsx @@ -24,18 +24,21 @@ interface AddTicketModalProps { const TYPE_OPTIONS: { value: AddTicketType; label: string }[] = [ { value: 'paid', label: 'Paid' }, + { value: 'door', label: 'At Door' }, { value: 'unpaid', label: 'Unpaid' }, { value: 'guest', label: 'Guest' }, ]; const SUBMIT_LABELS: Record = { paid: 'Create & send ticket', + door: 'Record door payment', unpaid: 'Create & send pay link', guest: 'Invite guest', }; const SUBMIT_ICONS: Record = { paid: EnvelopeIcon, + door: BanknotesIcon, unpaid: LinkIcon, guest: StarIcon, }; @@ -47,6 +50,15 @@ function previewLines(form: AddTicketFormState, eventPriceLabel: string): string if (form.type === 'paid') { lines.push(`Payment of ${eventPriceLabel} recorded as paid — counts toward revenue`); lines.push('Confirmation email with QR ticket sent'); + } else if (form.type === 'door') { + lines.push(`Cash payment of ${eventPriceLabel} recorded as paid at the door — counts toward revenue`); + lines.push('QR code issued'); + if (!form.firstName.trim()) { + lines.push('No name — the ticket is logged as a "Walk-in"'); + } + lines.push(hasEmail + ? 'Confirmation email with QR ticket sent' + : 'No email — nothing is sent, walk-in kept on the list only'); } else if (form.type === 'unpaid') { lines.push(`Ticket marked unpaid — balance of ${eventPriceLabel} to collect at the door`); lines.push('QR code issued, flagged "unpaid" for door staff'); @@ -66,12 +78,14 @@ function previewLines(form: AddTicketFormState, eventPriceLabel: string): string const PREVIEW_STYLES: Record = { paid: { box: 'bg-blue-50 border-blue-200', icon: 'text-blue-500', text: 'text-blue-800' }, + door: { box: 'bg-emerald-50 border-emerald-200', icon: 'text-emerald-500', text: 'text-emerald-800' }, unpaid: { box: 'bg-orange-50 border-orange-200', icon: 'text-orange-500', text: 'text-orange-800' }, guest: { box: 'bg-amber-50 border-amber-200', icon: 'text-amber-500', text: 'text-amber-800' }, }; const PREVIEW_ICONS: Record = { paid: CheckCircleIcon, + door: BanknotesIcon, unpaid: BanknotesIcon, guest: StarIcon, }; @@ -88,6 +102,8 @@ export function AddTicketModal({ if (!open) return null; const emailRequired = form.type === 'paid'; + // Door walk-ins can be logged with nothing filled in + const nameRequired = form.type !== 'door'; const style = PREVIEW_STYLES[form.type]; const PreviewIcon = PREVIEW_ICONS[form.type]; const SubmitIcon = SUBMIT_ICONS[form.type]; @@ -120,7 +136,7 @@ export function AddTicketModal({ type="button" onClick={() => setForm((f) => ({ ...f, type: option.value }))} className={clsx( - 'flex-1 px-3 py-2 text-sm font-medium rounded-btn min-h-[36px] transition-colors', + 'flex-1 px-2 py-2 text-xs sm:text-sm font-medium rounded-btn min-h-[36px] whitespace-nowrap transition-colors', form.type === option.value ? 'bg-white shadow-sm text-primary-dark' : 'text-gray-500 hover:text-gray-700' @@ -133,11 +149,11 @@ export function AddTicketModal({
- - First Name {nameRequired && '*'} + setForm((f) => ({ ...f, firstName: e.target.value }))} className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" - placeholder="First name" /> + placeholder={nameRequired ? 'First name' : 'First name (optional)'} />
@@ -155,6 +171,7 @@ export function AddTicketModal({ placeholder={emailRequired ? 'email@example.com' : 'email@example.com (optional)'} />

{form.type === 'paid' && 'Ticket will be sent to this email'} + {form.type === 'door' && 'Optional — if provided, the ticket confirmation is sent here'} {form.type === 'unpaid' && 'If provided, the payment link is sent here'} {form.type === 'guest' && 'If provided, a confirmation email will be sent'}

diff --git a/frontend/src/app/admin/events/[id]/_modals/EventModals.tsx b/frontend/src/app/admin/events/[id]/_modals/EventModals.tsx index d4acfe2..b00c995 100644 --- a/frontend/src/app/admin/events/[id]/_modals/EventModals.tsx +++ b/frontend/src/app/admin/events/[id]/_modals/EventModals.tsx @@ -133,6 +133,16 @@ export function EventModals(props: EventModalsProps) {

Send confirmation email with QR ticket

+
+ + {/* Mobile FAB */}
) : ( <> + {/* 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]/_tabs/TicketsTab.tsx b/frontend/src/app/admin/events/[id]/_tabs/TicketsTab.tsx index fe53081..c3e12f1 100644 --- a/frontend/src/app/admin/events/[id]/_tabs/TicketsTab.tsx +++ b/frontend/src/app/admin/events/[id]/_tabs/TicketsTab.tsx @@ -1,8 +1,10 @@ +import { useEffect, useRef, useState } from 'react'; import { Ticket } from '@/lib/api'; import { parseDate, EVENT_TIMEZONE } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents'; +import Pagination, { usePaginatedList } from '@/components/admin/Pagination'; import { MagnifyingGlassIcon, ChevronDownIcon, @@ -48,6 +50,20 @@ export function TicketsTab({ handleRemoveCheckin, setShowTicketExportSheet, }: TicketsTabProps) { + // Paginated client-side, same as the Attendees tab: the parent keeps the full + // ticket list for the header counts and the export actions. + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + const filterKey = `${ticketSearchQuery}|${ticketStatusFilter}`; + const prevFilterKey = useRef(filterKey); + useEffect(() => { + if (prevFilterKey.current !== filterKey) { + prevFilterKey.current = filterKey; + setPage(1); + } + }, [filterKey]); + const pagedTickets = usePaginatedList(filteredConfirmedTickets, page, pageSize, setPage); + return (
{/* Desktop toolbar */} @@ -152,7 +168,7 @@ export function TicketsTab({ ) : ( - filteredConfirmedTickets.map((ticket) => ( + pagedTickets.map((ticket) => (

{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}

@@ -216,7 +232,7 @@ export function TicketsTab({ {confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
) : ( - filteredConfirmedTickets.map((ticket) => ( + pagedTickets.map((ticket) => (
@@ -253,6 +269,15 @@ export function TicketsTab({ )) )}
+ +
); } diff --git a/frontend/src/app/admin/events/[id]/_types.ts b/frontend/src/app/admin/events/[id]/_types.ts index 7667df2..3c60110 100644 --- a/frontend/src/app/admin/events/[id]/_types.ts +++ b/frontend/src/app/admin/events/[id]/_types.ts @@ -15,9 +15,10 @@ export interface PrimaryAction { // Ticket type in the unified Add Ticket modal: // paid = confirmation + QR emailed, counts toward revenue +// door = already paid in cash at the door, counts toward revenue, every field optional // unpaid = QR flagged unpaid, balance collected at door, pay link emailed if possible // guest = free comp ticket, auto-confirmed, no revenue -export type AddTicketType = 'paid' | 'unpaid' | 'guest'; +export type AddTicketType = 'paid' | 'door' | 'unpaid' | 'guest'; export interface AddTicketFormState { type: AddTicketType; diff --git a/frontend/src/app/admin/events/[id]/page.tsx b/frontend/src/app/admin/events/[id]/page.tsx index f49ccae..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 @@ -84,7 +84,7 @@ export default function AdminEventDetailPage() { const [showNoteModal, setShowNoteModal] = useState(false); const [selectedTicket, setSelectedTicket] = useState(null); const [noteText, setNoteText] = useState(''); - // Unified Add Ticket modal (paid / unpaid / guest via segmented control) + // Unified Add Ticket modal (paid / door / unpaid / guest via segmented control) const [showAddTicketModal, setShowAddTicketModal] = useState(false); const [addTicketForm, setAddTicketForm] = useState(EMPTY_ADD_TICKET_FORM); const [submitting, setSubmitting] = useState(false); @@ -222,7 +222,7 @@ export default function AdminEventDetailPage() { const res = await ticketsApi.adminAdd({ eventId: event.id, type: addTicketForm.type, - firstName: addTicketForm.firstName, + firstName: addTicketForm.firstName || undefined, lastName: addTicketForm.lastName || undefined, email: addTicketForm.email || undefined, phone: addTicketForm.phone || undefined, @@ -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/events/page.tsx b/frontend/src/app/admin/events/page.tsx index 1bb1051..8eae65d 100644 --- a/frontend/src/app/admin/events/page.tsx +++ b/frontend/src/app/admin/events/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { useLanguage } from '@/context/LanguageContext'; @@ -15,12 +15,16 @@ import toast from 'react-hot-toast'; import clsx from 'clsx'; import { parseDate } from '@/lib/utils'; import EventFormModal from './_components/EventFormModal'; +import Pagination from '@/components/admin/Pagination'; export default function AdminEventsPage() { const router = useRouter(); const { t, locale } = useLanguage(); const searchParams = useSearchParams(); const [events, setEvents] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); const [editingEvent, setEditingEvent] = useState(null); @@ -28,22 +32,42 @@ export default function AdminEventsPage() { const [settingFeatured, setSettingFeatured] = useState(null); useEffect(() => { - loadEvents(); loadFeaturedEvent(); }, []); + useEffect(() => { + loadEvents(); + }, [page, pageSize]); + + // The ?edit= deep link may point at an event that is not on the current + // page, so fall back to fetching it directly instead of only scanning the page. + const handledEditId = useRef(null); useEffect(() => { const editId = searchParams.get('edit'); - if (editId && events.length > 0) { - const event = events.find(e => e.id === editId); - if (event) handleEdit(event); + if (!editId || handledEditId.current === editId) return; + const event = events.find(e => e.id === editId); + if (event) { + handledEditId.current = editId; + handleEdit(event); + return; } - }, [searchParams, events]); + if (loading) return; + handledEditId.current = editId; + eventsApi.getById(editId) + .then(({ event }) => handleEdit(event)) + .catch(() => toast.error('Event not found')); + }, [searchParams, events, loading]); const loadEvents = async () => { try { - const { events } = await eventsApi.getAll(); + const { events, total } = await eventsApi.getAll({ page, pageSize }); setEvents(events); + setTotal(total ?? events.length); + // If the current page emptied out (e.g. after deleting its last event), + // fall back to the new last page. + if (events.length === 0 && (total ?? 0) > 0 && page > 1) { + setPage(Math.max(1, Math.ceil((total ?? 0) / pageSize))); + } } catch (error) { toast.error('Failed to load events'); } finally { @@ -401,6 +425,16 @@ export default function AdminEventsPage() { )}
+ + {/* Mobile FAB */}
+ + {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