diff --git a/.gitignore b/.gitignore index ffc5a40..26037d1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ package-lock.json # Build outputs dist/ .next/ +.next-build/ out/ build/ @@ -56,6 +57,10 @@ yarn-error.log* # Testing coverage/ +# Go photo-api service +photo-api/bin/ +photo-api/data/ + # Misc *.pem diff --git a/backend/.env.example b/backend/.env.example index c1339c5..bed4f2b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -22,10 +22,18 @@ DATABASE_URL=./data/spanglish.db # Note: running more than one instance requires DB_TYPE=postgres. SQLite is a # single local file and cannot be shared safely across instances. -# Redis connection URL. When set, the cache, rate limiter, pub/sub (real-time -# payment events), distributed locks, and the email hourly cap are shared across -# all instances. When unset, each instance uses in-memory equivalents. +# Redis connection URL. When set, the cache, rate limiter, login lockout, +# pub/sub (real-time payment events), distributed locks, and the email hourly +# cap are shared across all instances. When unset, each instance uses in-memory +# equivalents. +# +# In production always set a password (requirepass on the server) and put it in +# the URL. Use the rediss:// scheme for TLS (handled natively by the client), +# and an optional /N path to select a DB index when sharing a Redis instance +# with other applications. # REDIS_URL=redis://localhost:6379 +# REDIS_URL=redis://:your-redis-password@redis.internal:6379 +# REDIS_URL=rediss://:your-redis-password@redis.example.com:6380/1 # Optional S3-compatible object storage for media uploads (e.g. Garage, MinIO, # AWS S3). When S3_ENDPOINT and S3_BUCKET are set, uploads go to the bucket and diff --git a/backend/package.json b/backend/package.json index a045340..c0c948d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "tsx watch src/index.ts", "build": "tsc", + "test": "vitest run", "start": "NODE_ENV=production node dist/index.js", "db:generate": "drizzle-kit generate", "db:migrate": "tsx src/db/migrate.ts", @@ -42,7 +43,9 @@ "@types/pg": "^8.11.6", "@types/qrcode": "^1.5.6", "drizzle-kit": "^0.22.8", + "ioredis-mock": "^8.13.1", "tsx": "^4.15.7", - "typescript": "^5.5.2" + "typescript": "^5.5.2", + "vitest": "^4.1.10" } } diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index b998a3b..bab2ed9 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -199,7 +199,19 @@ async function migrate() { try { await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } - + + // Migration: Add payment_status column to tickets (paid | unpaid | comp), + // backfilled from is_guest and the payments table on first run + try { + await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN payment_status TEXT NOT NULL DEFAULT 'unpaid'`); + await (db as any).run(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`); + await (db as any).run(sql` + UPDATE tickets SET payment_status = 'paid' + WHERE is_guest = 0 + AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid') + `); + } catch (e) { /* column may already exist */ } + // Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries) // SQLite doesn't support altering column constraints, so we'll just ensure new entries work @@ -702,6 +714,18 @@ async function migrate() { await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`); } catch (e) { /* column may already exist */ } + // Migration: Add payment_status column to tickets (paid | unpaid | comp), + // backfilled from is_guest and the payments table on first run + try { + await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN payment_status VARCHAR(10) NOT NULL DEFAULT 'unpaid'`); + await (db as any).execute(sql`UPDATE tickets SET payment_status = 'comp' WHERE is_guest = 1`); + await (db as any).execute(sql` + UPDATE tickets SET payment_status = 'paid' + WHERE is_guest = 0 + AND id IN (SELECT ticket_id FROM payments WHERE status = 'paid') + `); + } catch (e) { /* column may already exist */ } + await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS payments ( id UUID PRIMARY KEY, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index ee75c02..fc964f6 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -110,6 +110,8 @@ export const sqliteTickets = sqliteTable('tickets', { qrCode: text('qr_code'), adminNote: text('admin_note'), isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false), + // Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue + paymentStatus: text('payment_status', { enum: ['paid', 'unpaid', 'comp'] }).notNull().default('unpaid'), createdAt: text('created_at').notNull(), }); @@ -468,6 +470,8 @@ export const pgTickets = pgTable('tickets', { qrCode: varchar('qr_code', { length: 255 }), adminNote: pgText('admin_note'), isGuest: pgInteger('is_guest').notNull().default(0), + // Paid: revenue counted; Unpaid: balance due (collect at door); Comp: free guest, no revenue + paymentStatus: varchar('payment_status', { length: 10 }).notNull().default('unpaid'), createdAt: timestamp('created_at').notNull(), }); diff --git a/backend/src/index.ts b/backend/src/index.ts index d7773c9..2c8ed7c 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -24,10 +24,11 @@ import legalPagesRoutes from './routes/legal-pages.js'; import legalSettingsRoutes from './routes/legal-settings.js'; import faqRoutes from './routes/faq.js'; import emailService from './lib/email.js'; -import { initEmailQueue } from './lib/emailQueue.js'; -import { startBookingCleanup } from './lib/bookingCleanup.js'; -import { startHoldSweep } from './lib/holdSweep.js'; -import { startEventEndSweep } from './lib/eventEndSweep.js'; +import { initEmailQueue, stopQueue } from './lib/emailQueue.js'; +import { startBookingCleanup, stopBookingCleanup } from './lib/bookingCleanup.js'; +import { startHoldSweep, stopHoldSweep } from './lib/holdSweep.js'; +import { startEventEndSweep, stopEventEndSweep } from './lib/eventEndSweep.js'; +import { closeRedis } from './lib/redis.js'; import { getLock } from './lib/stores/lock.js'; import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js'; @@ -1943,8 +1944,11 @@ startEventEndSweep(); // Initialize email templates on startup. // Guarded by a distributed lock so that, when running multiple replicas, only // one instance seeds/updates templates per boot instead of all of them racing. +// onUnavailable 'run': at boot the Redis connection may not be ready yet, and +// seeding is upsert-idempotent, so racing replicas are safe — never skipping +// beats never seeding on a first boot during a Redis blip. getLock() - .withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates()) + .withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates(), { onUnavailable: 'run' }) .then((result) => { if (result === null) { console.log('[Email] Template seeding skipped (another instance holds the lock)'); @@ -1961,7 +1965,43 @@ console.log(`📋 OpenAPI spec at http://localhost:${port}/openapi.json`); // Log which backend (memory/redis, local/s3) each subsystem selected. logSelectedBackends(); -serve({ +const server = serve({ fetch: app.fetch, port, }); + +// Graceful shutdown: stop the periodic jobs, stop accepting connections, then +// close Redis and exit. Open SSE payment streams hold sockets forever, so +// server.close() alone never completes — force-close remaining connections +// after a short grace period, with a hard exit as the final backstop. +let shuttingDown = false; +function shutdown(signal: string): void { + if (shuttingDown) return; + shuttingDown = true; + console.log(`[shutdown] ${signal} received, draining...`); + + stopBookingCleanup(); + stopHoldSweep(); + stopEventEndSweep(); + stopQueue(); + + server.close(() => { + console.log('[shutdown] server closed, closing redis'); + closeRedis().finally(() => process.exit(0)); + }); + + const forceClose = setTimeout(() => { + console.warn('[shutdown] force-closing remaining connections (SSE streams)'); + (server as any).closeAllConnections?.(); + }, 5_000); + forceClose.unref(); + + const forceExit = setTimeout(() => { + console.warn('[shutdown] drain timed out, forcing exit'); + process.exit(1); + }, 10_000); + forceExit.unref(); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/backend/src/lib/backends.ts b/backend/src/lib/backends.ts index 63196a9..c2eec12 100644 --- a/backend/src/lib/backends.ts +++ b/backend/src/lib/backends.ts @@ -1,11 +1,12 @@ // Reports which backend each scalable subsystem is using, for the health // endpoint and startup logging. -import { isRedisEnabled, isRedisHealthy } from './redis.js'; +import { isRedisEnabled, isRedisHealthy, getRedisHealthDetail } from './redis.js'; import { getRateLimiter } from './stores/rateLimiter.js'; import { getPubSub } from './stores/pubsub.js'; import { getCache } from './stores/cache.js'; import { getLock } from './stores/lock.js'; +import { getLoginLockout } from './stores/loginLockout.js'; import { getStorage } from './storage.js'; export function describeBackends() { @@ -14,12 +15,13 @@ export function describeBackends() { rateLimiter: getRateLimiter().backend, pubsub: getPubSub().backend, lock: getLock().backend, + loginLockout: getLoginLockout().backend, storage: getStorage().backend, }; } export function describeRedis() { - return { enabled: isRedisEnabled(), healthy: isRedisHealthy() }; + return { enabled: isRedisEnabled(), healthy: isRedisHealthy(), ...getRedisHealthDetail() }; } /** Log one line per subsystem at startup so the active backend is obvious. */ @@ -32,5 +34,6 @@ export function logSelectedBackends(): void { console.log(` rate limiter: ${b.rateLimiter}`); console.log(` pub/sub: ${b.pubsub}`); console.log(` lock: ${b.lock}`); + console.log(` login lockout:${b.loginLockout}`); console.log(` storage: ${b.storage}`); } diff --git a/backend/src/lib/bookingCleanup.ts b/backend/src/lib/bookingCleanup.ts index 2e331f1..21907f4 100644 --- a/backend/src/lib/bookingCleanup.ts +++ b/backend/src/lib/bookingCleanup.ts @@ -18,7 +18,7 @@ import { and, eq, lt, inArray, notInArray } from 'drizzle-orm'; import { db, dbAll, tickets, payments } from '../db/index.js'; import { getNow, toDbDate } from './utils.js'; import { getLock } from './stores/lock.js'; -import { MANUAL_PAYMENT_PROVIDERS } from './manualProviders.js'; +import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js'; function getTtlMs(): number { const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10); diff --git a/backend/src/lib/capacity.ts b/backend/src/lib/capacity.ts new file mode 100644 index 0000000..5c3e69b --- /dev/null +++ b/backend/src/lib/capacity.ts @@ -0,0 +1,70 @@ +// Single source of truth for event seat accounting. +// +// A seat is held by a booking the moment the money is real or claimed to be: +// - ticket 'confirmed' or 'checked_in' (paid), or +// - ticket 'pending' whose payment is 'pending_approval' (customer clicked +// "I've paid" and is waiting for admin verification). +// +// A bare 'pending' payment — an opened checkout that was never paid nor claimed, +// on any provider — holds NO seat, so abandoned bookings can never block sales. +// The accepted trade-off is a small oversell window when several people book the +// last seats and all later pay/claim; admin approval is the backstop and may +// knowingly approve over capacity (see routes/payments.ts). +// +// 'pending_approval' is a payment status (the ticket row stays 'pending'), so +// every capacity count joins tickets to payments. There is exactly one payment +// row per ticket (created together in routes/tickets.ts). +// +// Every place that counts seats — booking creation, public availability, +// hold recovery, admin dashboards — must go through these builders so the +// formula cannot diverge between surfaces. + +import { sql, and, eq, inArray } from 'drizzle-orm'; +import { tickets, payments } from '../db/index.js'; + +// COALESCE keeps the predicate two-valued under the LEFT JOIN (a ticket with no +// payment row must count as "not holding" — NULL would poison NOT ...). +export const seatHoldingSql = sql`(${(tickets as any).status} IN ('confirmed', 'checked_in') OR (${(tickets as any).status} = 'pending' AND COALESCE(${(payments as any).status}, '') = 'pending_approval'))`; + +/** + * Query: number of seats currently held for an event. + * `executor` is the db, or a transaction (sync sqlite tx: finish with `.get()`; + * async pg tx / plain db: await via dbGet). + */ +export function seatHolderCountQuery(executor: any, eventId: string) { + return executor + .select({ count: sql`count(distinct ${(tickets as any).id})` }) + .from(tickets) + .leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id)) + .where(and(eq((tickets as any).eventId, eventId), seatHoldingSql)); +} + +/** + * Query: how many of the given tickets do NOT currently hold a seat (and so + * would need fresh capacity if promoted to a seat-holding state). + */ +export function unseatedTicketCountQuery(executor: any, ticketIds: string[]) { + return executor + .select({ count: sql`count(distinct ${(tickets as any).id})` }) + .from(tickets) + .leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id)) + .where(and(inArray((tickets as any).id, ticketIds), sql`NOT ${seatHoldingSql}`)); +} + +/** + * Query: per-event breakdown of paid vs claimed seats, grouped by event. + * paidCount = confirmed + checked_in; claimedCount = pending_approval-held. + * Pass `eventId` to restrict to one event (still returns a grouped row). + */ +export function eventSeatBreakdownQuery(executor: any, eventId?: string) { + const query = executor + .select({ + eventId: (tickets as any).eventId, + paidCount: sql`sum(case when ${(tickets as any).status} IN ('confirmed', 'checked_in') then 1 else 0 end)`, + claimedCount: sql`sum(case when ${(tickets as any).status} = 'pending' AND COALESCE(${(payments as any).status}, '') = 'pending_approval' then 1 else 0 end)`, + }) + .from(tickets) + .leftJoin(payments, eq((payments as any).ticketId, (tickets as any).id)); + return (eventId ? query.where(eq((tickets as any).eventId, eventId)) : query) + .groupBy((tickets as any).eventId); +} diff --git a/backend/src/lib/holdRecovery.ts b/backend/src/lib/holdRecovery.ts index d80adb5..2888dda 100644 --- a/backend/src/lib/holdRecovery.ts +++ b/backend/src/lib/holdRecovery.ts @@ -1,19 +1,22 @@ -// Shared capacity-checked recovery for released bookings. +// Shared capacity-checked recovery for bookings that don't currently hold a seat. // -// When a booking is put on hold (or failed/cancelled), its ticket(s) drop out of the -// capacity-counting statuses ('pending', 'confirmed', 'checked_in'), releasing the seat. -// Recovering such a booking (user "I've paid" again, or an admin reactivating / marking -// it paid / reopening a failed payment) must atomically re-check that the event still has -// room before re-reserving the seat, exactly like the original booking-creation flow in -// routes/tickets.ts. +// Under the seat-holding rule (lib/capacity.ts) a seat is held by paid/checked-in +// tickets and by 'pending_approval' payments. Promoting a booking INTO one of those +// states — user clicking "I've paid", an admin approving/reactivating a payment — +// must atomically re-check that the event still has room, exactly like the original +// booking-creation flow in routes/tickets.ts. Demoting back to bare 'pending' +// (e.g. reopening a failed payment) claims no seat and skips the check. // -// Callers choose which ticket statuses are eligible to be re-reserved via -// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list — and -// tickets that already hold a seat — are left untouched and don't consume capacity. +// Callers choose which ticket statuses are eligible to be flipped via +// `options.fromTicketStatuses` (default ['on_hold']); tickets not in that list are +// left untouched. Tickets that already hold a seat cost no new capacity. +// `options.skipCapacityCheck` lets an admin knowingly approve over capacity — +// the UI warns first (routes/payments.ts /approve with allowOverCapacity). -import { eq, and, inArray, sql } from 'drizzle-orm'; +import { eq, and, inArray } from 'drizzle-orm'; import { db, dbGet, tickets, payments, events, isSqlite } from '../db/index.js'; import { getNow, calculateAvailableSeats, isEventSoldOut } from './utils.js'; +import { seatHolderCountQuery, unseatedTicketCountQuery } from './capacity.js'; export class HoldCapacityError extends Error { constructor(public available: number) { @@ -26,6 +29,8 @@ interface ReserveOptions { extraPaymentFields?: Record; /** Ticket statuses eligible to be flipped to targetTicketStatus. Default: ['on_hold']. */ fromTicketStatuses?: Array<'on_hold' | 'cancelled' | 'pending'>; + /** Admin override: reserve even when it puts the event over capacity. */ + skipCapacityCheck?: boolean; } /** @@ -34,7 +39,8 @@ interface ReserveOptions { * Only tickets whose current status is in `fromTicketStatuses` are flipped. * Capacity is asserted against the number of those tickets that don't already * hold a seat, so re-reserving tickets that are already seated is a no-op. - * Throws HoldCapacityError if the event no longer has room. + * Throws HoldCapacityError if the event no longer has room (unless the target + * state holds no seat, or skipCapacityCheck is set). */ export async function reserveOnHoldBooking( eventId: string, @@ -46,6 +52,10 @@ export async function reserveOnHoldBooking( if (ticketIds.length === 0) return; const fromTicketStatuses = options.fromTicketStatuses ?? ['on_hold']; + // Bare 'pending' payments hold no seat, so moving a booking back to 'pending' + // consumes no capacity and needs no check. + const targetHoldsSeat = targetPaymentStatus !== 'pending' || targetTicketStatus === 'confirmed'; + const checkCapacity = targetHoldsSeat && !options.skipCapacityCheck; const event = await dbGet( (db as any).select().from(events).where(eq((events as any).id, eventId)) @@ -65,8 +75,14 @@ export async function reserveOnHoldBooking( if (options.paidByAdminId) paymentUpdate.paidByAdminId = options.paidByAdminId; } + // Keep the ticket-level payment flag in sync when the payment settles + const ticketUpdate: Record = { status: targetTicketStatus }; + if (targetPaymentStatus === 'paid') { + ticketUpdate.paymentStatus = 'paid'; + } + // `needed` is how many of these tickets don't currently hold a seat and so must - // be found new capacity; tickets already in a seat-holding status cost nothing. + // be found new capacity; tickets already in a seat-holding state cost nothing. const assertCapacity = (reserved: number, needed: number) => { if (needed <= 0) return; if (isEventSoldOut(event.capacity, reserved)) { @@ -80,26 +96,14 @@ export async function reserveOnHoldBooking( if (isSqlite()) { (db as any).transaction((tx: any) => { - const countRow = tx - .select({ count: sql`count(*)` }) - .from(tickets) - .where(and( - eq((tickets as any).eventId, eventId), - sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')` - )) - .get(); - const neededRow = tx - .select({ count: sql`count(*)` }) - .from(tickets) - .where(and( - inArray((tickets as any).id, ticketIds), - sql`${(tickets as any).status} NOT IN ('pending', 'confirmed', 'checked_in')` - )) - .get(); - assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0)); + if (checkCapacity) { + const countRow = seatHolderCountQuery(tx, eventId).get(); + const neededRow = unseatedTicketCountQuery(tx, ticketIds).get(); + assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0)); + } tx.update(tickets) - .set({ status: targetTicketStatus }) + .set(ticketUpdate) .where(and( inArray((tickets as any).id, ticketIds), inArray((tickets as any).status, fromTicketStatuses) @@ -113,28 +117,14 @@ export async function reserveOnHoldBooking( }); } else { await (db as any).transaction(async (tx: any) => { - const countRow = await dbGet( - tx - .select({ count: sql`count(*)` }) - .from(tickets) - .where(and( - eq((tickets as any).eventId, eventId), - sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')` - )) - ); - const neededRow = await dbGet( - tx - .select({ count: sql`count(*)` }) - .from(tickets) - .where(and( - inArray((tickets as any).id, ticketIds), - sql`${(tickets as any).status} NOT IN ('pending', 'confirmed', 'checked_in')` - )) - ); - assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0)); + if (checkCapacity) { + const countRow = await dbGet(seatHolderCountQuery(tx, eventId)); + const neededRow = await dbGet(unseatedTicketCountQuery(tx, ticketIds)); + assertCapacity(Number(countRow?.count || 0), Number(neededRow?.count || 0)); + } await tx.update(tickets) - .set({ status: targetTicketStatus }) + .set(ticketUpdate) .where(and( inArray((tickets as any).id, ticketIds), inArray((tickets as any).status, fromTicketStatuses) diff --git a/backend/src/lib/holdSweep.ts b/backend/src/lib/holdSweep.ts index 912003c..41bd115 100644 --- a/backend/src/lib/holdSweep.ts +++ b/backend/src/lib/holdSweep.ts @@ -1,23 +1,24 @@ -// Auto-hold stale manual-payment bookings. +// Auto-hold stale unsettled manual-payment bookings. // -// This job releases the seat held by an abandoned manual-payment booking (bank -// transfer / TPago / cash) after HOLD_THRESHOLD_HOURS. It covers two states, both of -// which keep a seat reserved while awaiting a human: -// - 'pending_approval': the user clicked "I've paid" and is waiting for an admin. -// - 'pending' on a manual provider (see MANUAL_PAYMENT_PROVIDERS): the booking was -// never settled (these are exempt from the 30-min auto-fail in bookingCleanup.ts, -// so this is their only seat-release path). -// In either case the payment (and its ticket) is silently moved to 'on_hold', which -// drops it out of the capacity-counting statuses ('pending', 'confirmed', 'checked_in') -// and so releases the seat back to the event. The user receives no notification — they -// can recover via "I've paid" again, and an admin can reactivate or mark it paid -// directly, both re-checking capacity. +// This job moves abandoned manual-payment bookings (bank transfer / TPago / cash) +// to 'on_hold' after HOLD_THRESHOLD_HOURS — bookings still in bare 'pending', i.e. +// the customer never clicked "I've paid" and no admin settled them. These are exempt +// from the 30-min auto-fail in bookingCleanup.ts, and under the capacity rule in +// lib/capacity.ts they hold no seat, so this sweep is pure list hygiene: it keeps +// dead checkouts out of the admin's pending queues. +// +// 'pending_approval' (customer claims they paid) is deliberately NOT swept: a +// claimed payment keeps its seat until an admin approves or rejects it — the admin +// UI surfaces aging claims instead of silently releasing them. +// +// The user receives no notification — they can recover via "I've paid", and an +// admin can approve/reactivate directly; every recovery path re-checks capacity. -import { and, or, eq, lt, inArray } from 'drizzle-orm'; +import { and, eq, lt, inArray } from 'drizzle-orm'; import { db, dbAll, tickets, payments } from '../db/index.js'; import { getNow, toDbDate } from './utils.js'; import { getLock } from './stores/lock.js'; -import { MANUAL_PAYMENT_PROVIDERS } from './manualProviders.js'; +import { MANUAL_PAYMENT_PROVIDERS } from './paymentProviders.js'; function getThresholdMs(): number { const hours = parseInt(process.env.HOLD_THRESHOLD_HOURS || '72', 10); @@ -25,9 +26,9 @@ function getThresholdMs(): number { } /** - * Move stale awaiting-verification payments (and their tickets) to 'on_hold'. - * Covers 'pending_approval' payments and 'pending' payments on manual providers. - * Returns the number of payments put on hold. + * Move stale unsettled manual payments (and their tickets) to 'on_hold'. + * Covers only bare 'pending' payments on manual providers; 'pending_approval' + * is never swept. Returns the number of payments put on hold. */ export async function sweepStaleApprovals(): Promise { const cutoff = toDbDate(new Date(Date.now() - getThresholdMs())); @@ -40,13 +41,8 @@ export async function sweepStaleApprovals(): Promise { }) .from(payments) .where(and( - or( - eq((payments as any).status, 'pending_approval'), - and( - eq((payments as any).status, 'pending'), - inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]) - ) - ), + eq((payments as any).status, 'pending'), + inArray((payments as any).provider, [...MANUAL_PAYMENT_PROVIDERS]), lt((payments as any).createdAt, cutoff) )) ); @@ -72,7 +68,7 @@ export async function sweepStaleApprovals(): Promise { )); } - console.log(`[HoldSweep] Put ${stale.length} stale awaiting-verification payment(s) on hold.`); + console.log(`[HoldSweep] Put ${stale.length} stale unsettled manual payment(s) on hold.`); return stale.length; } diff --git a/backend/src/lib/manualProviders.ts b/backend/src/lib/manualProviders.ts deleted file mode 100644 index 5252755..0000000 --- a/backend/src/lib/manualProviders.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Payment providers that require a human to verify the money arrived. -// -// These methods (bank transfer / TPago / cash) are never auto-confirmed and — -// crucially — are never auto-failed by the stale-booking cleanup: an admin settles -// them by hand. Note this is broader than the set of methods that expose an online -// "I've paid" step (bank transfer / TPago only); cash is settled at the door. -export const MANUAL_PAYMENT_PROVIDERS = ['bank_transfer', 'tpago', 'cash'] as const; diff --git a/backend/src/lib/paymentProviders.ts b/backend/src/lib/paymentProviders.ts new file mode 100644 index 0000000..83ad086 --- /dev/null +++ b/backend/src/lib/paymentProviders.ts @@ -0,0 +1,37 @@ +// Payment provider registry. +// +// Every provider is either: +// - 'automatic': the gateway itself confirms the payment (webhook/invoice +// settlement) and the booking is auto-approved on success. No admin involved. +// Currently Lightning; future online gateways (e.g. Stripe) go here. +// - 'manual': a human must verify the money arrived (TPago, bank transfer, +// card handled offline, cash at the door). These are never auto-confirmed +// and never auto-failed; an admin settles them by hand. Bank transfer and +// TPago additionally expose an online "I've paid" step that moves the +// payment to 'pending_approval'. +// +// Capacity note (see lib/capacity.ts): only paid/checked-in tickets and +// 'pending_approval' payments hold a seat. A bare 'pending' payment — of either +// kind — holds no seat, so an abandoned checkout can never block sales. + +export type PaymentProviderKind = 'automatic' | 'manual'; + +export const PAYMENT_PROVIDERS: Record = { + lightning: { kind: 'automatic' }, + tpago: { kind: 'manual' }, + bank_transfer: { kind: 'manual' }, + card: { kind: 'manual' }, + cash: { kind: 'manual' }, +}; + +export const MANUAL_PAYMENT_PROVIDERS = Object.keys(PAYMENT_PROVIDERS).filter( + (p) => PAYMENT_PROVIDERS[p].kind === 'manual' +); + +export function isManualProvider(provider: string): boolean { + return PAYMENT_PROVIDERS[provider]?.kind === 'manual'; +} + +export function isAutomaticProvider(provider: string): boolean { + return PAYMENT_PROVIDERS[provider]?.kind === 'automatic'; +} diff --git a/backend/src/lib/redis.ts b/backend/src/lib/redis.ts index 4c53bca..ccdca88 100644 --- a/backend/src/lib/redis.ts +++ b/backend/src/lib/redis.ts @@ -4,7 +4,15 @@ // before with in-memory backends. When set, this module owns a single shared // command connection plus a dedicated subscriber connection (a connection in // subscribe mode cannot run normal commands), with auto-reconnect, capped -// backoff, and a health flag that callers and the health endpoint can read. +// backoff, an active PING probe, and a health flag that callers and the health +// endpoint can read. +// +// TLS: use a rediss:// URL — ioredis enables TLS from the scheme. A /N path +// selects a DB index (e.g. redis://host:6379/1) when sharing an instance. +// We deliberately do not use ioredis's keyPrefix option: all keys are already +// namespaced per subsystem (cache:, rl:, lock:, lockout:), and keyPrefix has a +// pub/sub asymmetry (SUBSCRIBE channels get prefixed, PUBLISH channels do not) +// that would silently break the payment SSE channel. import Redis from 'ioredis'; @@ -12,6 +20,14 @@ let client: Redis | null = null; let subscriber: Redis | null = null; let healthy = false; let initialized = false; +let pingTimer: ReturnType | null = null; +let lastPingOkAt: string | null = null; +let lastPingMs: number | null = null; + +// Debounce repeated error logs during a sustained outage: transitions are +// always logged, repeated per-retry errors at most once per LOG_EVERY_MS. +const ERROR_LOG_EVERY_MS = 30_000; +let lastErrorLogAt = 0; /** Whether Redis is configured via REDIS_URL. */ export function isRedisEnabled(): boolean { @@ -23,14 +39,41 @@ export function isRedisHealthy(): boolean { return isRedisEnabled() && healthy; } +/** Detail for the health endpoint: when the last successful PING happened. */ +export function getRedisHealthDetail(): { lastPingOkAt: string | null; lastPingMs: number | null } { + return { lastPingOkAt, lastPingMs }; +} + +function setHealthy(next: boolean, context: string): void { + if (next !== healthy) { + if (next) { + console.log(`[redis] healthy (${context})`); + } else { + console.warn(`[redis] unhealthy (${context})`); + } + } + healthy = next; +} + +function logErrorDebounced(label: string, err: unknown): void { + const now = Date.now(); + if (now - lastErrorLogAt >= ERROR_LOG_EVERY_MS) { + lastErrorLogAt = now; + console.error(`[redis] (${label}) error:`, (err as any)?.message || err); + } +} + function buildClient(label: string): Redis { const url = process.env.REDIS_URL as string; const instance = new Redis(url, { // Keep the process responsive: fail fast on a per-command basis and let the - // callers degrade to their in-memory fallback rather than hanging. + // callers degrade to their in-memory fallback rather than hanging. Do not + // queue commands while disconnected — with fail-open callers everywhere a + // growing offline queue would only add latency and memory pressure. maxRetriesPerRequest: 1, enableOfflineQueue: false, lazyConnect: false, + connectTimeout: 5000, retryStrategy(times) { // Capped exponential backoff for reconnects: 200ms, 400ms ... max 5s. const delay = Math.min(times * 200, 5000); @@ -42,30 +85,53 @@ function buildClient(label: string): Redis { console.log(`[redis] (${label}) connecting`); }); instance.on('ready', () => { - healthy = true; - console.log(`[redis] (${label}) ready`); + setHealthy(true, `${label} ready`); }); instance.on('error', (err) => { - healthy = false; - console.error(`[redis] (${label}) error:`, err?.message || err); + setHealthy(false, `${label} error`); + logErrorDebounced(label, err); }); instance.on('reconnecting', () => { - healthy = false; - console.warn(`[redis] (${label}) reconnecting`); + setHealthy(false, `${label} reconnecting`); }); instance.on('end', () => { - healthy = false; - console.warn(`[redis] (${label}) connection closed`); + setHealthy(false, `${label} connection closed`); }); return instance; } +// Actively probe the command connection. The event-driven flag alone misses a +// silently hung connection; a periodic PING with a hard timeout catches it. +async function pingOnce(): Promise { + if (!client) return; + const started = Date.now(); + try { + await Promise.race([ + client.ping(), + new Promise((_, reject) => { + const t = setTimeout(() => reject(new Error('ping timeout')), 2000); + (t as any).unref?.(); + }), + ]); + lastPingMs = Date.now() - started; + lastPingOkAt = new Date().toISOString(); + setHealthy(true, 'ping ok'); + } catch (err) { + setHealthy(false, 'ping failed'); + logErrorDebounced('ping', err); + } +} + function ensureInit(): void { if (initialized || !isRedisEnabled()) return; initialized = true; client = buildClient('commands'); subscriber = buildClient('subscriber'); + pingTimer = setInterval(() => { + void pingOnce(); + }, 10_000); + (pingTimer as any).unref?.(); } /** Shared command connection, or null when Redis is not configured. */ @@ -82,6 +148,10 @@ export function getSubscriber(): Redis | null { /** Close connections (used for graceful shutdown). */ export async function closeRedis(): Promise { + if (pingTimer) { + clearInterval(pingTimer); + pingTimer = null; + } const tasks: Promise[] = []; if (client) tasks.push(client.quit().catch(() => undefined)); if (subscriber) tasks.push(subscriber.quit().catch(() => undefined)); diff --git a/backend/src/lib/stores/lock.test.ts b/backend/src/lib/stores/lock.test.ts new file mode 100644 index 0000000..1c5b64e --- /dev/null +++ b/backend/src/lib/stores/lock.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import RedisMock from 'ioredis-mock'; + +const mocks = vi.hoisted(() => ({ redis: null as any })); + +vi.mock('../redis.js', () => ({ + isRedisEnabled: () => true, + getRedis: () => mocks.redis, +})); + +import { MemoryLock, RedisLock, LockUnavailableError } from './lock.js'; + +describe('MemoryLock', () => { + it('grants the lock, blocks contenders, and frees on release', async () => { + const lock = new MemoryLock(); + const token = await lock.acquire('a', 60_000); + expect(token).toBeTruthy(); + expect(await lock.acquire('a', 60_000)).toBeNull(); + await lock.release('a', token!); + expect(await lock.acquire('a', 60_000)).toBeTruthy(); + }); + + it('ignores release with the wrong token', async () => { + const lock = new MemoryLock(); + const token = await lock.acquire('a', 60_000); + await lock.release('a', 'not-the-token'); + expect(await lock.acquire('a', 60_000)).toBeNull(); + await lock.release('a', token!); + }); + + it('withLock runs fn while held and returns null under contention', async () => { + const lock = new MemoryLock(); + const held = await lock.acquire('a', 60_000); + expect(await lock.withLock('a', 60_000, async () => 'ran')).toBeNull(); + await lock.release('a', held!); + expect(await lock.withLock('a', 60_000, async () => 'ran')).toBe('ran'); + // Released after withLock completes. + expect(await lock.acquire('a', 60_000)).toBeTruthy(); + }); +}); + +describe('RedisLock', () => { + beforeEach(async () => { + mocks.redis = new RedisMock(); + // ioredis-mock shares data between instances by connection string. + await mocks.redis.flushall(); + }); + + it('grants the lock and blocks contenders', async () => { + const lock = new RedisLock(); + const token = await lock.acquire('a', 60_000); + expect(token).toBeTruthy(); + expect(await lock.acquire('a', 60_000)).toBeNull(); + }); + + it('release is compare-and-delete: wrong token does not free the lock', async () => { + const lock = new RedisLock(); + const token = await lock.acquire('a', 60_000); + await lock.release('a', 'not-the-token'); + expect(await lock.acquire('a', 60_000)).toBeNull(); + await lock.release('a', token!); + expect(await lock.acquire('a', 60_000)).toBeTruthy(); + }); + + it('throws LockUnavailableError when the client is not initialized', async () => { + mocks.redis = null; + const lock = new RedisLock(); + await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError); + }); + + it('throws LockUnavailableError when the backend errors', async () => { + mocks.redis = { set: () => Promise.reject(new Error('connection refused')) }; + const lock = new RedisLock(); + await expect(lock.acquire('a', 60_000)).rejects.toBeInstanceOf(LockUnavailableError); + }); + + it('withLock skips (returns null, fn not called) when unavailable by default', async () => { + mocks.redis = null; + const lock = new RedisLock(); + const fn = vi.fn(async () => 'ran'); + expect(await lock.withLock('a', 60_000, fn)).toBeNull(); + expect(fn).not.toHaveBeenCalled(); + }); + + it('withLock runs fn without the lock when onUnavailable is "run"', async () => { + mocks.redis = null; + const lock = new RedisLock(); + const fn = vi.fn(async () => 'ran'); + expect(await lock.withLock('a', 60_000, fn, { onUnavailable: 'run' })).toBe('ran'); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('withLock returns fn result and releases the lock afterwards', async () => { + const lock = new RedisLock(); + expect(await lock.withLock('a', 60_000, async () => 42)).toBe(42); + expect(await lock.acquire('a', 60_000)).toBeTruthy(); + }); +}); diff --git a/backend/src/lib/stores/lock.ts b/backend/src/lib/stores/lock.ts index 10950ae..e673031 100644 --- a/backend/src/lib/stores/lock.ts +++ b/backend/src/lib/stores/lock.ts @@ -5,22 +5,45 @@ // // Use acquire/release for long-lived ownership (e.g. a background poller) and // withLock for a one-shot critical section. Selection is based on REDIS_URL. +// +// There is deliberately no auto-renewal/watchdog: every guarded section (sweep +// jobs, template seeding) is a handful of status-conditional bulk UPDATEs that +// finish far below the lock TTL, and a rare TTL overrun only risks one +// idempotent overlapping run. Keep TTLs generous instead of adding renewal. import { randomUUID } from 'crypto'; import { getRedis, isRedisEnabled } from '../redis.js'; +// Thrown when Redis is configured but the lock backend cannot be reached. +// This is distinct from contention (acquire resolves null): the caller must +// decide whether its critical section is safe to run without mutual exclusion. +export class LockUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'LockUnavailableError'; + } +} + +export interface WithLockOptions { + // What withLock should do when the lock backend is unavailable (not mere + // contention): 'skip' (default) returns null as if the lock were held + // elsewhere; 'run' executes fn without mutual exclusion. + onUnavailable?: 'skip' | 'run'; +} + export interface Lock { readonly backend: 'memory' | 'redis'; // Returns a token when the lock was acquired, or null when already held. + // Throws LockUnavailableError when the backend is configured but erroring. acquire(key: string, ttlMs: number): Promise; release(key: string, token: string): Promise; // Runs fn while holding the lock; returns fn's result, or null if not acquired. - withLock(key: string, ttlMs: number, fn: () => Promise): Promise; + withLock(key: string, ttlMs: number, fn: () => Promise, opts?: WithLockOptions): Promise; } // ==================== Memory implementation ==================== -class MemoryLock implements Lock { +export class MemoryLock implements Lock { readonly backend = 'memory' as const; private held = new Map(); @@ -58,23 +81,23 @@ class MemoryLock implements Lock { const RELEASE_SCRIPT = 'if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end'; -class RedisLock implements Lock { +export class RedisLock implements Lock { readonly backend = 'redis' as const; async acquire(key: string, ttlMs: number): Promise { const redis = getRedis(); if (!redis) { - // Redis configured but unavailable: do not block critical sections. - return randomUUID(); + throw new LockUnavailableError('redis client not initialized'); } const token = randomUUID(); try { const result = await redis.set(`lock:${key}`, token, 'PX', ttlMs, 'NX'); return result === 'OK' ? token : null; } catch (err: any) { - console.error('[lock] redis acquire error, proceeding without lock:', err?.message || err); - // Fail open so a Redis outage does not deadlock startup or jobs. - return randomUUID(); + // Do NOT fabricate a token here: during an outage every replica would + // "acquire" every lock and run the guarded sections concurrently. Let the + // caller choose between skipping the run and running unlocked. + throw new LockUnavailableError(err?.message || String(err)); } } @@ -88,8 +111,19 @@ class RedisLock implements Lock { } } - async withLock(key: string, ttlMs: number, fn: () => Promise): Promise { - const token = await this.acquire(key, ttlMs); + async withLock(key: string, ttlMs: number, fn: () => Promise, opts?: WithLockOptions): Promise { + let token: string | null; + try { + token = await this.acquire(key, ttlMs); + } catch (err) { + if (!(err instanceof LockUnavailableError)) throw err; + if (opts?.onUnavailable === 'run') { + console.warn(`[lock] backend unavailable, running "${key}" WITHOUT mutual exclusion:`, err.message); + return fn(); + } + console.warn(`[lock] backend unavailable, skipping "${key}" this run:`, err.message); + return null; + } if (!token) return null; try { return await fn(); diff --git a/backend/src/lib/stores/loginLockout.test.ts b/backend/src/lib/stores/loginLockout.test.ts new file mode 100644 index 0000000..c3a02ef --- /dev/null +++ b/backend/src/lib/stores/loginLockout.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import RedisMock from 'ioredis-mock'; + +const mocks = vi.hoisted(() => ({ redis: null as any })); + +vi.mock('../redis.js', () => ({ + isRedisEnabled: () => true, + getRedis: () => mocks.redis, +})); + +import { + MemoryLoginLockout, + RedisLoginLockout, + MAX_LOGIN_ATTEMPTS, +} from './loginLockout.js'; + +describe('MemoryLoginLockout', () => { + it('locks after MAX_LOGIN_ATTEMPTS failures with retryAfter', async () => { + const lockout = new MemoryLoginLockout(); + for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) { + await lockout.recordFailure('user@example.com'); + expect((await lockout.isLocked('user@example.com')).locked).toBe(false); + } + await lockout.recordFailure('user@example.com'); + const status = await lockout.isLocked('user@example.com'); + expect(status.locked).toBe(true); + expect(status.retryAfter).toBeGreaterThan(0); + }); + + it('clear removes the lockout', async () => { + const lockout = new MemoryLoginLockout(); + for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) { + await lockout.recordFailure('user@example.com'); + } + await lockout.clear('user@example.com'); + expect((await lockout.isLocked('user@example.com')).locked).toBe(false); + }); + + it('treats emails case-insensitively', async () => { + const lockout = new MemoryLoginLockout(); + for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) { + await lockout.recordFailure('User@Example.com'); + } + expect((await lockout.isLocked('user@example.com')).locked).toBe(true); + }); + + it('expires after the lockout window', async () => { + vi.useFakeTimers(); + try { + const lockout = new MemoryLoginLockout(); + for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) { + await lockout.recordFailure('user@example.com'); + } + expect((await lockout.isLocked('user@example.com')).locked).toBe(true); + vi.advanceTimersByTime(16 * 60 * 1000); + expect((await lockout.isLocked('user@example.com')).locked).toBe(false); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('RedisLoginLockout', () => { + beforeEach(async () => { + mocks.redis = new RedisMock(); + // ioredis-mock shares data between instances by connection string. + await mocks.redis.flushall(); + }); + + it('locks after MAX_LOGIN_ATTEMPTS failures shared via redis', async () => { + const lockout = new RedisLoginLockout(); + for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) { + await lockout.recordFailure('user@example.com'); + expect((await lockout.isLocked('user@example.com')).locked).toBe(false); + } + await lockout.recordFailure('user@example.com'); + const status = await lockout.isLocked('user@example.com'); + expect(status.locked).toBe(true); + expect(status.retryAfter).toBeGreaterThan(0); + }); + + it('sets the lockout window TTL on the first failure', async () => { + const lockout = new RedisLoginLockout(); + await lockout.recordFailure('user@example.com'); + const ttl = await mocks.redis.pttl('lockout:user@example.com'); + expect(ttl).toBeGreaterThan(0); + }); + + it('clear removes the lockout', async () => { + const lockout = new RedisLoginLockout(); + for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) { + await lockout.recordFailure('user@example.com'); + } + await lockout.clear('user@example.com'); + expect((await lockout.isLocked('user@example.com')).locked).toBe(false); + }); + + it('treats emails case-insensitively', async () => { + const lockout = new RedisLoginLockout(); + for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) { + await lockout.recordFailure('User@Example.com'); + } + expect((await lockout.isLocked('user@example.com')).locked).toBe(true); + }); + + it('fails open when the client is not initialized', async () => { + mocks.redis = null; + const lockout = new RedisLoginLockout(); + await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined(); + expect((await lockout.isLocked('user@example.com')).locked).toBe(false); + }); + + it('fails open when the backend errors', async () => { + mocks.redis = { + get: () => Promise.reject(new Error('connection refused')), + pttl: () => Promise.reject(new Error('connection refused')), + del: () => Promise.reject(new Error('connection refused')), + lockoutRecordFailure: () => Promise.reject(new Error('connection refused')), + }; + const lockout = new RedisLoginLockout(); + await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined(); + expect((await lockout.isLocked('user@example.com')).locked).toBe(false); + await expect(lockout.clear('user@example.com')).resolves.toBeUndefined(); + }); +}); diff --git a/backend/src/lib/stores/loginLockout.ts b/backend/src/lib/stores/loginLockout.ts new file mode 100644 index 0000000..c821078 --- /dev/null +++ b/backend/src/lib/stores/loginLockout.ts @@ -0,0 +1,164 @@ +// Per-email login lockout abstraction with two implementations: +// - memory: per-process Map (the original routes/auth.ts behavior) +// - redis: shared counter across all instances so the lockout cannot be +// bypassed by round-robining replicas +// +// Semantics: recordFailure starts a window on the first failure; once the +// failure count reaches the max the email is locked until the window expires; +// clear removes the counter on successful login. Selection is based on +// REDIS_URL. The redis implementation fails open (never locked, failures not +// recorded) — the per-IP auth rate limit remains as a backstop during a +// Redis outage. + +import type Redis from 'ioredis'; +import { getRedis, isRedisEnabled } from '../redis.js'; + +export const MAX_LOGIN_ATTEMPTS = 5; +export const LOCKOUT_DURATION_MS = 15 * 60 * 1000; // 15 minutes + +export interface LockoutStatus { + locked: boolean; + // Seconds until the lockout lifts; only set when locked. + retryAfter?: number; +} + +export interface LoginLockout { + readonly backend: 'memory' | 'redis'; + isLocked(email: string): Promise; + recordFailure(email: string): Promise; + clear(email: string): Promise; +} + +// Emails are compared case-insensitively so "User@x.com" and "user@x.com" +// share one counter. +function normalize(email: string): string { + return email.trim().toLowerCase(); +} + +// ==================== Memory implementation ==================== + +export class MemoryLoginLockout implements LoginLockout { + readonly backend = 'memory' as const; + private attempts = new Map(); + + constructor() { + // Periodically drop expired entries so the Map does not grow unbounded. + const cleanup = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of this.attempts) { + if (now > entry.resetAt) this.attempts.delete(key); + } + }, 60_000); + (cleanup as any).unref?.(); + } + + async isLocked(email: string): Promise { + const entry = this.attempts.get(normalize(email)); + const now = Date.now(); + if (!entry || now > entry.resetAt) return { locked: false }; + if (entry.count >= MAX_LOGIN_ATTEMPTS) { + return { locked: true, retryAfter: Math.ceil((entry.resetAt - now) / 1000) }; + } + return { locked: false }; + } + + async recordFailure(email: string): Promise { + const key = normalize(email); + const now = Date.now(); + const entry = this.attempts.get(key); + if (!entry || now > entry.resetAt) { + this.attempts.set(key, { count: 1, resetAt: now + LOCKOUT_DURATION_MS }); + return; + } + entry.count++; + } + + async clear(email: string): Promise { + this.attempts.delete(normalize(email)); + } +} + +// ==================== Redis implementation ==================== + +// Same atomic INCR+PEXPIRE shape as the rate limiter's consume script: the +// window starts at the first failure and any TTL-less counter is repaired. +const RECORD_FAILURE_SCRIPT = ` +local count = redis.call('INCR', KEYS[1]) +if count == 1 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) +end +if redis.call('PTTL', KEYS[1]) < 0 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) +end +return count +`; + +type RedisWithLockout = Redis & { + lockoutRecordFailure(key: string, windowMs: number): Promise; +}; + +function withLockoutCommand(redis: Redis): RedisWithLockout { + if (typeof (redis as any).lockoutRecordFailure !== 'function') { + redis.defineCommand('lockoutRecordFailure', { numberOfKeys: 1, lua: RECORD_FAILURE_SCRIPT }); + } + return redis as RedisWithLockout; +} + +export class RedisLoginLockout implements LoginLockout { + readonly backend = 'redis' as const; + + private key(email: string): string { + return `lockout:${normalize(email)}`; + } + + async isLocked(email: string): Promise { + const redis = getRedis(); + if (!redis) return { locked: false }; + try { + const [count, ttl] = await Promise.all([ + redis.get(this.key(email)), + redis.pttl(this.key(email)), + ]); + if (count !== null && parseInt(count, 10) >= MAX_LOGIN_ATTEMPTS) { + const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(LOCKOUT_DURATION_MS / 1000); + return { locked: true, retryAfter }; + } + return { locked: false }; + } catch (err: any) { + // Fail open: the per-IP auth rate limit still applies. + console.error('[loginLockout] redis error, treating as unlocked:', err?.message || err); + return { locked: false }; + } + } + + async recordFailure(email: string): Promise { + const redis = getRedis(); + if (!redis) return; + try { + await withLockoutCommand(redis).lockoutRecordFailure(this.key(email), LOCKOUT_DURATION_MS); + } catch (err: any) { + console.error('[loginLockout] redis error recording failure:', err?.message || err); + } + } + + async clear(email: string): Promise { + const redis = getRedis(); + if (!redis) return; + try { + await redis.del(this.key(email)); + } catch (err: any) { + console.error('[loginLockout] redis error clearing failures:', err?.message || err); + } + } +} + +// ==================== Selection ==================== + +let instance: LoginLockout | null = null; + +export function getLoginLockout(): LoginLockout { + if (!instance) { + instance = isRedisEnabled() ? new RedisLoginLockout() : new MemoryLoginLockout(); + } + return instance; +} diff --git a/backend/src/lib/stores/rateLimiter.test.ts b/backend/src/lib/stores/rateLimiter.test.ts new file mode 100644 index 0000000..c53b612 --- /dev/null +++ b/backend/src/lib/stores/rateLimiter.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import RedisMock from 'ioredis-mock'; + +const mocks = vi.hoisted(() => ({ redis: null as any })); + +vi.mock('../redis.js', () => ({ + isRedisEnabled: () => true, + getRedis: () => mocks.redis, +})); + +import { MemoryRateLimiter, RedisRateLimiter } from './rateLimiter.js'; + +describe('MemoryRateLimiter', () => { + it('allows up to max within the window, then blocks with retryAfter', async () => { + const limiter = new MemoryRateLimiter(); + for (let i = 0; i < 3; i++) { + expect((await limiter.consume('k', 3, 60_000)).allowed).toBe(true); + } + const blocked = await limiter.consume('k', 3, 60_000); + expect(blocked.allowed).toBe(false); + expect(blocked.retryAfter).toBeGreaterThan(0); + expect(blocked.retryAfter).toBeLessThanOrEqual(60); + }); + + it('resets after the window elapses', async () => { + vi.useFakeTimers(); + try { + const limiter = new MemoryRateLimiter(); + expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true); + expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(false); + vi.advanceTimersByTime(1_500); + expect((await limiter.consume('k', 1, 1_000)).allowed).toBe(true); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('RedisRateLimiter', () => { + beforeEach(async () => { + mocks.redis = new RedisMock(); + // ioredis-mock shares data between instances by connection string. + await mocks.redis.flushall(); + }); + + it('sets the window TTL atomically on the first hit', async () => { + const limiter = new RedisRateLimiter(); + expect((await limiter.consume('k', 5, 60_000)).allowed).toBe(true); + const ttl = await mocks.redis.pttl('rl:k'); + expect(ttl).toBeGreaterThan(0); + expect(ttl).toBeLessThanOrEqual(60_000); + }); + + it('blocks over the limit with a sane retryAfter', async () => { + const limiter = new RedisRateLimiter(); + for (let i = 0; i < 2; i++) { + expect((await limiter.consume('k', 2, 60_000)).allowed).toBe(true); + } + const blocked = await limiter.consume('k', 2, 60_000); + expect(blocked.allowed).toBe(false); + expect(blocked.retryAfter).toBeGreaterThan(0); + expect(blocked.retryAfter).toBeLessThanOrEqual(60); + }); + + it('self-heals a counter stranded without a TTL', async () => { + await mocks.redis.set('rl:k', '3'); + expect(await mocks.redis.pttl('rl:k')).toBeLessThan(0); + const limiter = new RedisRateLimiter(); + await limiter.consume('k', 10, 60_000); + expect(await mocks.redis.pttl('rl:k')).toBeGreaterThan(0); + }); + + it('fails open when the backend errors', async () => { + mocks.redis = { rlConsume: () => Promise.reject(new Error('connection refused')) }; + const limiter = new RedisRateLimiter(); + expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true); + }); + + it('fails open when the client is not initialized', async () => { + mocks.redis = null; + const limiter = new RedisRateLimiter(); + expect((await limiter.consume('k', 1, 60_000)).allowed).toBe(true); + }); +}); diff --git a/backend/src/lib/stores/rateLimiter.ts b/backend/src/lib/stores/rateLimiter.ts index 6cf9f34..8e9ef21 100644 --- a/backend/src/lib/stores/rateLimiter.ts +++ b/backend/src/lib/stores/rateLimiter.ts @@ -1,10 +1,13 @@ // Rate limiter abstraction with two implementations: // - memory: per-process fixed window (the original behavior) -// - redis: shared fixed window across all instances (INCR + PEXPIRE) +// - redis: shared fixed window across all instances, implemented as a single +// Lua script so INCR and PEXPIRE are atomic (a crash between separate calls +// would otherwise strand a counter with no expiry) // // Selection happens once based on REDIS_URL. On any Redis error the limiter // fails open (allows the request) so a Redis blip never takes the API down. +import type Redis from 'ioredis'; import { getRedis, isRedisEnabled } from '../redis.js'; export interface RateLimitResult { @@ -24,7 +27,7 @@ interface Bucket { resetAt: number; } -class MemoryRateLimiter implements RateLimiter { +export class MemoryRateLimiter implements RateLimiter { readonly backend = 'memory' as const; private buckets = new Map(); @@ -58,22 +61,44 @@ class MemoryRateLimiter implements RateLimiter { // ==================== Redis implementation ==================== -class RedisRateLimiter implements RateLimiter { +// Atomically increments the window counter, sets the expiry on the first hit, +// and repairs any counter left without a TTL (self-heals keys stranded by the +// pre-Lua implementation or a lost PEXPIRE). Returns {count, ttlMs}. +export const CONSUME_SCRIPT = ` +local count = redis.call('INCR', KEYS[1]) +if count == 1 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) +end +local ttl = redis.call('PTTL', KEYS[1]) +if ttl < 0 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) + ttl = tonumber(ARGV[1]) +end +return {count, ttl} +`; + +type RedisWithConsume = Redis & { + rlConsume(key: string, windowMs: number): Promise<[number, number]>; +}; + +function withConsumeCommand(redis: Redis): RedisWithConsume { + if (typeof (redis as any).rlConsume !== 'function') { + // ioredis caches the script SHA and transparently handles NOSCRIPT. + redis.defineCommand('rlConsume', { numberOfKeys: 1, lua: CONSUME_SCRIPT }); + } + return redis as RedisWithConsume; +} + +export class RedisRateLimiter implements RateLimiter { readonly backend = 'redis' as const; async consume(key: string, max: number, windowMs: number): Promise { const redis = getRedis(); if (!redis) return { allowed: true }; - const redisKey = `rl:${key}`; try { - const count = await redis.incr(redisKey); - if (count === 1) { - // First hit in this window: set the expiry that defines the window. - await redis.pexpire(redisKey, windowMs); - } + const [count, ttl] = await withConsumeCommand(redis).rlConsume(`rl:${key}`, windowMs); if (count > max) { - const ttl = await redis.pttl(redisKey); const retryAfter = ttl > 0 ? Math.ceil(ttl / 1000) : Math.ceil(windowMs / 1000); return { allowed: false, retryAfter }; } diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index c7a355f..609713f 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -3,6 +3,7 @@ import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubs import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm'; import { requireAuth } from '../lib/auth.js'; import { getNow } from '../lib/utils.js'; +import { eventSeatBreakdownQuery } from '../lib/capacity.js'; const adminRouter = new Hono(); @@ -20,8 +21,9 @@ const csvEscape = (value: string) => { adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => { const now = getNow(); - // Get upcoming events - const upcomingEvents = await dbAll( + // Get upcoming events with seat counts (paid + claimed, per lib/capacity.ts) + // so the dashboard's capacity alerts reflect real availability. + const upcomingEventsRaw = await dbAll( (db as any) .select() .from(events) @@ -34,6 +36,24 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => .orderBy((events as any).startDatetime) .limit(5) ); + + const seatRows = await dbAll(eventSeatBreakdownQuery(db)); + const seatsByEvent = new Map(); + for (const row of seatRows) { + seatsByEvent.set(row.eventId, { + paid: Number(row.paidCount) || 0, + claimed: Number(row.claimedCount) || 0, + }); + } + const upcomingEvents = upcomingEventsRaw.map((event: any) => { + const counts = seatsByEvent.get(event.id) || { paid: 0, claimed: 0 }; + return { + ...event, + bookedCount: counts.paid, + claimedCount: counts.claimed, + availableSeats: Math.max(0, (event.capacity || 0) - counts.paid - counts.claimed), + }; + }); // Get recent tickets const recentTickets = await dbAll( @@ -70,6 +90,8 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => .where(eq((tickets as any).status, 'confirmed')) ); + // 'pending' = checkout opened, nothing paid or claimed (informational); + // 'pending_approval' = customer says they paid, needs admin verification (actionable). const pendingPayments = await dbGet( (db as any) .select({ count: sql`count(*)` }) @@ -77,6 +99,13 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => .where(eq((payments as any).status, 'pending')) ); + const awaitingApprovalPayments = await dbGet( + (db as any) + .select({ count: sql`count(*)` }) + .from(payments) + .where(eq((payments as any).status, 'pending_approval')) + ); + const onHoldPayments = await dbGet( (db as any) .select({ count: sql`count(*)` }) @@ -115,6 +144,7 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => totalTickets: totalTickets?.count || 0, confirmedTickets: confirmedTickets?.count || 0, pendingPayments: pendingPayments?.count || 0, + awaitingApprovalPayments: awaitingApprovalPayments?.count || 0, onHoldPayments: onHoldPayments?.count || 0, totalRevenue, newContacts: newContacts?.count || 0, diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 8e65ee0..2528602 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -21,6 +21,7 @@ import { import { generateId, getNow, toDbBool } from '../lib/utils.js'; import { sendEmail } from '../lib/email.js'; import { rateLimitMiddleware } from '../lib/rateLimit.js'; +import { getLoginLockout } from '../lib/stores/loginLockout.js'; // Per-IP rate limit for sensitive auth endpoints (registration, login, and all // email-dispatching flows) to curb credential stuffing and email flooding. @@ -36,42 +37,6 @@ type AuthUser = User & { const auth = new Hono(); -// Rate limiting store (in production, use Redis) -const loginAttempts = new Map(); -const MAX_LOGIN_ATTEMPTS = 5; -const LOCKOUT_DURATION = 15 * 60 * 1000; // 15 minutes - -function checkRateLimit(email: string): { allowed: boolean; retryAfter?: number } { - const now = Date.now(); - const attempts = loginAttempts.get(email); - - if (!attempts) { - return { allowed: true }; - } - - if (now > attempts.resetAt) { - loginAttempts.delete(email); - return { allowed: true }; - } - - if (attempts.count >= MAX_LOGIN_ATTEMPTS) { - return { allowed: false, retryAfter: Math.ceil((attempts.resetAt - now) / 1000) }; - } - - return { allowed: true }; -} - -function recordFailedAttempt(email: string): void { - const now = Date.now(); - const attempts = loginAttempts.get(email) || { count: 0, resetAt: now + LOCKOUT_DURATION }; - attempts.count++; - loginAttempts.set(email, attempts); -} - -function clearFailedAttempts(email: string): void { - loginAttempts.delete(email); -} - const registerSchema = z.object({ email: z.string().email(), password: z.string().min(10, 'Password must be at least 10 characters'), @@ -188,12 +153,12 @@ auth.post('/register', authRateLimit, zValidator('json', registerSchema), async auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => { const data = c.req.valid('json'); - // Check rate limit - const rateLimit = checkRateLimit(data.email); - if (!rateLimit.allowed) { - return c.json({ + // Per-email lockout (shared across instances when Redis is configured). + const lockout = await getLoginLockout().isLocked(data.email); + if (lockout.locked) { + return c.json({ error: 'Too many login attempts. Please try again later.', - retryAfter: rateLimit.retryAfter + retryAfter: lockout.retryAfter }, 429); } @@ -201,7 +166,7 @@ auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => (db as any).select().from(users).where(eq((users as any).email, data.email)) ); if (!user) { - recordFailedAttempt(data.email); + await getLoginLockout().recordFailure(data.email); return c.json({ error: 'Invalid credentials' }, 401); } @@ -223,12 +188,12 @@ auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => const validPassword = await verifyPassword(data.password, user.password); if (!validPassword) { - recordFailedAttempt(data.email); + await getLoginLockout().recordFailure(data.email); return c.json({ error: 'Invalid credentials' }, 401); } // Clear failed attempts on successful login - clearFailedAttempts(data.email); + await getLoginLockout().clear(data.email); // Transparently upgrade legacy bcrypt hashes to argon2 now that we have the // plaintext and have verified it. Best-effort: a failure here must not block diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 07fe638..c032b84 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -7,6 +7,7 @@ import { requireAuth, getAuthUser } from '../lib/auth.js'; import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js'; import { slugify, uniqueSlug } from '../lib/slugify.js'; import { revalidateFrontendCache } from '../lib/revalidate.js'; +import { eventSeatBreakdownQuery } from '../lib/capacity.js'; interface UserContext { id: string; @@ -201,27 +202,28 @@ eventsRouter.get('/', async (c) => { const result = await dbAll(query.orderBy(desc((events as any).startDatetime))); - // Single grouped query for booked counts across all events (avoids N+1: previously - // this ran one COUNT query per event). - const countRows = await dbAll( - (db as any) - .select({ eventId: (tickets as any).eventId, count: sql`count(*)` }) - .from(tickets) - .where(sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`) - .groupBy((tickets as any).eventId) - ); - const countByEvent = new Map(); + // 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); + // claimedCount = "I've paid" claims awaiting admin verification. Both hold seats, + // so availableSeats subtracts them together — the same formula the booking-creation + // capacity check enforces (lib/capacity.ts). + const countRows = await dbAll(eventSeatBreakdownQuery(db)); + const countByEvent = new Map(); for (const row of countRows) { - countByEvent.set(row.eventId, Number(row.count) || 0); + countByEvent.set(row.eventId, { + paid: Number(row.paidCount) || 0, + claimed: Number(row.claimedCount) || 0, + }); } const eventsWithCounts = result.map((event: any) => { const normalized = normalizeEvent(event); - const bookedCount = countByEvent.get(event.id) || 0; + const counts = countByEvent.get(event.id) || { paid: 0, claimed: 0 }; return { ...normalized, - bookedCount, - availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), + bookedCount: counts.paid, + claimedCount: counts.claimed, + availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed), }; }); @@ -246,27 +248,14 @@ eventsRouter.get('/:id', async (c) => { } } - // Count confirmed AND checked_in tickets (checked_in were previously confirmed) - // This ensures check-in doesn't affect capacity/spots_left - const ticketCount = await dbGet( - (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, event.id), - sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` - ) - ) - ); - const normalized = normalizeEvent(event); - const bookedCount = ticketCount?.count || 0; + const counts = await getEventSeatCounts(event.id); return c.json({ event: { ...normalized, - bookedCount, - availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), + bookedCount: counts.paid, + claimedCount: counts.claimed, + availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed), }, }); }); @@ -278,20 +267,14 @@ async function getSiteTimezone(): Promise { return settings?.timezone || 'America/Asuncion'; } -// Helper function to get ticket count for an event -async function getEventTicketCount(eventId: string): Promise { - const ticketCount = await dbGet( - (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, eventId), - sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` - ) - ) - ); - return ticketCount?.count || 0; +// Helper: paid (confirmed/checked_in) and claimed (pending_approval-held) seat +// counts for one event — see lib/capacity.ts for the seat-holding rule. +async function getEventSeatCounts(eventId: string): Promise<{ paid: number; claimed: number }> { + const row = await dbGet(eventSeatBreakdownQuery(db, eventId)); + return { + paid: Number(row?.paidCount) || 0, + claimed: Number(row?.claimedCount) || 0, + }; } // Get the earliest upcoming published event with ticket counts (ignores featured promotion) @@ -315,12 +298,13 @@ async function getNextChronologicalUpcoming(): Promise { return null; } - const bookedCount = await getEventTicketCount(event.id); + const counts = await getEventSeatCounts(event.id); const normalized = normalizeEvent(event); return { ...normalized, - bookedCount, - availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), + bookedCount: counts.paid, + claimedCount: counts.claimed, + availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed), }; } @@ -383,13 +367,14 @@ eventsRouter.get('/next/upcoming', async (c) => { // If we have a valid featured event, return it if (featuredEvent) { - const bookedCount = await getEventTicketCount(featuredEvent.id); + const counts = await getEventSeatCounts(featuredEvent.id); const normalized = normalizeEvent(featuredEvent); return c.json({ event: { ...normalized, - bookedCount, - availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), + bookedCount: counts.paid, + claimedCount: counts.claimed, + availableSeats: calculateAvailableSeats(normalized.capacity, counts.paid + counts.claimed), isFeatured: true, }, }); diff --git a/backend/src/routes/lnbits.ts b/backend/src/routes/lnbits.ts index af34907..0a33c2c 100644 --- a/backend/src/routes/lnbits.ts +++ b/backend/src/routes/lnbits.ts @@ -12,7 +12,7 @@ import { } from '../lib/lnbits.js'; import emailService from '../lib/email.js'; import { getPubSub } from '../lib/stores/pubsub.js'; -import { getLock } from '../lib/stores/lock.js'; +import { getLock, LockUnavailableError } from '../lib/stores/lock.js'; const lnbitsRouter = new Hono(); @@ -106,12 +106,26 @@ async function startBackgroundChecker(ticketId: string, paymentHash: string, exp const expiryMs = expirySeconds * 1000; - const lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs); - if (!lockToken) { + let lockToken: string | null = null; + let lockUnavailable = false; + try { + lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs); + } catch (err) { + if (!(err instanceof LockUnavailableError)) throw err; + // Fail open: in webhook-less deployments this poller is the only way a + // Lightning payment gets confirmed, so a Redis outage must not stop it. + // Duplicate pollers across replicas are harmless because + // handlePaymentComplete only acts on rows it actually transitions. + console.warn(`[lnbits] lock backend unavailable, polling ticket ${ticketId} without lock:`, err.message); + lockUnavailable = true; + } + if (!lockToken && !lockUnavailable) { // Another instance is already polling this ticket. return; } - checkerLockTokens.set(ticketId, lockToken); + if (lockToken) { + checkerLockTokens.set(ticketId, lockToken); + } const startTime = Date.now(); let checkCount = 0; @@ -270,25 +284,35 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) { } // Confirm all tickets in the booking (idempotent: only flip pending -> confirmed) + let transitioned = 0; for (const ticket of ticketsToConfirm) { - await (db as any) + const result: any = await (db as any) .update(tickets) - .set({ status: 'confirmed' }) + .set({ status: 'confirmed', paymentStatus: 'paid' }) .where(and(eq((tickets as any).id, ticket.id), eq((tickets as any).status, 'pending'))); - + transitioned += result?.changes ?? result?.rowCount ?? 0; + await (db as any) .update(payments) - .set({ + .set({ status: 'paid', reference: paymentHash, paidAt: now, updatedAt: now, }) .where(and(eq((payments as any).ticketId, ticket.id), eq((payments as any).status, 'pending'))); - + console.log(`Ticket ${ticket.id} confirmed via Lightning payment (hash: ${paymentHash})`); } - + + // Only the caller that actually flipped rows sends the emails. The webhook + // and the background poller (and pollers on multiple replicas during a Redis + // outage) can all land here; without this guard they would each email. + if (transitioned === 0) { + console.log(`Ticket ${ticketId} was already confirmed by a concurrent caller, skipping emails`); + return; + } + // Get primary payment for sending receipt const payment = await dbGet( (db as any) @@ -296,7 +320,7 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) { .from(payments) .where(eq((payments as any).ticketId, ticketId)) ); - + // Send confirmation emails asynchronously // For multi-ticket bookings, send email with all ticket info Promise.all([ @@ -372,8 +396,7 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => { } }, 15000); - // Clean up on disconnect - stream.onAbort(() => { + const cleanup = () => { clearInterval(heartbeat); const connections = activeConnections.get(ticketId); if (connections) { @@ -388,11 +411,28 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => { } } } - }); - - // Keep the stream open - while (true) { - await stream.sleep(30000); + }; + + // Clean up on disconnect + stream.onAbort(cleanup); + + // Keep the stream open, and every 15s fall back to reading the ticket + // status from the DB. Pub/sub is best-effort: a message published while the + // subscriber connection was reconnecting is lost, and without this check + // the client would wait forever on a payment that already confirmed. + try { + while (true) { + await stream.sleep(15000); + const current = await dbGet( + (db as any).select().from(tickets).where(eq((tickets as any).id, ticketId)) + ); + if (current?.status === 'confirmed') { + await sendEvent({ type: 'paid', ticketId }); + return; + } + } + } finally { + cleanup(); } }); }); diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts index 1914b73..72ad423 100644 --- a/backend/src/routes/payments.ts +++ b/backend/src/routes/payments.ts @@ -19,6 +19,9 @@ const updatePaymentSchema = z.object({ const approvePaymentSchema = z.object({ adminNote: z.string().optional(), sendEmail: z.boolean().optional().default(true), + // Admin override: confirm the booking even when it puts the event over + // capacity. The UI asks for explicit confirmation before sending this. + allowOverCapacity: z.boolean().optional().default(false), }); const rejectPaymentSchema = z.object({ @@ -285,7 +288,7 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json await (db as any) .update(tickets) - .set({ status: 'confirmed' }) + .set({ status: 'confirmed', paymentStatus: 'paid' }) .where(eq((tickets as any).id, (t as any).id)); } @@ -317,29 +320,28 @@ paymentsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json // Approve payment (admin) - specifically for pending_approval payments paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValidator('json', approvePaymentSchema), async (c) => { const id = c.req.param('id'); - const { adminNote, sendEmail } = c.req.valid('json'); + const { adminNote, sendEmail, allowOverCapacity } = c.req.valid('json'); const user = (c as any).get('user'); - + const payment = await dbGet( (db as any) .select() .from(payments) .where(eq((payments as any).id, id)) ); - + if (!payment) { return c.json({ error: 'Payment not found' }, 404); } - + // Can approve pending, pending_approval, on_hold, or failed payments. // 'failed' covers an admin confirming a payment that was auto-failed or rejected // in error; its tickets are cancelled, so recovery re-checks capacity below. + // Bare 'pending' covers customers who paid but never clicked "I've paid". if (!['pending', 'pending_approval', 'on_hold', 'failed'].includes(payment.status)) { return c.json({ error: 'Payment cannot be approved in its current state' }, 400); } - const now = getNow(); - // Get the ticket associated with this payment const ticket = await dbGet( (db as any) @@ -381,55 +383,35 @@ paymentsRouter.post('/:id/approve', requireAuth(['admin', 'organizer']), zValida } } - if (payment.status === 'on_hold' || payment.status === 'failed') { - // The seat was released when this booking went on hold or failed - re-check - // capacity before confirming it directly. - try { - await reserveOnHoldBooking( - ticket.eventId, - ticketsToConfirm.map((t: any) => t.id), - 'confirmed', - 'paid', - { - paidByAdminId: user.id, - fromTicketStatuses: - payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold'], - } - ); - } catch (err) { - if (err instanceof HoldCapacityError) { - return c.json({ - error: 'This event is now full. Your spot was released after the payment deadline passed.', - }, 400); + // Confirm the booking through the shared capacity-checked reservation. + // Tickets that already hold a seat ('pending_approval' claims) cost no new + // capacity; unseated ones (bare 'pending', on_hold, failed/cancelled) do. + // When the event is full, the admin gets a structured over-capacity error and + // may retry with allowOverCapacity to knowingly overbook. + try { + await reserveOnHoldBooking( + ticket.eventId, + ticketsToConfirm.map((t: any) => t.id), + 'confirmed', + 'paid', + { + paidByAdminId: user.id, + fromTicketStatuses: + payment.status === 'failed' ? ['cancelled', 'on_hold', 'pending'] : ['on_hold', 'pending'], + skipCapacityCheck: allowOverCapacity, + ...(adminNote ? { extraPaymentFields: { adminNote } } : {}), } - throw err; - } - if (adminNote) { - await (db as any) - .update(payments) - .set({ adminNote }) - .where(inArray((payments as any).ticketId, ticketsToConfirm.map((t: any) => t.id))); - } - } else { - // Update all payments in the booking to paid - for (const t of ticketsToConfirm) { - await (db as any) - .update(payments) - .set({ - status: 'paid', - paidAt: now, - paidByAdminId: user.id, - adminNote: adminNote || payment.adminNote, - updatedAt: now, - }) - .where(eq((payments as any).ticketId, (t as any).id)); - - // Update ticket status to confirmed - await (db as any) - .update(tickets) - .set({ status: 'confirmed' }) - .where(eq((tickets as any).id, (t as any).id)); + ); + } catch (err) { + if (err instanceof HoldCapacityError) { + return c.json({ + error: 'Approving this payment puts the event over capacity.', + code: 'EVENT_OVER_CAPACITY', + availableSeats: err.available, + requestedSeats: ticketsToConfirm.length, + }, 409); } + throw err; } // Send confirmation emails asynchronously (if sendEmail is true, which is the default) diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 1ff4610..ff2c0fd 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -10,6 +10,7 @@ import { rateLimitMiddleware } from '../lib/rateLimit.js'; import emailService from '../lib/email.js'; import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js'; import { reserveOnHoldBooking, HoldCapacityError } from '../lib/holdRecovery.js'; +import { seatHolderCountQuery } from '../lib/capacity.js'; const ticketsRouter = new Hono(); @@ -128,21 +129,12 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { return c.json({ error: 'Selected payment method is not available for this event' }, 400); } - // Check capacity - count pending, confirmed AND checked_in tickets. - // Pending reservations must hold seats to prevent overselling via unpaid bookings - // (cancelled/failed tickets are excluded so abandoned/rejected bookings free their seats). + // Check capacity against held seats (paid/checked-in tickets plus claimed + // manual payments) — see lib/capacity.ts. Bare pending bookings hold no seat. const existingTicketCount = await dbGet( - (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, data.eventId), - sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')` - ) - ) + seatHolderCountQuery(db, data.eventId) ); - + const confirmedCount = existingTicketCount?.count || 0; const availableSeats = calculateAvailableSeats(event.capacity, confirmedCount); @@ -230,16 +222,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { try { if (isSqlite()) { (db as any).transaction((tx: any) => { - const countRow = tx - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, data.eventId), - sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')` - ) - ) - .get(); + const countRow = seatHolderCountQuery(tx, data.eventId).get(); const reserved = Number(countRow?.count || 0); if (isEventSoldOut(event.capacity, reserved)) { throw new BookingCapacityError('SOLD_OUT'); @@ -290,17 +273,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { }); } else { await (db as any).transaction(async (tx: any) => { - const countRow = await dbGet( - tx - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, data.eventId), - sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')` - ) - ) - ); + const countRow = await dbGet(seatHolderCountQuery(tx, data.eventId)); const reserved = Number(countRow?.count || 0); if (isEventSoldOut(event.capacity, reserved)) { throw new BookingCapacityError('SOLD_OUT'); @@ -388,7 +361,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => { for (const t of createdTickets) { await (db as any) .update(tickets) - .set({ status: 'confirmed' }) + .set({ status: 'confirmed', paymentStatus: 'paid' }) .where(and(eq((tickets as any).id, t.id), eq((tickets as any).status, 'pending'))); await (db as any) .update(payments) @@ -1029,6 +1002,9 @@ ticketsRouter.post('/validate', requireAuth(['admin', 'organizer', 'staff']), as attendeeEmail: ticket.attendeeEmail, attendeePhone: ticket.attendeePhone, status: ticket.status, + paymentStatus: ticket.paymentStatus, + // Balance to collect at the door for unpaid tickets + amountDue: ticket.paymentStatus === 'unpaid' && event ? event.price : 0, checkinAt: ticket.checkinAt, checkedInBy, }, @@ -1109,10 +1085,12 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff'] return c.json({ error: 'Ticket not found' }, 404); } - if (ticket.status === 'confirmed') { + // Confirmed/checked-in tickets can still be marked paid when they carry an + // unpaid balance (admin-added unpaid tickets collected at the door) + if (['confirmed', 'checked_in'].includes(ticket.status) && ticket.paymentStatus !== 'unpaid') { return c.json({ error: 'Ticket already confirmed' }, 400); } - + if (ticket.status === 'cancelled') { return c.json({ error: 'Cannot confirm cancelled ticket' }, 400); } @@ -1152,12 +1130,12 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff'] throw err; } } else { - // Confirm all tickets in the booking + // Confirm all tickets in the booking (checked-in tickets keep their status) for (const t of ticketsToConfirm) { // Update ticket status await (db as any) .update(tickets) - .set({ status: 'confirmed' }) + .set({ status: t.status === 'checked_in' ? 'checked_in' : 'confirmed', paymentStatus: 'paid' }) .where(eq((tickets as any).id, t.id)); // Update payment status @@ -1498,6 +1476,7 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, preferredLanguage: data.preferredLanguage || null, status: ticketStatus, + paymentStatus: 'paid', qrCode, checkinAt: data.autoCheckin ? now : null, adminNote: data.adminNote || null, @@ -1541,148 +1520,26 @@ ticketsRouter.post('/admin/create', requireAuth(['admin', 'organizer', 'staff']) }, 201); }); -// Admin create manual ticket (sends confirmation email + ticket to attendee) -ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({ - eventId: z.string(), - firstName: z.string().min(2), - lastName: z.string().optional().or(z.literal('')), - email: z.string().email('Valid email is required for manual tickets'), - phone: z.string().optional().or(z.literal('')), - preferredLanguage: z.enum(['en', 'es']).optional(), - adminNote: z.string().max(1000).optional(), -})), async (c) => { - const data = c.req.valid('json'); - - // Get event - const event = await dbGet( - (db as any).select().from(events).where(eq((events as any).id, data.eventId)) - ); - if (!event) { - return c.json({ error: 'Event not found' }, 404); - } - - // Admin manual ticket: bypass capacity check (allow over-capacity for admin-created tickets) - - const now = getNow(); - const attendeeEmail = data.email.trim(); - - // Find or create user - let user = await dbGet( - (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)) - ); - - const fullName = data.lastName && data.lastName.trim() - ? `${data.firstName} ${data.lastName}`.trim() - : data.firstName; - - if (!user) { - const userId = generateId(); - user = { - id: userId, - email: attendeeEmail, - password: '', - name: fullName, - phone: data.phone || null, - role: 'user', - languagePreference: null, - createdAt: now, - updatedAt: now, - }; - await (db as any).insert(users).values(user); - } - - // Check for existing active ticket for this user and event - const existingTicket = await dbGet( - (db as any) - .select() - .from(tickets) - .where( - and( - eq((tickets as any).userId, user.id), - eq((tickets as any).eventId, data.eventId) - ) - ) - ); - - if (existingTicket && existingTicket.status !== 'cancelled') { - return c.json({ error: 'This person already has a ticket for this event' }, 400); - } - - // Create ticket as confirmed - const ticketId = generateId(); - const qrCode = generateTicketCode(); - - const newTicket = { - id: ticketId, - userId: user.id, - eventId: data.eventId, - attendeeFirstName: data.firstName, - attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, - attendeeEmail: attendeeEmail, - attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, - preferredLanguage: data.preferredLanguage || null, - status: 'confirmed', - qrCode, - checkinAt: null, - adminNote: data.adminNote || null, - createdAt: now, - }; - - await (db as any).insert(tickets).values(newTicket); - - // Create payment record (marked as paid - manual entry) - const paymentId = generateId(); - const adminUser = (c as any).get('user'); - const newPayment = { - id: paymentId, - ticketId, - provider: 'cash', - amount: event.price, - currency: event.currency, - status: 'paid', - reference: 'Manual ticket', - paidAt: now, - paidByAdminId: adminUser?.id || null, - createdAt: now, - updatedAt: now, - }; - - await (db as any).insert(payments).values(newPayment); - - // Send booking confirmation email + ticket (asynchronously) - emailService.sendBookingConfirmation(ticketId).then(result => { - if (result.success) { - console.log(`[Email] Booking confirmation sent for manual ticket ${ticketId}`); - } else { - console.error(`[Email] Failed to send booking confirmation for manual ticket ${ticketId}:`, result.error); - } - }).catch(err => { - console.error('[Email] Exception sending booking confirmation for manual ticket:', err); - }); - - return c.json({ - ticket: { - ...newTicket, - event: { - title: event.title, - startDatetime: event.startDatetime, - location: event.location, - }, - }, - payment: newPayment, - message: 'Manual ticket created and confirmation email sent', - }, 201); -}); - -// Admin invite guest ticket (free, confirmed, not counted in revenue) -ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({ +// 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 +// 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), lastName: z.string().optional().or(z.literal('')), email: z.string().email().optional().or(z.literal('')), phone: z.string().optional().or(z.literal('')), preferredLanguage: z.enum(['en', 'es']).optional(), + checkinNow: z.boolean().optional().default(false), adminNote: z.string().max(1000).optional(), +}).refine((d) => d.type !== 'paid' || !!(d.email && d.email.trim()), { + message: 'Email is required for paid tickets', + path: ['email'], })), async (c) => { const data = c.req.valid('json'); @@ -1693,18 +1550,20 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), return c.json({ error: 'Event not found' }, 404); } + // Admin-added tickets bypass the capacity check (intentional over-capacity) + const now = getNow(); const adminUser = (c as any).get('user'); - - // Find or create user (use placeholder email if none provided) - const attendeeEmail = data.email && data.email.trim() - ? data.email.trim() - : `guest-${generateId()}@guestinvite.local`; + const hasEmail = !!(data.email && data.email.trim()); + const attendeeEmail = hasEmail + ? data.email!.trim() + : `${data.type === 'guest' ? 'guest' : 'door'}-${generateId()}@${data.type === 'guest' ? 'guestinvite' : 'doorentry'}.local`; const fullName = data.lastName && data.lastName.trim() ? `${data.firstName} ${data.lastName}`.trim() : data.firstName; + // Find or create user let user = await dbGet( (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)) ); @@ -1725,8 +1584,8 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), await (db as any).insert(users).values(user); } - // Check for existing active ticket (only for real emails, not placeholder) - if (data.email && data.email.trim()) { + // Check for existing active ticket (only when a real email was provided) + if (hasEmail) { const existingTicket = await dbGet( (db as any) .select() @@ -1745,6 +1604,7 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), const ticketId = generateId(); const qrCode = generateTicketCode(); + const paymentStatus = data.type === 'guest' ? 'comp' : data.type === 'paid' ? 'paid' : 'unpaid'; const newTicket = { id: ticketId, @@ -1752,50 +1612,85 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), eventId: data.eventId, attendeeFirstName: data.firstName, attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, - attendeeEmail: data.email && data.email.trim() ? data.email.trim() : null, + attendeeEmail: hasEmail ? data.email!.trim() : null, attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, preferredLanguage: data.preferredLanguage || null, - status: 'confirmed', - isGuest: 1, + status: data.checkinNow ? 'checked_in' : 'confirmed', + isGuest: data.type === 'guest' ? 1 : 0, + paymentStatus, qrCode, - checkinAt: null, + checkinAt: data.checkinNow ? now : null, + checkedInByAdminId: data.checkinNow ? adminUser?.id || null : null, adminNote: data.adminNote || null, createdAt: now, }; await (db as any).insert(tickets).values(newTicket); - // Create a $0 payment record to track the invite + // Payment record: paid cash for paid/guest ($0 for guest), pending tpago for unpaid const paymentId = generateId(); - const newPayment = { - id: paymentId, - ticketId, - provider: 'cash', - amount: 0, - currency: event.currency, - status: 'paid', - reference: 'Guest invite', - paidAt: now, - paidByAdminId: adminUser?.id || null, - createdAt: now, - updatedAt: now, - }; + const newPayment = data.type === 'unpaid' + ? { + id: paymentId, + ticketId, + provider: 'tpago', + amount: event.price, + currency: event.currency, + status: 'pending', + reference: 'Unpaid ticket — collect at door', + paidAt: null, + paidByAdminId: null, + createdAt: now, + updatedAt: now, + } + : { + id: paymentId, + ticketId, + provider: 'cash', + amount: data.type === 'guest' ? 0 : event.price, + currency: event.currency, + status: 'paid', + reference: data.type === 'guest' ? 'Guest invite' : 'Manual ticket', + paidAt: now, + paidByAdminId: adminUser?.id || null, + createdAt: now, + updatedAt: now, + }; await (db as any).insert(payments).values(newPayment); - // Send booking confirmation email if a real email was provided - if (data.email && data.email.trim()) { + // Emails (asynchronous): paid always confirms; guest confirms when an email + // exists; unpaid sends the TPago (Bancard) pay-link instructions instead + if (data.type === 'unpaid') { + if (hasEmail) { + emailService.sendPaymentInstructions(ticketId).then(result => { + if (!result.success) { + console.error(`[Email] Failed to send pay link for unpaid ticket ${ticketId}:`, result.error); + } + }).catch(err => { + console.error('[Email] Exception sending pay link for unpaid ticket:', err); + }); + } + } else if (data.type === 'paid' || hasEmail) { emailService.sendBookingConfirmation(ticketId).then(result => { - if (result.success) { - console.log(`[Email] Booking confirmation sent for guest ticket ${ticketId}`); - } else { - console.error(`[Email] Failed to send booking confirmation for guest ticket ${ticketId}:`, result.error); + if (!result.success) { + console.error(`[Email] Failed to send booking confirmation for ${data.type} ticket ${ticketId}:`, result.error); } }).catch(err => { - console.error('[Email] Exception sending booking confirmation for guest ticket:', err); + console.error(`[Email] Exception sending booking confirmation for ${data.type} ticket:`, err); }); } + const messages: Record = { + paid: 'Ticket created — confirmation email sent', + unpaid: hasEmail + ? 'Unpaid ticket created — payment link sent' + : 'Unpaid ticket created — collect payment at the door', + guest: hasEmail + ? 'Guest invited — confirmation email sent' + : 'Guest invited', + }; + return c.json({ ticket: { ...newTicket, @@ -1806,7 +1701,7 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), }, }, payment: newPayment, - message: 'Guest ticket created successfully', + message: data.checkinNow ? `${messages[data.type]} · checked in` : messages[data.type], }, 201); }); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 83e9234..d483629 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -12,5 +12,5 @@ "resolveJsonModule": true }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] } diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 0000000..6ec74ee --- /dev/null +++ b/backend/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}); diff --git a/deploy/back-end_nginx.conf b/deploy/back-end_nginx.conf index 589202d..ff48e0b 100644 --- a/deploy/back-end_nginx.conf +++ b/deploy/back-end_nginx.conf @@ -63,6 +63,29 @@ server { return 413 '{"error":"Payload too large (413). Please upload a smaller file."}'; } + # Photo gallery service (photo-api, port 3020). ^~ wins over the / + # prefix below; bigger body cap and unbuffered uploads for photo batches. + location ^~ /api/photos/ { + limit_req zone=spanglish_api_limit burst=50 nodelay; + + proxy_pass http://spanglish_photos; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Strip CORS headers from the service (nginx handles CORS here) + proxy_hide_header 'Access-Control-Allow-Origin'; + proxy_hide_header 'Access-Control-Allow-Methods'; + + client_max_body_size 100m; + proxy_request_buffering off; + proxy_read_timeout 300s; + proxy_connect_timeout 300s; + } + location / { limit_req zone=spanglish_api_limit burst=50 nodelay; diff --git a/deploy/docker-compose.scale.yml b/deploy/docker-compose.scale.yml index ed19405..1d89db8 100644 --- a/deploy/docker-compose.scale.yml +++ b/deploy/docker-compose.scale.yml @@ -28,7 +28,20 @@ services: redis: image: redis:7-alpine - command: ["redis-server", "--appendonly", "yes"] + # noeviction is deliberate: rate-limit, lock, and lockout keys must never + # be evicted for correctness. The cache workload is a single short-TTL key, + # so memory pressure is negligible; if caching ever grows, revisit this or + # move the cache to its own DB index. + command: + [ + "redis-server", + "--appendonly", "yes", + "--requirepass", "${REDIS_PASSWORD:-change-me-redis-password}", + "--maxmemory", "256mb", + "--maxmemory-policy", "noeviction", + ] + environment: + REDISCLI_AUTH: "${REDIS_PASSWORD:-change-me-redis-password}" volumes: - redisdata:/data healthcheck: @@ -46,7 +59,7 @@ services: DB_TYPE: postgres DATABASE_URL: postgresql://spanglish:spanglish@postgres:5432/spanglish DB_POOL_MAX: "15" - REDIS_URL: redis://redis:6379 + REDIS_URL: "redis://:${REDIS_PASSWORD:-change-me-redis-password}@redis:6379" JWT_SECRET: change-me-to-a-strong-secret FRONTEND_URL: http://localhost:8080 # Optional S3-compatible storage so uploads are shared across replicas. diff --git a/deploy/front-end_nginx.conf b/deploy/front-end_nginx.conf index 9b96a40..c6b6225 100644 --- a/deploy/front-end_nginx.conf +++ b/deploy/front-end_nginx.conf @@ -79,6 +79,24 @@ server { proxy_connect_timeout 300s; } + # Photo gallery service (photo-api, port 3020). ^~ wins over the /api + # prefix below. Batch photo uploads are large and slow, so the body cap + # is raised and request buffering is off for this location only. + location ^~ /api/photos/ { + proxy_pass http://spanglish_photos; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + client_max_body_size 100m; + proxy_request_buffering off; + proxy_read_timeout 300s; + proxy_connect_timeout 300s; + } + # Proxy /api to backend location /api { proxy_pass http://spanglish_backend; diff --git a/deploy/spanglish-photos.service b/deploy/spanglish-photos.service new file mode 100644 index 0000000..29de8cc --- /dev/null +++ b/deploy/spanglish-photos.service @@ -0,0 +1,30 @@ +[Unit] +Description=Spanglish Photo Gallery API +Documentation=https://git.azzamo.net/Michilis/Spanglish +After=network.target spanglish-backend.service + +[Service] +Type=simple +User=spanglish +Group=spanglish +WorkingDirectory=/home/spanglish/Spanglish/photo-api +EnvironmentFile=/home/spanglish/Spanglish/photo-api/.env +Environment=PORT=3020 +# Apply pending photos_* migrations before serving (idempotent, advisory-locked) +ExecStartPre=/home/spanglish/Spanglish/photo-api/bin/photo-api migrate +ExecStart=/home/spanglish/Spanglish/photo-api/bin/photo-api +Restart=on-failure +RestartSec=10 +StandardOutput=syslog +StandardError=syslog +SyslogIdentifier=spanglish-photos + +# Security hardening (mirrors spanglish-backend.service) +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/home/spanglish/Spanglish/photo-api/data + +[Install] +WantedBy=multi-user.target diff --git a/deploy/spanglish_upstreams.conf b/deploy/spanglish_upstreams.conf index 26b2462..f472d28 100644 --- a/deploy/spanglish_upstreams.conf +++ b/deploy/spanglish_upstreams.conf @@ -4,4 +4,8 @@ upstream spanglish_frontend { upstream spanglish_backend { server 127.0.0.1:3018; +} + +upstream spanglish_photos { + server 127.0.0.1:3020; } \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example index c7d9259..463c8ba 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -7,6 +7,11 @@ NEXT_PUBLIC_SITE_URL=https://spanglishcommunity.com # API URL (leave empty for same-origin proxy) NEXT_PUBLIC_API_URL= +# Photo gallery service (photo-api) origin, used by the /api/photos rewrite +# and server-side rendering of /photos pages. +# Dev default: http://localhost:3003 — production: http://127.0.0.1:3020 +PHOTO_API_URL= + # Google OAuth (optional - leave empty to hide Google Sign-In button) # Get your Client ID from: https://console.cloud.google.com/apis/credentials # 1. Create a new OAuth 2.0 Client ID (Web application) diff --git a/frontend/next.config.js b/frontend/next.config.js index f34c070..7f20796 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -4,6 +4,10 @@ // being hardcoded to localhost. const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001'; +// The standalone photo gallery service (photo-api/). Must be rewritten +// before the generic /api rule below — Next rewrites are order-sensitive. +const PHOTO_API_URL = process.env.PHOTO_API_URL || 'http://localhost:3003'; + // Extra image hosts can be allowed via a comma-separated env var (e.g. a CDN). const extraImageHosts = (process.env.NEXT_PUBLIC_IMAGE_HOSTS || '') .split(',') @@ -20,6 +24,9 @@ const securityHeaders = [ ]; const nextConfig = { + // Overridable so a production build can run while `next dev` holds .next + // (e.g. NEXT_DIST_DIR=.next-build npm run build). + distDir: process.env.NEXT_DIST_DIR || '.next', images: { // Restrict remote image sources to a known allowlist instead of allowing any // https host (which let the Next image optimizer be used as an open proxy). @@ -39,6 +46,10 @@ const nextConfig = { }, async rewrites() { return [ + { + source: '/api/photos/:path*', + destination: `${PHOTO_API_URL}/api/photos/:path*`, + }, { source: '/api/:path*', destination: `${BACKEND_URL}/api/:path*`, diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index e51fff0..a159ed5 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -5,7 +5,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api'; -import { formatDateLong, formatTime, formatRucDisplay } from '@/lib/utils'; +import { formatDateLong, formatTime, formatRucDisplay, eventSpotsLeft, isEventSoldOut } from '@/lib/utils'; import { isSafeExternalUrl } from '@/lib/safeRedirect'; import toast from 'react-hot-toast'; import type { @@ -108,16 +108,15 @@ export default function BookingPage() { return; } - const bookedCount = eventRes.event.bookedCount ?? 0; - const capacity = eventRes.event.capacity ?? 0; - const soldOut = bookedCount >= capacity; - if (soldOut) { + // Server-authoritative availability — same formula the booking API + // enforces, so a sold-out event is caught here, not at submit time. + if (isEventSoldOut(eventRes.event)) { toast.error(t('events.details.soldOut')); router.push(`/events/${eventRes.event.slug}`); return; } - const spotsLeft = Math.max(0, capacity - bookedCount); + const spotsLeft = eventSpotsLeft(eventRes.event); setEvent(eventRes.event); // Cap quantity by available spots (never allow requesting more than spotsLeft) setTicketQuantity((q) => Math.min(q, Math.max(1, spotsLeft))); @@ -366,6 +365,23 @@ export default function BookingPage() { } } catch (error: any) { toast.error(error.message || t('booking.form.errors.bookingFailed')); + // Capacity race on the last seats: refresh availability so the page + // reflects reality (sold-out block / lower quantity cap) instead of the + // stale counts loaded when the form was opened. + const message = String(error?.message || ''); + if (/sold out|seats available/i.test(message)) { + try { + const { event: freshEvent } = await eventsApi.getById(params.eventId as string); + setEvent(freshEvent); + const freshSpots = eventSpotsLeft(freshEvent); + if (freshSpots > 0) { + setTicketQuantity((q) => Math.min(q, freshSpots)); + setAttendees((prev) => prev.slice(0, Math.max(0, freshSpots - 1))); + } + } catch { + // Keep the stale event state if the refresh fails + } + } } finally { setSubmitting(false); } @@ -388,8 +404,8 @@ export default function BookingPage() { return null; } - const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0)); - const isSoldOut = (event.bookedCount ?? 0) >= event.capacity; + const spotsLeft = eventSpotsLeft(event); + const isSoldOut = isEventSoldOut(event); // Paying step - waiting for Lightning payment (compact design) if (step === 'paying' && bookingResult && bookingResult.lightningInvoice) { diff --git a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx index fa959fa..ba0eb77 100644 --- a/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx +++ b/frontend/src/app/(public)/events/[id]/EventDetailClient.tsx @@ -5,7 +5,7 @@ import Link from 'next/link'; import Image from 'next/image'; import { useLanguage } from '@/context/LanguageContext'; import { eventsApi, Event } from '@/lib/api'; -import { formatPrice, formatDateLong, formatTime } from '@/lib/utils'; +import { formatPrice, formatDateLong, formatTime, eventSpotsLeft, isEventSoldOut } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import ShareButtons from '@/components/ShareButtons'; @@ -43,9 +43,10 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail .catch(console.error); }, [eventId]); - // Spots left: never negative; sold out when confirmed >= capacity - const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0)); - const isSoldOut = (event.bookedCount ?? 0) >= event.capacity; + // Server-authoritative availability (paid + claimed seats count; abandoned + // pending bookings don't) — matches the booking API's sold-out check exactly. + const spotsLeft = eventSpotsLeft(event); + const isSoldOut = isEventSoldOut(event); const maxTickets = isSoldOut ? 0 : Math.min(MAX_TICKETS_PER_PERSON, Math.max(1, spotsLeft)); useEffect(() => { diff --git a/frontend/src/app/(public)/events/[id]/gallery/page.tsx b/frontend/src/app/(public)/events/[id]/gallery/page.tsx new file mode 100644 index 0000000..1d4b005 --- /dev/null +++ b/frontend/src/app/(public)/events/[id]/gallery/page.tsx @@ -0,0 +1,50 @@ +import type { Metadata } from 'next'; +import { Suspense } from 'react'; +import type { PhotoGallery, Photo } from '@/lib/api'; +import GalleryClient from '@/app/(public)/photos/[slug]/GalleryClient'; + +const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003'; + +interface PageProps { + params: { id: string }; // event slug, same param name as the parent route +} + +// Public event galleries render server-side; link/ticket galleries return +// null here and are fetched client-side with the viewer's token. +async function getEventGallery( + eventSlug: string +): Promise<{ gallery: PhotoGallery; photos: Photo[] } | null> { + try { + const res = await fetch( + `${photoApiUrl}/api/photos/public/events/${encodeURIComponent(eventSlug)}/gallery`, + { next: { revalidate: 300 } } + ); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +export async function generateMetadata({ params }: PageProps): Promise { + const data = await getEventGallery(params.id); + if (!data) { + return { title: 'Event Photos', robots: { index: false } }; + } + return { + title: `${data.gallery.title} – Photos`, + description: + data.gallery.description || + `Photos from ${data.gallery.title}, a Spanglish language exchange event in Asunción.`, + }; +} + +export default async function EventGalleryPage({ params }: PageProps) { + const initial = await getEventGallery(params.id); + return ( + // Suspense boundary required by useSearchParams() in the client child. + + + + ); +} diff --git a/frontend/src/app/(public)/events/[id]/page.tsx b/frontend/src/app/(public)/events/[id]/page.tsx index fd1246c..cec0e03 100644 --- a/frontend/src/app/(public)/events/[id]/page.tsx +++ b/frontend/src/app/(public)/events/[id]/page.tsx @@ -117,7 +117,10 @@ function generateEventJsonLd(event: Event) { '@type': 'Offer', price: event.price, priceCurrency: event.currency, - availability: Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0)) > 0 + availability: + (typeof event.availableSeats === 'number' + ? event.availableSeats + : Math.max(0, (event.capacity ?? 0) - (event.bookedCount ?? 0))) > 0 ? 'https://schema.org/InStock' : 'https://schema.org/SoldOut', url: `${siteUrl}/events/${event.slug}`, diff --git a/frontend/src/app/(public)/photos/PhotosIndexClient.tsx b/frontend/src/app/(public)/photos/PhotosIndexClient.tsx new file mode 100644 index 0000000..da01cf7 --- /dev/null +++ b/frontend/src/app/(public)/photos/PhotosIndexClient.tsx @@ -0,0 +1,72 @@ +'use client'; + +import Link from 'next/link'; +import { useLanguage } from '@/context/LanguageContext'; +import type { PhotoGallery } from '@/lib/api'; +import Card from '@/components/ui/Card'; +import { CameraIcon } from '@heroicons/react/24/outline'; + +export default function PhotosIndexClient({ galleries }: { galleries: PhotoGallery[] }) { + const { locale } = useLanguage(); + const es = locale === 'es'; + + const formatDate = (iso?: string) => { + if (!iso) return null; + return new Date(iso).toLocaleDateString(es ? 'es-ES' : 'en-US', { + year: 'numeric', + month: 'long', + timeZone: 'America/Asuncion', + }); + }; + + return ( +
+
+

+ {es ? 'Galerías de Fotos' : 'Photo Galleries'} +

+

+ {es + ? 'Recuerdos de nuestros intercambios de idiomas en Asunción' + : 'Memories from our language exchanges in Asunción'} +

+ + {galleries.length === 0 ? ( +
+ +

+ {es ? 'Aún no hay galerías publicadas.' : 'No galleries published yet.'} +

+
+ ) : ( +
+ {galleries.map((g) => ( + // Event-linked galleries live under the event's URL. + + +
+ {g.coverUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} +
+
+

+ {es && g.titleEs ? g.titleEs : g.title} +

+

+ {g.photoCount} {es ? 'fotos' : 'photos'} + {g.event?.startDatetime && <> · {formatDate(g.event.startDatetime)}} +

+
+
+ + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx b/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx new file mode 100644 index 0000000..c75aa2c --- /dev/null +++ b/frontend/src/app/(public)/photos/[slug]/GalleryClient.tsx @@ -0,0 +1,303 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Link from 'next/link'; +import { useSearchParams, useRouter, usePathname } from 'next/navigation'; +import { useLanguage } from '@/context/LanguageContext'; +import { useAuth } from '@/context/AuthContext'; +import { photosApi, PhotoGallery, Photo } from '@/lib/api'; +import Button from '@/components/ui/Button'; +import { ImageGridSkeleton } from '@/components/ui/Skeleton'; +import Lightbox from '@/components/Lightbox'; +import { + ArrowDownTrayIcon, + CalendarIcon, + CameraIcon, + LockClosedIcon, + TicketIcon, +} from '@heroicons/react/24/outline'; + +interface GalleryClientProps { + /** Fetch by gallery slug (standalone galleries at /photos/[slug]) … */ + slug?: string; + /** … or by event slug (event galleries at /events/[slug]/gallery). */ + eventSlug?: string; + initial: { gallery: PhotoGallery; photos: Photo[] } | null; +} + +type DeniedState = 'login' | 'ticket' | 'notfound' | null; + +export default function GalleryClient({ slug, eventSlug, initial }: GalleryClientProps) { + const { locale } = useLanguage(); + const es = locale === 'es'; + const { user, isLoading: authLoading } = useAuth(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + const router = useRouter(); + const shareToken = searchParams.get('token') || undefined; + + const [gallery, setGallery] = useState(initial?.gallery ?? null); + const [photos, setPhotos] = useState(initial?.photos ?? []); + const [loading, setLoading] = useState(!initial); + const [denied, setDenied] = useState(null); + const [lightboxIndex, setLightboxIndex] = useState(null); + + // Server-rendered public galleries need no client fetch. Everything else + // (link/ticket/private) is fetched here with the share token and/or the + // viewer's own auth token attached. + useEffect(() => { + if (initial) return; + if (authLoading) return; // wait so the Bearer token is available + let cancelled = false; + setLoading(true); + const fetcher = eventSlug + ? photosApi.getEventGallery(eventSlug, shareToken) + : photosApi.getPublicGallery(slug || '', shareToken); + fetcher + .then((data) => { + if (cancelled) return; + setGallery(data.gallery); + setPhotos(data.photos); + setDenied(null); + }) + .catch((err: Error) => { + if (cancelled) return; + const msg = err.message || ''; + if (msg.includes('Authentication required')) setDenied('login'); + else if (msg.includes('attendees')) setDenied('ticket'); + else setDenied('notfound'); + }) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [slug, eventSlug, shareToken, initial, authLoading, user?.id]); + + if (loading || (authLoading && !initial)) { + return ( +
+
+ +
+
+ ); + } + + if (denied === 'login') { + const redirect = encodeURIComponent(`${pathname}${shareToken ? `?token=${shareToken}` : ''}`); + return ( + router.push(`/login?redirect=${redirect}`)}> + {es ? 'Iniciar sesión' : 'Log in'} + + } + /> + ); + } + + if (denied === 'ticket') { + return ( + + + + ) : undefined + } + /> + ); + } + + if (denied === 'notfound' || !gallery) { + return ( + + + + } + /> + ); + } + + const readyPhotos = photos.filter((p) => p.status === 'ready' && p.urls.thumb); + const lightboxItems = readyPhotos.map((p) => ({ + id: p.id, + previewUrl: p.urls.preview || p.urls.original, + downloadUrl: p.urls.original, + filename: p.originalFilename, + })); + + const title = es && gallery.titleEs ? gallery.titleEs : gallery.title; + const description = es && gallery.descriptionEs ? gallery.descriptionEs : gallery.description; + const eventTitle = gallery.event + ? es && gallery.event.titleEs + ? gallery.event.titleEs + : gallery.event.title + : null; + const eventDate = gallery.event?.startDatetime + ? new Date(gallery.event.startDatetime).toLocaleDateString(es ? 'es-ES' : 'en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'America/Asuncion', + }) + : null; + + // Hero backdrop: the cover photo's preview when available, else the + // first photo. Falls back to a navy gradient with no image. + const coverPhoto = + readyPhotos.find((p) => p.id === gallery.coverPhotoId) || readyPhotos[0] || null; + const heroUrl = coverPhoto ? coverPhoto.urls.preview || coverPhoto.urls.thumb : null; + + return ( +
+ {/* Hero */} +
+ {heroUrl && ( + <> + {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ + )} +
+

+ {title} +

+ {description && ( +

{description}

+ )} +
+ + + {readyPhotos.length} {es ? 'fotos' : 'photos'} + + {gallery.event && ( + + + {eventTitle} + {eventDate && · {eventDate}} + + )} +
+
+
+ + {/* Masonry grid */} +
+ {readyPhotos.length === 0 ? ( +
+ +

+ {es ? 'Las fotos estarán disponibles pronto.' : 'Photos will be available soon.'} +

+
+ ) : ( +
+ {readyPhotos.map((photo, i) => ( +
setLightboxIndex(i)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setLightboxIndex(i); + } + }} + className="group relative mb-2 md:mb-3 break-inside-avoid overflow-hidden rounded-xl bg-gray-100 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-yellow" + style={ + photo.width && photo.height + ? { aspectRatio: `${photo.width} / ${photo.height}` } + : undefined + } + > + {/* eslint-disable-next-line @next/next/no-img-element */} + + + ))} +
+ )} + + {lightboxIndex !== null && ( + setLightboxIndex(null)} + onNavigate={setLightboxIndex} + /> + )} +
+
+ ); +} + +function GateMessage({ + icon: Icon, + title, + body, + action, +}: { + icon: typeof CameraIcon; + title: string; + body: string; + action?: React.ReactNode; +}) { + return ( +
+
+ +

{title}

+

{body}

+ {action} +
+
+ ); +} diff --git a/frontend/src/app/(public)/photos/[slug]/page.tsx b/frontend/src/app/(public)/photos/[slug]/page.tsx new file mode 100644 index 0000000..736af70 --- /dev/null +++ b/frontend/src/app/(public)/photos/[slug]/page.tsx @@ -0,0 +1,51 @@ +import type { Metadata } from 'next'; +import { Suspense } from 'react'; +import type { PhotoGallery, Photo } from '@/lib/api'; +import GalleryClient from './GalleryClient'; + +const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003'; + +interface PageProps { + params: { slug: string }; +} + +// Public galleries render server-side (SEO + fast first paint); link and +// ticket galleries return null here and are fetched client-side with the +// viewer's token / share token. +async function getPublicGallery( + slug: string +): Promise<{ gallery: PhotoGallery; photos: Photo[] } | null> { + try { + const res = await fetch( + `${photoApiUrl}/api/photos/public/galleries/${encodeURIComponent(slug)}`, + { next: { revalidate: 300 } } + ); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } +} + +export async function generateMetadata({ params }: PageProps): Promise { + const data = await getPublicGallery(params.slug); + if (!data) { + return { title: 'Photo Gallery', robots: { index: false } }; + } + return { + title: `${data.gallery.title} – Photos`, + description: + data.gallery.description || + `Photos from ${data.gallery.title}, a Spanglish language exchange event in Asunción.`, + }; +} + +export default async function GalleryPage({ params }: PageProps) { + const initial = await getPublicGallery(params.slug); + return ( + // Suspense boundary required by useSearchParams() in the client child. + + + + ); +} diff --git a/frontend/src/app/(public)/photos/page.tsx b/frontend/src/app/(public)/photos/page.tsx new file mode 100644 index 0000000..62f774f --- /dev/null +++ b/frontend/src/app/(public)/photos/page.tsx @@ -0,0 +1,30 @@ +import type { Metadata } from 'next'; +import type { PhotoGallery } from '@/lib/api'; +import PhotosIndexClient from './PhotosIndexClient'; + +// Server-side calls go straight to the photo-api service; the browser uses +// the same-origin /api/photos path via the Next rewrite / nginx. +const photoApiUrl = process.env.PHOTO_API_URL || 'http://localhost:3003'; + +export const metadata: Metadata = { + title: 'Event Photo Galleries', + description: 'Photos from past Spanglish language exchange events in Asunción.', +}; + +async function getGalleries(): Promise { + try { + const res = await fetch(`${photoApiUrl}/api/photos/public/galleries`, { + next: { revalidate: 300 }, + }); + if (!res.ok) return []; + const data = await res.json(); + return data.galleries || []; + } catch { + return []; + } +} + +export default async function PhotosPage() { + const galleries = await getGalleries(); + return ; +} diff --git a/frontend/src/app/admin/events/[id]/_components/StatusBadge.tsx b/frontend/src/app/admin/events/[id]/_components/StatusBadge.tsx index a410096..e4be1db 100644 --- a/frontend/src/app/admin/events/[id]/_components/StatusBadge.tsx +++ b/frontend/src/app/admin/events/[id]/_components/StatusBadge.tsx @@ -18,3 +18,23 @@ export function StatusBadge({ status, compact = false }: { status: string; compa ); } + +// Ticket payment status: Paid (revenue), Unpaid (balance due at door), Comp (free guest). +// Tickets created before the payment_status column default to unpaid via backfill. +export function PaymentBadge({ paymentStatus, compact = false }: { paymentStatus?: string; compact?: boolean }) { + if (!paymentStatus) return null; + const styles: Record = { + paid: 'bg-emerald-100 text-emerald-700', + unpaid: 'bg-orange-100 text-orange-700', + comp: 'bg-amber-100 text-amber-700', + }; + return ( + + {paymentStatus === 'comp' ? 'Comp' : paymentStatus === 'unpaid' ? 'Unpaid' : 'Paid'} + + ); +} diff --git a/frontend/src/app/admin/events/[id]/_modals/AddTicketModal.tsx b/frontend/src/app/admin/events/[id]/_modals/AddTicketModal.tsx new file mode 100644 index 0000000..d971606 --- /dev/null +++ b/frontend/src/app/admin/events/[id]/_modals/AddTicketModal.tsx @@ -0,0 +1,209 @@ +import type { Dispatch, FormEvent, SetStateAction } from 'react'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import clsx from 'clsx'; +import { + BanknotesIcon, + CheckCircleIcon, + EnvelopeIcon, + LinkIcon, + StarIcon, + XMarkIcon, +} from '@heroicons/react/24/outline'; +import type { AddTicketType, AddTicketFormState } from '../_types'; + +interface AddTicketModalProps { + open: boolean; + onClose: () => void; + form: AddTicketFormState; + setForm: Dispatch>; + onSubmit: (e: FormEvent) => void; + submitting: boolean; + eventPriceLabel: string; +} + +const TYPE_OPTIONS: { value: AddTicketType; label: string }[] = [ + { value: 'paid', label: 'Paid' }, + { value: 'unpaid', label: 'Unpaid' }, + { value: 'guest', label: 'Guest' }, +]; + +const SUBMIT_LABELS: Record = { + paid: 'Create & send ticket', + unpaid: 'Create & send pay link', + guest: 'Invite guest', +}; + +const SUBMIT_ICONS: Record = { + paid: EnvelopeIcon, + unpaid: LinkIcon, + guest: StarIcon, +}; + +// Live "what happens" preview lines for the selected type / email / check-in combo +function previewLines(form: AddTicketFormState, eventPriceLabel: string): string[] { + const hasEmail = !!form.email.trim(); + const lines: 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 === 'unpaid') { + lines.push(`Ticket marked unpaid — balance of ${eventPriceLabel} to collect at the door`); + lines.push('QR code issued, flagged "unpaid" for door staff'); + lines.push(hasEmail + ? 'Bancard (TPago) payment link emailed to the attendee' + : 'No email — no pay link sent, payment collected at the door'); + } else { + lines.push('Free guest ticket (comp) — not counted in revenue'); + lines.push('Auto-confirmed with QR code'); + lines.push(hasEmail ? 'Confirmation email sent' : 'No email — nothing is sent'); + } + if (form.checkinNow) { + lines.push('Checked in immediately'); + } + return lines; +} + +const PREVIEW_STYLES: Record = { + paid: { box: 'bg-blue-50 border-blue-200', icon: 'text-blue-500', text: 'text-blue-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, + unpaid: BanknotesIcon, + guest: StarIcon, +}; + +export function AddTicketModal({ + open, + onClose, + form, + setForm, + onSubmit, + submitting, + eventPriceLabel, +}: AddTicketModalProps) { + if (!open) return null; + + const emailRequired = form.type === 'paid'; + const style = PREVIEW_STYLES[form.type]; + const PreviewIcon = PREVIEW_ICONS[form.type]; + const SubmitIcon = SUBMIT_ICONS[form.type]; + + return ( +
+ e.stopPropagation()} + > +
+

Add Ticket

+ +
+
+ {/* Segmented ticket-type control */} +
+ {TYPE_OPTIONS.map((option) => ( + + ))} +
+ +
+
+ + 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" /> +
+
+ + setForm((f) => ({ ...f, lastName: 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="Last name" /> +
+
+
+ + setForm((f) => ({ ...f, email: 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={emailRequired ? 'email@example.com' : 'email@example.com (optional)'} /> +

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

+
+
+ + setForm((f) => ({ ...f, phone: 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="+595 981 123456" /> +
+
+ +