3 Commits
Author SHA1 Message Date
MichilisandClaude Fable 5 9b2668f498 Unify admin ticket creation into one modal with first-class payment status.
- Replace the three Attendees-tab modals (Manual Ticket / Add at Door /
  Invite Guest) with a single Add Ticket modal: Paid/Unpaid/Guest segmented
  control, shared fields, "Check in now" for all types, and a live
  "what happens" preview, backed by one POST /api/tickets/admin/add.
- Add tickets.payment_status (paid | unpaid | comp) with a backfill
  migration; keep it in sync on every payment-settlement path (mark-paid,
  admin approval, Lightning, free bookings, hold recovery).
- Show Paid/Unpaid/Comp badges in the attendee list, count only paid
  tickets toward revenue, let unpaid tickets be resolved via Mark Paid,
  and flag unpaid tickets with their balance due in the door scanner.
- Replace the per-page useStatsPrivacy hook with an admin-wide
  PrivacyContext + SensitiveValue mask, toggled from the admin layout.
- Add server-side pagination with page-size options to the users page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 05:25:00 +00:00
MichilisandClaude Fable 5 e38d14970d Harden the Redis layer for production.
- Locks no longer fail open: an unreachable backend throws
  LockUnavailableError and withLock skips the run (or opts into running
  unlocked, as template seeding does). The lnbits payment poller keeps
  polling through an outage, made safe by only sending confirmation
  emails when a row actually transitioned.
- Rate limiter INCR+PEXPIRE now runs as one Lua script, self-healing
  counters stranded without a TTL.
- Per-email login lockout moves to a Redis-backed store shared across
  replicas (in-memory fallback preserved), case-insensitive on email.
- Graceful shutdown on SIGTERM/SIGINT: stop periodic jobs, close the
  server, force-close lingering SSE sockets, close Redis.
- Active PING probe backs the health flag; /health reports last ping.
  SSE payment streams also poll the DB as a pub/sub-gap fallback.
- Scale compose gains requirepass, maxmemory 256mb with noeviction, and
  an authenticated healthcheck; .env.example documents passwords, TLS
  (rediss://), and DB-index selection.
- First tests in the repo: vitest + ioredis-mock covering the lock,
  rate limiter, and login lockout stores (27 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 04:58:52 +00:00
MichilisandClaude Fable 5 71c277045b Centralize seat capacity accounting and the payment provider registry.
Replace manualProviders.ts with a paymentProviders.ts registry
(automatic vs manual settlement) and move all seat counting into
capacity.ts as the single source of truth: only paid/checked-in tickets
and pending_approval payments hold a seat, so abandoned checkouts never
block sales. Admins can now knowingly approve a payment over capacity
(allowOverCapacity), with the booking and admin UIs updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 04:58:40 +00:00
54 changed files with 2095 additions and 1064 deletions
+11 -3
View File
@@ -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
+4 -1
View File
@@ -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"
}
}
+25 -1
View File
@@ -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,
+4
View File
@@ -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(),
});
+46 -6
View File
@@ -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'));
+5 -2
View File
@@ -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}`);
}
+1 -1
View File
@@ -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);
+70
View File
@@ -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<number>`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<number>`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<number>`sum(case when ${(tickets as any).status} IN ('confirmed', 'checked_in') then 1 else 0 end)`,
claimedCount: sql<number>`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);
}
+41 -51
View File
@@ -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<string, any>;
/** 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<any>(
(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<string, any> = { 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<number>`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<number>`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<any>(
tx
.select({ count: sql<number>`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<any>(
tx
.select({ count: sql<number>`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<any>(seatHolderCountQuery(tx, eventId));
const neededRow = await dbGet<any>(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)
+22 -26
View File
@@ -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<number> {
const cutoff = toDbDate(new Date(Date.now() - getThresholdMs()));
@@ -40,13 +41,8 @@ export async function sweepStaleApprovals(): Promise<number> {
})
.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<number> {
));
}
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;
}
-7
View File
@@ -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;
+37
View File
@@ -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<string, { kind: PaymentProviderKind }> = {
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';
}
+80 -10
View File
@@ -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<typeof setInterval> | 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<void> {
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<void> {
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
const tasks: Promise<unknown>[] = [];
if (client) tasks.push(client.quit().catch(() => undefined));
if (subscriber) tasks.push(subscriber.quit().catch(() => undefined));
+98
View File
@@ -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();
});
});
+44 -10
View File
@@ -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<string | null>;
release(key: string, token: string): Promise<void>;
// Runs fn while holding the lock; returns fn's result, or null if not acquired.
withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null>;
withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>, opts?: WithLockOptions): Promise<T | null>;
}
// ==================== Memory implementation ====================
class MemoryLock implements Lock {
export class MemoryLock implements Lock {
readonly backend = 'memory' as const;
private held = new Map<string, { token: string; expiresAt: number }>();
@@ -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<string | null> {
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<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null> {
const token = await this.acquire(key, ttlMs);
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>, opts?: WithLockOptions): Promise<T | null> {
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();
+125
View File
@@ -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();
});
});
+164
View File
@@ -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<LockoutStatus>;
recordFailure(email: string): Promise<void>;
clear(email: string): Promise<void>;
}
// 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<string, { count: number; resetAt: number }>();
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<LockoutStatus> {
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<void> {
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<void> {
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<number>;
};
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<LockoutStatus> {
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<void> {
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<void> {
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;
}
@@ -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);
});
});
+35 -10
View File
@@ -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<string, Bucket>();
@@ -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<RateLimitResult> {
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 };
}
+32 -2
View File
@@ -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<any>(
(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<any>(eventSeatBreakdownQuery(db));
const seatsByEvent = new Map<string, { paid: number; claimed: number }>();
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<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
@@ -77,6 +99,13 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
.where(eq((payments as any).status, 'pending'))
);
const awaitingApprovalPayments = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(payments)
.where(eq((payments as any).status, 'pending_approval'))
);
const onHoldPayments = await dbGet<any>(
(db as any)
.select({ count: sql<number>`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,
+9 -44
View File
@@ -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<string, { count: number; resetAt: number }>();
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
+36 -51
View File
@@ -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<any>(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<any>(
(db as any)
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
.from(tickets)
.where(sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`)
.groupBy((tickets as any).eventId)
);
const countByEvent = new Map<string, number>();
// 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<any>(eventSeatBreakdownQuery(db));
const countByEvent = new Map<string, { paid: number; claimed: number }>();
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<any>(
(db as any)
.select({ count: sql<number>`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<string> {
return settings?.timezone || 'America/Asuncion';
}
// Helper function to get ticket count for an event
async function getEventTicketCount(eventId: string): Promise<number> {
const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`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<any>(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<any | null> {
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,
},
});
+58 -18
View File
@@ -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<any>(
(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<any>(
(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();
}
});
});
+36 -54
View File
@@ -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<any>(
(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<any>(
(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)
+102 -207
View File
@@ -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<any>(
(db as any)
.select({ count: sql<number>`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<number>`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<any>(
tx
.select({ count: sql<number>`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<any>(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<any>(
(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<string, string> = {
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);
});
+1 -1
View File
@@ -12,5 +12,5 @@
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
},
});
+15 -2
View File
@@ -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.
@@ -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) {
@@ -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(() => {
@@ -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}`,
@@ -18,3 +18,23 @@ export function StatusBadge({ status, compact = false }: { status: string; compa
</span>
);
}
// 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<string, string> = {
paid: 'bg-emerald-100 text-emerald-700',
unpaid: 'bg-orange-100 text-orange-700',
comp: 'bg-amber-100 text-amber-700',
};
return (
<span className={clsx(
'inline-flex items-center rounded-full font-medium',
compact ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-0.5 text-xs',
styles[paymentStatus] || 'bg-gray-100 text-gray-800'
)}>
{paymentStatus === 'comp' ? 'Comp' : paymentStatus === 'unpaid' ? 'Unpaid' : 'Paid'}
</span>
);
}
@@ -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<SetStateAction<AddTicketFormState>>;
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<AddTicketType, string> = {
paid: 'Create & send ticket',
unpaid: 'Create & send pay link',
guest: 'Invite guest',
};
const SUBMIT_ICONS: Record<AddTicketType, typeof EnvelopeIcon> = {
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<AddTicketType, { box: string; icon: string; text: string }> = {
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<AddTicketType, typeof EnvelopeIcon> = {
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 (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
onClick={onClose}
role="presentation"
>
<Card
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<h2 className="text-base font-bold">Add Ticket</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={onSubmit} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
{/* Segmented ticket-type control */}
<div className="flex rounded-btn bg-gray-100 p-1">
{TYPE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setForm((f) => ({ ...f, type: option.value }))}
className={clsx(
'flex-1 px-3 py-2 text-sm font-medium rounded-btn min-h-[36px] transition-colors',
form.type === option.value
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-500 hover:text-gray-700'
)}
>
{option.label}
</button>
))}
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={form.firstName}
onChange={(e) => 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" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
<input type="text" value={form.lastName}
onChange={(e) => 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" />
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1">Email {emailRequired && '*'}</label>
<input type="email" required={emailRequired} value={form.email}
onChange={(e) => 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)'} />
<p className="text-[10px] text-gray-500 mt-1">
{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'}
</p>
</div>
<div>
<label className="block text-xs font-medium mb-1">Phone</label>
<input type="tel" value={form.phone}
onChange={(e) => 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" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Admin Note</label>
<textarea value={form.adminNote}
onChange={(e) => setForm((f) => ({ ...f, adminNote: 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"
rows={2} placeholder="Internal note..." />
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="checkinNow" checked={form.checkinNow}
onChange={(e) => setForm((f) => ({ ...f, checkinNow: e.target.checked }))}
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
<label htmlFor="checkinNow" className="text-sm font-medium">Check in now</label>
</div>
{/* Live preview of what this submission does */}
<div className={clsx('border rounded-lg p-3', style.box)}>
<div className="flex items-start gap-2">
<PreviewIcon className={clsx('w-4 h-4 mt-0.5 flex-shrink-0', style.icon)} />
<div className={clsx('text-xs', style.text)}>
<p className="font-medium">What happens:</p>
<ul className="list-disc ml-4 mt-0.5 space-y-0.5">
{previewLines(form, eventPriceLabel).map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={onClose} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
<SubmitIcon className="w-4 h-4 mr-1.5" />
{SUBMIT_LABELS[form.type]}
</Button>
</div>
</form>
</Card>
</div>
);
}
@@ -1,17 +1,16 @@
import type { Dispatch, FormEvent, SetStateAction } from 'react';
import { Ticket } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { BottomSheet } from '@/components/admin/MobileComponents';
import clsx from 'clsx';
import {
BanknotesIcon,
CheckCircleIcon,
EnvelopeIcon,
PlusIcon,
StarIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import type { AttendeeStatusFilter, AttendeeFormState, AddAtDoorFormState } from '../_types';
import type { AttendeeStatusFilter, AddTicketType } from '../_types';
interface EventModalsProps {
// counts + filter
@@ -29,6 +28,7 @@ interface EventModalsProps {
// add ticket sheet
showAddTicketSheet: boolean;
setShowAddTicketSheet: (value: boolean) => void;
openAddTicket: (type: AddTicketType) => void;
// export sheets
showExportSheet: boolean;
setShowExportSheet: (value: boolean) => void;
@@ -36,24 +36,6 @@ interface EventModalsProps {
showTicketExportSheet: boolean;
setShowTicketExportSheet: (value: boolean) => void;
handleExportTickets: (status: 'confirmed' | 'checked_in' | 'all') => void;
// add at door
showAddAtDoorModal: boolean;
setShowAddAtDoorModal: (value: boolean) => void;
addAtDoorForm: AddAtDoorFormState;
setAddAtDoorForm: Dispatch<SetStateAction<AddAtDoorFormState>>;
handleAddAtDoor: (e: FormEvent) => void;
// manual ticket
showManualTicketModal: boolean;
setShowManualTicketModal: (value: boolean) => void;
manualTicketForm: AttendeeFormState;
setManualTicketForm: Dispatch<SetStateAction<AttendeeFormState>>;
handleManualTicket: (e: FormEvent) => void;
// invite guest
showInviteGuestModal: boolean;
setShowInviteGuestModal: (value: boolean) => void;
inviteGuestForm: AttendeeFormState;
setInviteGuestForm: Dispatch<SetStateAction<AttendeeFormState>>;
handleInviteGuest: (e: FormEvent) => void;
// shared submit flag
submitting: boolean;
// note modal
@@ -83,27 +65,13 @@ export function EventModals(props: EventModalsProps) {
setMobileFilterOpen,
showAddTicketSheet,
setShowAddTicketSheet,
openAddTicket,
showExportSheet,
setShowExportSheet,
handleExportAttendees,
showTicketExportSheet,
setShowTicketExportSheet,
handleExportTickets,
showAddAtDoorModal,
setShowAddAtDoorModal,
addAtDoorForm,
setAddAtDoorForm,
handleAddAtDoor,
showManualTicketModal,
setShowManualTicketModal,
manualTicketForm,
setManualTicketForm,
handleManualTicket,
showInviteGuestModal,
setShowInviteGuestModal,
inviteGuestForm,
setInviteGuestForm,
handleInviteGuest,
submitting,
showNoteModal,
setShowNoteModal,
@@ -156,27 +124,27 @@ export function EventModals(props: EventModalsProps) {
>
<div className="space-y-1">
<button
onClick={() => { setShowManualTicketModal(true); setShowAddTicketSheet(false); }}
onClick={() => { openAddTicket('paid'); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
>
<EnvelopeIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Manual Ticket</p>
<p className="text-xs text-gray-500">Send confirmation email with ticket</p>
<p className="font-medium">Paid Ticket</p>
<p className="text-xs text-gray-500">Send confirmation email with QR ticket</p>
</div>
</button>
<button
onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketSheet(false); }}
onClick={() => { openAddTicket('unpaid'); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
>
<PlusIcon className="w-5 h-5 text-gray-500" />
<BanknotesIcon className="w-5 h-5 text-gray-500" />
<div>
<p className="font-medium">Add at Door</p>
<p className="text-xs text-gray-500">Quick add with optional auto check-in</p>
<p className="font-medium">Unpaid Ticket</p>
<p className="text-xs text-gray-500">Pay link now or collect at the door</p>
</div>
</button>
<button
onClick={() => { setShowInviteGuestModal(true); setShowAddTicketSheet(false); }}
onClick={() => { openAddTicket('guest'); setShowAddTicketSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
>
<StarIcon className="w-5 h-5 text-gray-500" />
@@ -237,243 +205,6 @@ export function EventModals(props: EventModalsProps) {
</div>
</BottomSheet>
{/* Add at Door Modal */}
{showAddAtDoorModal && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
onClick={() => setShowAddAtDoorModal(false)}
role="presentation"
>
<Card
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<h2 className="text-base font-bold">Add Attendee at Door</h2>
<button
onClick={() => setShowAddAtDoorModal(false)}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleAddAtDoor} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={addAtDoorForm.firstName}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, 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" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
<input type="text" value={addAtDoorForm.lastName}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, 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" />
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1">Email</label>
<input type="email" value={addAtDoorForm.email}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, 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="email@example.com" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Phone</label>
<input type="tel" value={addAtDoorForm.phone}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, 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" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Admin Note</label>
<textarea value={addAtDoorForm.adminNote}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, adminNote: 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"
rows={2} placeholder="Internal note..." />
</div>
<div className="flex items-center gap-3">
<input type="checkbox" id="autoCheckin" checked={addAtDoorForm.autoCheckin}
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, autoCheckin: e.target.checked })}
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
<label htmlFor="autoCheckin" className="text-sm font-medium">Auto check-in immediately</label>
</div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={() => setShowAddAtDoorModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">Add Attendee</Button>
</div>
</form>
</Card>
</div>
)}
{/* Manual Ticket Modal */}
{showManualTicketModal && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
onClick={() => setShowManualTicketModal(false)}
role="presentation"
>
<Card
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<div>
<h2 className="text-base font-bold">Create Manual Ticket</h2>
<p className="text-xs text-gray-500">Confirmation email will be sent</p>
</div>
<button onClick={() => setShowManualTicketModal(false)}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleManualTicket} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={manualTicketForm.firstName}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, 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" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
<input type="text" value={manualTicketForm.lastName}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, 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" />
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1">Email *</label>
<input type="email" required value={manualTicketForm.email}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, 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="email@example.com" />
<p className="text-[10px] text-gray-500 mt-1">Ticket will be sent to this email</p>
</div>
<div>
<label className="block text-xs font-medium mb-1">Phone</label>
<input type="tel" value={manualTicketForm.phone}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, 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" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Admin Note</label>
<textarea value={manualTicketForm.adminNote}
onChange={(e) => setManualTicketForm({ ...manualTicketForm, adminNote: 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"
rows={2} placeholder="Internal note..." />
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<EnvelopeIcon className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
<div className="text-xs text-blue-800">
<p className="font-medium">This will send:</p>
<ul className="list-disc ml-4 mt-0.5 space-y-0.5">
<li>Booking confirmation email</li>
<li>Ticket with QR code</li>
</ul>
</div>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={() => setShowManualTicketModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
<EnvelopeIcon className="w-4 h-4 mr-1.5" />
Create & Send
</Button>
</div>
</form>
</Card>
</div>
)}
{/* Invite Guest Modal */}
{showInviteGuestModal && (
<div
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
onClick={() => setShowInviteGuestModal(false)}
role="presentation"
>
<Card
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
<div>
<h2 className="text-base font-bold">Invite Guest</h2>
<p className="text-xs text-gray-500">Free ticket not counted in revenue</p>
</div>
<button onClick={() => setShowInviteGuestModal(false)}
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleInviteGuest} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1">First Name *</label>
<input type="text" required value={inviteGuestForm.firstName}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, 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" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Last Name</label>
<input type="text" value={inviteGuestForm.lastName}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, 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" />
</div>
</div>
<div>
<label className="block text-xs font-medium mb-1">Email</label>
<input type="email" value={inviteGuestForm.email}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, 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="email@example.com (optional)" />
<p className="text-[10px] text-gray-500 mt-1">If provided, a confirmation email will be sent</p>
</div>
<div>
<label className="block text-xs font-medium mb-1">Phone</label>
<input type="tel" value={inviteGuestForm.phone}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, 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" />
</div>
<div>
<label className="block text-xs font-medium mb-1">Admin Note</label>
<textarea value={inviteGuestForm.adminNote}
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, adminNote: 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"
rows={2} placeholder="Internal note..." />
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<StarIcon className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-amber-800">
Guest tickets are <strong>free</strong> and are automatically confirmed. They are not counted toward revenue or paid ticket totals.
</p>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button type="button" variant="outline" onClick={() => setShowInviteGuestModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
<StarIcon className="w-4 h-4 mr-1.5" />
Invite Guest
</Button>
</div>
</form>
</Card>
</div>
)}
{/* Note Modal */}
{showNoteModal && selectedTicket && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
@@ -14,9 +14,11 @@ import {
FunnelIcon,
ChatBubbleLeftIcon,
ArrowPathIcon,
BanknotesIcon,
CheckCircleIcon,
} from '@heroicons/react/24/outline';
import { StatusBadge } from '../_components/StatusBadge';
import type { AttendeeStatusFilter, PrimaryAction } from '../_types';
import { StatusBadge, PaymentBadge } from '../_components/StatusBadge';
import type { AttendeeStatusFilter, AddTicketType, PrimaryAction } from '../_types';
interface AttendeesTabProps {
locale: string;
@@ -37,15 +39,15 @@ interface AttendeesTabProps {
showAddTicketDropdown: boolean;
setShowAddTicketDropdown: (value: boolean) => void;
handleExportAttendees: (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => void;
setShowManualTicketModal: (value: boolean) => void;
setShowAddAtDoorModal: (value: boolean) => void;
setShowInviteGuestModal: (value: boolean) => void;
openAddTicket: (type: AddTicketType) => void;
setMobileFilterOpen: (value: boolean) => void;
setShowExportSheet: (value: boolean) => void;
setShowAddTicketSheet: (value: boolean) => void;
getPrimaryAction: (ticket: Ticket) => PrimaryAction | null;
handleOpenNoteModal: (ticket: Ticket) => void;
handleReactivate: (ticket: Ticket) => void;
handleMarkPaid: (ticketId: string) => void;
handleCheckin: (ticketId: string) => void;
}
export function AttendeesTab({
@@ -67,15 +69,15 @@ export function AttendeesTab({
showAddTicketDropdown,
setShowAddTicketDropdown,
handleExportAttendees,
setShowManualTicketModal,
setShowAddAtDoorModal,
setShowInviteGuestModal,
openAddTicket,
setMobileFilterOpen,
setShowExportSheet,
setShowAddTicketSheet,
getPrimaryAction,
handleOpenNoteModal,
handleReactivate,
handleMarkPaid,
handleCheckin,
}: AttendeesTabProps) {
return (
<div className="space-y-3">
@@ -143,13 +145,13 @@ export function AttendeesTab({
</Button>
}
>
<DropdownItem onClick={() => { setShowManualTicketModal(true); setShowAddTicketDropdown(false); }}>
<EnvelopeIcon className="w-4 h-4 mr-2" /> Manual Ticket
<DropdownItem onClick={() => { openAddTicket('paid'); setShowAddTicketDropdown(false); }}>
<EnvelopeIcon className="w-4 h-4 mr-2" /> Paid Ticket
</DropdownItem>
<DropdownItem onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketDropdown(false); }}>
<PlusIcon className="w-4 h-4 mr-2" /> Add at Door
<DropdownItem onClick={() => { openAddTicket('unpaid'); setShowAddTicketDropdown(false); }}>
<BanknotesIcon className="w-4 h-4 mr-2" /> Unpaid Ticket
</DropdownItem>
<DropdownItem onClick={() => { setShowInviteGuestModal(true); setShowAddTicketDropdown(false); }}>
<DropdownItem onClick={() => { openAddTicket('guest'); setShowAddTicketDropdown(false); }}>
<StarIcon className="w-4 h-4 mr-2" /> Invite Guest
</DropdownItem>
</Dropdown>
@@ -252,9 +254,7 @@ export function AttendeesTab({
<td className="px-4 py-2.5">
<div className="flex items-center gap-1 flex-wrap">
<StatusBadge status={ticket.status} compact />
{!!ticket.isGuest && (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
)}
<PaymentBadge paymentStatus={ticket.paymentStatus} compact />
</div>
{ticket.checkinAt && (
<p className="text-[10px] text-gray-400 mt-0.5">
@@ -279,6 +279,16 @@ export function AttendeesTab({
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
</DropdownItem>
)}
{ticket.paymentStatus === 'unpaid' && ticket.status === 'checked_in' && (
<DropdownItem onClick={() => handleMarkPaid(ticket.id)}>
<BanknotesIcon className="w-4 h-4 mr-2" /> Mark as Paid
</DropdownItem>
)}
{ticket.paymentStatus === 'unpaid' && ticket.status === 'confirmed' && (
<DropdownItem onClick={() => handleCheckin(ticket.id)}>
<CheckCircleIcon className="w-4 h-4 mr-2" /> Check In (unpaid)
</DropdownItem>
)}
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
@@ -323,9 +333,7 @@ export function AttendeesTab({
</div>
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
<StatusBadge status={ticket.status} compact />
{!!ticket.isGuest && (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
)}
<PaymentBadge paymentStatus={ticket.paymentStatus} compact />
</div>
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
@@ -346,6 +354,16 @@ export function AttendeesTab({
<ArrowPathIcon className="w-4 h-4 mr-2" /> Reactivate
</DropdownItem>
)}
{ticket.paymentStatus === 'unpaid' && ticket.status === 'checked_in' && (
<DropdownItem onClick={() => handleMarkPaid(ticket.id)}>
<BanknotesIcon className="w-4 h-4 mr-2" /> Mark as Paid
</DropdownItem>
)}
{ticket.paymentStatus === 'unpaid' && ticket.status === 'confirmed' && (
<DropdownItem onClick={() => handleCheckin(ticket.id)}>
<CheckCircleIcon className="w-4 h-4 mr-2" /> Check In (unpaid)
</DropdownItem>
)}
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
@@ -1,5 +1,6 @@
import { Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import SensitiveValue from '@/components/admin/SensitiveValue';
import { CalendarIcon, MapPinIcon, CurrencyDollarIcon, UsersIcon } from '@heroicons/react/24/outline';
interface OverviewTabProps {
@@ -48,8 +49,8 @@ export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confir
<UsersIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm">Capacity</p>
<p className="text-sm text-gray-600">{confirmedCount + checkedInCount} / {event.capacity} spots filled</p>
<p className="text-xs text-gray-500">{Math.max(0, event.capacity - confirmedCount - checkedInCount)} spots remaining</p>
<p className="text-sm text-gray-600"><SensitiveValue>{confirmedCount + checkedInCount} / {event.capacity}</SensitiveValue> spots filled</p>
<p className="text-xs text-gray-500"><SensitiveValue>{Math.max(0, event.capacity - confirmedCount - checkedInCount)}</SensitiveValue> spots remaining</p>
</div>
</div>
</div>
+9 -5
View File
@@ -13,14 +13,18 @@ export interface PrimaryAction {
icon?: ComponentType<{ className?: string }>;
}
export interface AttendeeFormState {
// Ticket type in the unified Add Ticket modal:
// paid = confirmation + QR emailed, counts toward revenue
// unpaid = QR flagged unpaid, balance collected at door, pay link emailed if possible
// guest = free comp ticket, auto-confirmed, no revenue
export type AddTicketType = 'paid' | 'unpaid' | 'guest';
export interface AddTicketFormState {
type: AddTicketType;
firstName: string;
lastName: string;
email: string;
phone: string;
adminNote: string;
}
export interface AddAtDoorFormState extends AttendeeFormState {
autoCheckin: boolean;
checkinNow: boolean;
}
+60 -118
View File
@@ -21,7 +21,6 @@ import {
EnvelopeIcon,
PencilIcon,
EyeIcon,
EyeSlashIcon,
UserGroupIcon,
CreditCardIcon,
ChevronDownIcon,
@@ -30,14 +29,14 @@ import {
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import { useStatsPrivacy } from '@/hooks/useStatsPrivacy';
import { usePrivacy } from '@/context/PrivacyContext';
import type {
TabType,
AttendeeStatusFilter,
TicketStatusFilter,
RecipientFilter,
AttendeeFormState,
AddAtDoorFormState,
AddTicketType,
AddTicketFormState,
PrimaryAction,
} from './_types';
import { formatCurrency, downloadBlob } from './_utils/format';
@@ -49,8 +48,19 @@ import { TicketsTab } from './_tabs/TicketsTab';
import { EmailTab } from './_tabs/EmailTab';
import { PaymentsTab } from './_tabs/PaymentsTab';
import { EventModals } from './_modals/EventModals';
import { AddTicketModal } from './_modals/AddTicketModal';
import EventFormModal from '../_components/EventFormModal';
const EMPTY_ADD_TICKET_FORM: AddTicketFormState = {
type: 'paid',
firstName: '',
lastName: '',
email: '',
phone: '',
adminNote: '',
checkinNow: false,
};
export default function AdminEventDetailPage() {
const params = useParams();
const eventId = params.id as string;
@@ -69,37 +79,21 @@ export default function AdminEventDetailPage() {
// Attendees tab state
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<AttendeeStatusFilter>('all');
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
const [showManualTicketModal, setShowManualTicketModal] = useState(false);
const [showStats, , toggleStats] = useStatsPrivacy();
const { privacyMode } = usePrivacy();
const showStats = !privacyMode;
const [showNoteModal, setShowNoteModal] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [noteText, setNoteText] = useState('');
const [addAtDoorForm, setAddAtDoorForm] = useState<AddAtDoorFormState>({
firstName: '',
lastName: '',
email: '',
phone: '',
autoCheckin: true,
adminNote: '',
});
const [manualTicketForm, setManualTicketForm] = useState<AttendeeFormState>({
firstName: '',
lastName: '',
email: '',
phone: '',
adminNote: '',
});
const [showInviteGuestModal, setShowInviteGuestModal] = useState(false);
const [inviteGuestForm, setInviteGuestForm] = useState<AttendeeFormState>({
firstName: '',
lastName: '',
email: '',
phone: '',
adminNote: '',
});
// Unified Add Ticket modal (paid / unpaid / guest via segmented control)
const [showAddTicketModal, setShowAddTicketModal] = useState(false);
const [addTicketForm, setAddTicketForm] = useState<AddTicketFormState>(EMPTY_ADD_TICKET_FORM);
const [submitting, setSubmitting] = useState(false);
const openAddTicket = (type: AddTicketType) => {
setAddTicketForm({ ...EMPTY_ADD_TICKET_FORM, type });
setShowAddTicketModal(true);
};
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
const [showExportDropdown, setShowExportDropdown] = useState(false); // desktop dropdown
const [showExportSheet, setShowExportSheet] = useState(false); // mobile bottom sheet
@@ -220,74 +214,27 @@ export default function AdminEventDetailPage() {
}
};
const handleAddAtDoor = async (e: React.FormEvent) => {
const handleAddTicket = async (e: React.FormEvent) => {
e.preventDefault();
if (!event) return;
setSubmitting(true);
try {
await ticketsApi.adminCreate({
const res = await ticketsApi.adminAdd({
eventId: event.id,
firstName: addAtDoorForm.firstName,
lastName: addAtDoorForm.lastName || undefined,
email: addAtDoorForm.email,
phone: addAtDoorForm.phone,
autoCheckin: addAtDoorForm.autoCheckin,
adminNote: addAtDoorForm.adminNote || undefined,
type: addTicketForm.type,
firstName: addTicketForm.firstName,
lastName: addTicketForm.lastName || undefined,
email: addTicketForm.email || undefined,
phone: addTicketForm.phone || undefined,
checkinNow: addTicketForm.checkinNow,
adminNote: addTicketForm.adminNote || undefined,
});
toast.success(addAtDoorForm.autoCheckin ? 'Attendee added and checked in' : 'Attendee added');
setShowAddAtDoorModal(false);
setAddAtDoorForm({ firstName: '', lastName: '', email: '', phone: '', autoCheckin: true, adminNote: '' });
toast.success(res.message || 'Ticket created');
setShowAddTicketModal(false);
setAddTicketForm(EMPTY_ADD_TICKET_FORM);
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to add attendee');
} finally {
setSubmitting(false);
}
};
const handleManualTicket = async (e: React.FormEvent) => {
e.preventDefault();
if (!event) return;
setSubmitting(true);
try {
await ticketsApi.manualCreate({
eventId: event.id,
firstName: manualTicketForm.firstName,
lastName: manualTicketForm.lastName || undefined,
email: manualTicketForm.email,
phone: manualTicketForm.phone || undefined,
adminNote: manualTicketForm.adminNote || undefined,
});
toast.success('Manual ticket created — confirmation email sent');
setShowManualTicketModal(false);
setManualTicketForm({ firstName: '', lastName: '', email: '', phone: '', adminNote: '' });
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to create manual ticket');
} finally {
setSubmitting(false);
}
};
const handleInviteGuest = async (e: React.FormEvent) => {
e.preventDefault();
if (!event) return;
setSubmitting(true);
try {
await ticketsApi.guestCreate({
eventId: event.id,
firstName: inviteGuestForm.firstName,
lastName: inviteGuestForm.lastName || undefined,
email: inviteGuestForm.email || undefined,
phone: inviteGuestForm.phone || undefined,
adminNote: inviteGuestForm.adminNote || undefined,
});
toast.success('Guest invited successfully');
setShowInviteGuestModal(false);
setInviteGuestForm({ firstName: '', lastName: '', email: '', phone: '', adminNote: '' });
loadEventData();
} catch (error: any) {
toast.error(error.message || 'Failed to invite guest');
toast.error(error.message || 'Failed to add ticket');
} finally {
setSubmitting(false);
}
@@ -449,8 +396,11 @@ export default function AdminEventDetailPage() {
const checkedInCount = getTicketsByStatus('checked_in').length;
const cancelledCount = getTicketsByStatus('cancelled').length;
const onHoldCount = getTicketsByStatus('on_hold').length;
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(t => !t.isGuest).length;
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(t => !t.isGuest).length;
// Revenue counts only settled tickets: unpaid (balance due) and comp (guest)
// tickets are excluded; legacy rows without paymentStatus fall back to !isGuest
const isRevenueTicket = (t: Ticket) => (t.paymentStatus ? t.paymentStatus === 'paid' : !t.isGuest);
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(isRevenueTicket).length;
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(isRevenueTicket).length;
const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price;
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
@@ -467,6 +417,10 @@ export default function AdminEventDetailPage() {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
}
if (ticket.status === 'confirmed') {
// Unpaid tickets resolve their balance first; check-in stays available via scanner
if (ticket.paymentStatus === 'unpaid') {
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), variant: 'outline' };
}
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), variant: 'primary' };
}
if (ticket.status === 'checked_in') {
@@ -490,10 +444,6 @@ export default function AdminEventDetailPage() {
</div>
{/* Desktop header actions */}
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
<Button variant="outline" size="sm" onClick={toggleStats} title={showStats ? 'Hide stats' : 'Show stats'}>
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-1.5" /> : <EyeIcon className="w-4 h-4 mr-1.5" />}
{showStats ? 'Hide Stats' : 'Show Stats'}
</Button>
<Link href={`/events/${event.slug}`} target="_blank">
<Button variant="outline" size="sm">
<EyeIcon className="w-4 h-4 mr-1.5" />
@@ -522,10 +472,6 @@ export default function AdminEventDetailPage() {
<DropdownItem onClick={() => { setShowEditForm(true); setMobileHeaderMenuOpen(false); }}>
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
</DropdownItem>
<DropdownItem onClick={() => { toggleStats(); setMobileHeaderMenuOpen(false); }}>
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-2" /> : <EyeIcon className="w-4 h-4 mr-2" />}
{showStats ? 'Hide Stats' : 'Show Stats'}
</DropdownItem>
</Dropdown>
</div>
</div>
@@ -704,15 +650,15 @@ export default function AdminEventDetailPage() {
showAddTicketDropdown={showAddTicketDropdown}
setShowAddTicketDropdown={setShowAddTicketDropdown}
handleExportAttendees={handleExportAttendees}
setShowManualTicketModal={setShowManualTicketModal}
setShowAddAtDoorModal={setShowAddAtDoorModal}
setShowInviteGuestModal={setShowInviteGuestModal}
openAddTicket={openAddTicket}
setMobileFilterOpen={setMobileFilterOpen}
setShowExportSheet={setShowExportSheet}
setShowAddTicketSheet={setShowAddTicketSheet}
getPrimaryAction={getPrimaryAction}
handleOpenNoteModal={handleOpenNoteModal}
handleReactivate={handleReactivate}
handleMarkPaid={handleMarkPaid}
handleCheckin={handleCheckin}
/>
)}
@@ -777,27 +723,13 @@ export default function AdminEventDetailPage() {
setMobileFilterOpen={setMobileFilterOpen}
showAddTicketSheet={showAddTicketSheet}
setShowAddTicketSheet={setShowAddTicketSheet}
openAddTicket={openAddTicket}
showExportSheet={showExportSheet}
setShowExportSheet={setShowExportSheet}
handleExportAttendees={handleExportAttendees}
showTicketExportSheet={showTicketExportSheet}
setShowTicketExportSheet={setShowTicketExportSheet}
handleExportTickets={handleExportTickets}
showAddAtDoorModal={showAddAtDoorModal}
setShowAddAtDoorModal={setShowAddAtDoorModal}
addAtDoorForm={addAtDoorForm}
setAddAtDoorForm={setAddAtDoorForm}
handleAddAtDoor={handleAddAtDoor}
showManualTicketModal={showManualTicketModal}
setShowManualTicketModal={setShowManualTicketModal}
manualTicketForm={manualTicketForm}
setManualTicketForm={setManualTicketForm}
handleManualTicket={handleManualTicket}
showInviteGuestModal={showInviteGuestModal}
setShowInviteGuestModal={setShowInviteGuestModal}
inviteGuestForm={inviteGuestForm}
setInviteGuestForm={setInviteGuestForm}
handleInviteGuest={handleInviteGuest}
submitting={submitting}
showNoteModal={showNoteModal}
setShowNoteModal={setShowNoteModal}
@@ -810,6 +742,16 @@ export default function AdminEventDetailPage() {
setPreviewHtml={setPreviewHtml}
/>
<AddTicketModal
open={showAddTicketModal}
onClose={() => setShowAddTicketModal(false)}
form={addTicketForm}
setForm={setAddTicketForm}
onSubmit={handleAddTicket}
submitting={submitting}
eventPriceLabel={event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
/>
<EventFormModal
open={showEditForm}
event={event}
+14 -2
View File
@@ -222,7 +222,14 @@ export default function AdminEventsPage() {
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-600">{formatDate(event.startDatetime)}</td>
<td className="px-4 py-3 text-sm">{event.bookedCount || 0} / {event.capacity}</td>
<td className="px-4 py-3 text-sm">
{(event.bookedCount || 0) + (event.claimedCount || 0)} / {event.capacity}
{(event.claimedCount || 0) > 0 && (
<span className="block text-[11px] text-yellow-600">
{event.claimedCount} pending approval
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
{getStatusBadge(event.status)}
@@ -332,7 +339,12 @@ export default function AdminEventsPage() {
</div>
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-xs text-gray-500">{event.bookedCount || 0} / {event.capacity} spots</p>
<p className="text-xs text-gray-500">
{(event.bookedCount || 0) + (event.claimedCount || 0)} / {event.capacity} spots
{(event.claimedCount || 0) > 0 && (
<span className="text-yellow-600"> · {event.claimedCount} pending</span>
)}
</p>
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Link href={`/admin/events/${event.id}`}
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center">
+31
View File
@@ -6,6 +6,7 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { PrivacyProvider, usePrivacy } from '@/context/PrivacyContext';
import LanguageToggle from '@/components/LanguageToggle';
import Button from '@/components/ui/Button';
import {
@@ -26,6 +27,8 @@ import {
QrCodeIcon,
DocumentTextIcon,
QuestionMarkCircleIcon,
EyeIcon,
EyeSlashIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useState } from 'react';
@@ -34,11 +37,24 @@ export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<PrivacyProvider>
<AdminLayoutInner>{children}</AdminLayoutInner>
</PrivacyProvider>
);
}
function AdminLayoutInner({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const { t, locale } = useLanguage();
const { user, hasAdminAccess, isLoading, logout } = useAuth();
const { privacyMode, togglePrivacyMode } = usePrivacy();
const [sidebarOpen, setSidebarOpen] = useState(false);
type Role = 'admin' | 'organizer' | 'staff' | 'marketing';
@@ -220,6 +236,21 @@ export default function AdminLayout({
</button>
<div className="flex items-center gap-4 ml-auto">
<Button
variant="outline"
size="sm"
onClick={togglePrivacyMode}
title={privacyMode ? t('admin.privacy.show') : t('admin.privacy.hide')}
>
{privacyMode ? (
<EyeIcon className="w-4 h-4 sm:mr-1.5" />
) : (
<EyeSlashIcon className="w-4 h-4 sm:mr-1.5" />
)}
<span className="hidden sm:inline">
{privacyMode ? t('admin.privacy.show') : t('admin.privacy.hide')}
</span>
</Button>
<LanguageToggle />
<Link href="/">
<Button variant="outline" size="sm">
+48 -20
View File
@@ -4,8 +4,10 @@ import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { useAuth } from '@/context/AuthContext';
import { usePrivacy } from '@/context/PrivacyContext';
import { adminApi, DashboardData } from '@/lib/api';
import Card from '@/components/ui/Card';
import SensitiveValue from '@/components/admin/SensitiveValue';
import {
UsersIcon,
CalendarIcon,
@@ -15,11 +17,12 @@ import {
UserGroupIcon,
ExclamationTriangleIcon,
} from '@heroicons/react/24/outline';
import { parseDate } from '@/lib/utils';
import { parseDate, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
export default function AdminDashboardPage() {
const { t, locale } = useLanguage();
const { user } = useAuth();
const { privacyMode } = usePrivacy();
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
@@ -89,6 +92,7 @@ export default function AdminDashboardPage() {
</div>
{/* Stats Grid */}
{!privacyMode && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{statCards.map((stat) => (
<Link key={stat.label} href={stat.href}>
@@ -106,22 +110,22 @@ export default function AdminDashboardPage() {
</Link>
))}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Alerts */}
<Card className="p-6">
<h2 className="font-semibold text-lg mb-4">Alerts</h2>
<div className="space-y-3">
{/* Low capacity warnings */}
{/* Low capacity warnings (availableSeats accounts for paid + claimed seats) */}
{data?.upcomingEvents
.filter(event => {
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
const percentFull = ((event.bookedCount || 0) / event.capacity) * 100;
return percentFull >= 80 && spotsLeft > 0;
const spotsLeft = eventSpotsLeft(event);
return event.capacity > 0 && spotsLeft > 0 && spotsLeft / event.capacity <= 0.2;
})
.map(event => {
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100);
const spotsLeft = eventSpotsLeft(event);
const percentFull = Math.round(((event.capacity - spotsLeft) / event.capacity) * 100);
return (
<Link
key={event.id}
@@ -132,7 +136,7 @@ export default function AdminDashboardPage() {
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
<div>
<span className="text-sm font-medium">{event.title}</span>
<p className="text-xs text-gray-500">Only {spotsLeft} spots left ({percentFull}% full)</p>
<p className="text-xs text-gray-500"><SensitiveValue>Only {spotsLeft} spots left ({percentFull}% full)</SensitiveValue></p>
</div>
</div>
<span className="badge badge-warning">Low capacity</span>
@@ -142,7 +146,7 @@ export default function AdminDashboardPage() {
{/* Sold out events */}
{data?.upcomingEvents
.filter(event => Math.max(0, event.capacity - (event.bookedCount || 0)) === 0)
.filter(event => isEventSoldOut(event))
.map(event => (
<Link
key={event.id}
@@ -160,16 +164,30 @@ export default function AdminDashboardPage() {
</Link>
))}
{data && data.stats.pendingPayments > 0 && (
<Link
{/* Actionable: customer says they paid, needs verification */}
{data && (data.stats.awaitingApprovalPayments ?? 0) > 0 && (
<Link
href="/admin/payments"
className="flex items-center justify-between p-3 bg-yellow-50 rounded-btn hover:bg-yellow-100 transition-colors"
>
<div className="flex items-center gap-3">
<CurrencyDollarIcon className="w-5 h-5 text-yellow-600" />
<span className="text-sm">Pending payments</span>
<span className="text-sm">Payments awaiting verification</span>
</div>
<span className="badge badge-warning">{data.stats.pendingPayments}</span>
<span className="badge badge-warning"><SensitiveValue>{data.stats.awaitingApprovalPayments}</SensitiveValue></span>
</Link>
)}
{/* Informational: opened checkouts that never paid — hold no seats */}
{data && data.stats.pendingPayments > 0 && (
<Link
href="/admin/payments"
className="flex items-center justify-between p-3 bg-gray-50 rounded-btn hover:bg-gray-100 transition-colors"
>
<div className="flex items-center gap-3">
<CurrencyDollarIcon className="w-5 h-5 text-gray-400" />
<span className="text-sm text-gray-500">Unpaid started bookings</span>
</div>
<span className="badge badge-info"><SensitiveValue>{data.stats.pendingPayments}</SensitiveValue></span>
</Link>
)}
{data && data.stats.newContacts > 0 && (
@@ -186,10 +204,11 @@ export default function AdminDashboardPage() {
)}
{/* No alerts */}
{data &&
data.stats.pendingPayments === 0 &&
data.stats.newContacts === 0 &&
!data.upcomingEvents.some(e => ((e.bookedCount || 0) / e.capacity) >= 0.8) && (
{data &&
data.stats.pendingPayments === 0 &&
(data.stats.awaitingApprovalPayments ?? 0) === 0 &&
data.stats.newContacts === 0 &&
!data.upcomingEvents.some(e => e.capacity > 0 && eventSpotsLeft(e) / e.capacity <= 0.2) && (
<p className="text-gray-500 text-sm text-center py-2">No alerts at this time</p>
)}
</div>
@@ -217,9 +236,16 @@ export default function AdminDashboardPage() {
<p className="font-medium text-sm">{event.title}</p>
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
</div>
<span className="text-sm text-gray-600">
{event.bookedCount || 0}/{event.capacity}
</span>
{!privacyMode && (
<span className="text-sm text-gray-600">
{(event.bookedCount || 0) + (event.claimedCount || 0)}/{event.capacity}
{(event.claimedCount || 0) > 0 && (
<span className="text-xs text-yellow-600 block text-right">
{event.claimedCount} pending approval
</span>
)}
</span>
)}
</Link>
))}
</div>
@@ -227,6 +253,7 @@ export default function AdminDashboardPage() {
</Card>
{/* Quick Stats */}
{!privacyMode && (
<Card className="p-6">
<h2 className="font-semibold text-lg mb-4">Quick Stats</h2>
<div className="grid grid-cols-2 gap-4">
@@ -242,6 +269,7 @@ export default function AdminDashboardPage() {
</div>
</div>
</Card>
)}
</div>
</div>
);
+162 -30
View File
@@ -3,6 +3,7 @@
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
import { isManualProvider } from '@/lib/api/payments';
import { parseDate, formatRucDisplay } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
@@ -36,6 +37,9 @@ export default function AdminPaymentsPage() {
const { t, locale } = useLanguage();
const [payments, setPayments] = useState<PaymentWithDetails[]>([]);
const [pendingApprovalPayments, setPendingApprovalPayments] = useState<PaymentWithDetails[]>([]);
// Manual-gateway payments still in bare 'pending': the customer may have paid
// without clicking "I've paid" — approvable directly from the approval tab.
const [unclaimedManualPayments, setUnclaimedManualPayments] = useState<PaymentWithDetails[]>([]);
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
@@ -69,17 +73,19 @@ export default function AdminPaymentsPage() {
const loadData = async () => {
try {
setLoading(true);
const [pendingRes, allRes, eventsRes] = await Promise.all([
const [pendingRes, allRes, unclaimedRes, eventsRes] = await Promise.all([
paymentsApi.getPendingApproval(),
paymentsApi.getAll({
status: statusFilter || undefined,
paymentsApi.getAll({
status: statusFilter || undefined,
provider: providerFilter || undefined,
eventIds: eventFilter.length > 0 ? eventFilter : undefined,
}),
paymentsApi.getAll({ status: 'pending' }),
eventsApi.getAll(),
]);
setPendingApprovalPayments(pendingRes.payments);
setPayments(allRes.payments);
setUnclaimedManualPayments(unclaimedRes.payments.filter(p => isManualProvider(p.provider)));
setEvents(eventsRes.events);
} catch (error) {
toast.error('Failed to load payments');
@@ -88,15 +94,35 @@ export default function AdminPaymentsPage() {
}
};
// Approve with over-capacity confirmation: the backend rejects an approval
// that would overbook the event unless the admin explicitly allows it.
const approveWithCapacityConfirm = async (id: string, note?: string, email?: boolean) => {
try {
await paymentsApi.approve(id, note, email);
} catch (error: any) {
if (error?.code !== 'EVENT_OVER_CAPACITY') throw error;
const seatsLeft = error?.data?.availableSeats ?? 0;
const requested = error?.data?.requestedSeats ?? 1;
const message = locale === 'es'
? `El evento está lleno (quedan ${seatsLeft} lugares, esta reserva necesita ${requested}). ¿Aprobar de todas formas y sobrevender?`
: `This event is full (${seatsLeft} seat(s) left, this booking needs ${requested}). Approve anyway and overbook?`;
if (!confirm(message)) return false;
await paymentsApi.approve(id, note, email, true);
}
return true;
};
const handleApprove = async (payment: PaymentWithDetails) => {
setProcessing(true);
try {
await paymentsApi.approve(payment.id, noteText, sendEmail);
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
setSelectedPayment(null);
setNoteText('');
setSendEmail(true);
loadData();
const approved = await approveWithCapacityConfirm(payment.id, noteText, sendEmail);
if (approved) {
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
setSelectedPayment(null);
setNoteText('');
setSendEmail(true);
loadData();
}
} catch (error: any) {
toast.error(error.message || 'Failed to approve payment');
} finally {
@@ -140,11 +166,13 @@ export default function AdminPaymentsPage() {
const handleConfirmPayment = async (id: string) => {
try {
await paymentsApi.approve(id);
toast.success('Payment confirmed');
loadData();
} catch (error) {
toast.error('Failed to confirm payment');
const approved = await approveWithCapacityConfirm(id);
if (approved) {
toast.success('Payment confirmed');
loadData();
}
} catch (error: any) {
toast.error(error.message || 'Failed to confirm payment');
}
};
@@ -298,6 +326,33 @@ export default function AdminPaymentsPage() {
return labels[provider] || provider;
};
// Manual gateways need admin verification; automatic ones confirm themselves.
const getProviderKindBadge = (provider: string) => (
isManualProvider(provider) ? (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-orange-50 text-orange-600">
{locale === 'es' ? 'Manual' : 'Manual'}
</span>
) : (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-blue-50 text-blue-600">
{locale === 'es' ? 'Automático' : 'Auto'}
</span>
)
);
// Age of a claim/booking, e.g. "3h" / "2d"; used to surface rotting approvals.
const getAgeInfo = (dateStr?: string | null) => {
if (!dateStr) return null;
const ms = Date.now() - parseDate(dateStr).getTime();
if (ms < 0) return null;
const hours = Math.floor(ms / (60 * 60 * 1000));
const label = hours < 1
? (locale === 'es' ? 'hace <1 h' : '<1h ago')
: hours < 48
? (locale === 'es' ? `hace ${hours} h` : `${hours}h ago`)
: (locale === 'es' ? `hace ${Math.floor(hours / 24)} días` : `${Math.floor(hours / 24)}d ago`);
return { hours, label, stale: hours >= 48 };
};
// Helper to get booking info for a payment (ticket count and total)
const getBookingInfo = (payment: PaymentWithDetails) => {
if (!payment.ticket?.bookingId) {
@@ -331,6 +386,22 @@ export default function AdminPaymentsPage() {
});
})();
// Manual payments never claimed by the customer — they may have paid and
// forgotten to press "I've paid", so they stay directly approvable here.
// Hidden once the event has ended (same rule as pending approvals above).
const visibleUnclaimedManualPayments = (() => {
const now = new Date();
return unclaimedManualPayments.filter((payment) => {
const eventId = payment.event?.id;
const fullEvent = eventId ? events.find((e) => e.id === eventId) : undefined;
const endIso = fullEvent?.endDatetime
|| fullEvent?.startDatetime
|| payment.event?.startDatetime;
if (!endIso) return true;
return parseDate(endIso).getTime() >= now.getTime();
});
})();
// Get booking info for pending approval payments
const getPendingBookingInfo = (payment: PaymentWithDetails) => {
if (!payment.ticket?.bookingId) {
@@ -348,9 +419,15 @@ export default function AdminPaymentsPage() {
};
};
// Calculate totals (sum all individual payment amounts)
const totalPending = payments
.filter(p => p.status === 'pending' || p.status === 'pending_approval')
// Calculate totals (sum all individual payment amounts).
// Claimed ('pending_approval') money is probably already in the account and
// just needs verification; bare 'pending' money may never arrive — keep the
// two apart so the totals don't overstate what's owed.
const totalAwaitingVerification = payments
.filter(p => p.status === 'pending_approval')
.reduce((sum, p) => sum + Number(p.amount), 0);
const totalUnclaimed = payments
.filter(p => p.status === 'pending')
.reduce((sum, p) => sum + Number(p.amount), 0);
const totalPaid = payments
.filter(p => p.status === 'paid')
@@ -370,9 +447,6 @@ export default function AdminPaymentsPage() {
return count;
};
const pendingBookingsCount = getUniqueBookingsCount(
payments.filter(p => p.status === 'pending' || p.status === 'pending_approval')
);
const paidBookingsCount = getUniqueBookingsCount(
payments.filter(p => p.status === 'paid')
);
@@ -721,9 +795,12 @@ export default function AdminPaymentsPage() {
<ClockIcon className="w-5 h-5 text-gray-600" />
</div>
<div>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p>
<p className="text-xl font-bold">{formatCurrency(totalPending, 'PYG')}</p>
<p className="text-xs text-gray-400">{pendingBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}</p>
<p className="text-sm text-gray-500">{locale === 'es' ? 'Por Verificar' : 'Awaiting Verification'}</p>
<p className="text-xl font-bold text-yellow-600">{formatCurrency(totalAwaitingVerification, 'PYG')}</p>
<p className="text-xs text-gray-400">
{locale === 'es' ? 'Sin pagar (sin reclamar): ' : 'Unpaid (unclaimed): '}
{formatCurrency(totalUnclaimed, 'PYG')}
</p>
</div>
</div>
</Card>
@@ -817,13 +894,18 @@ export default function AdminPaymentsPage() {
<span className="flex items-center gap-1">
{getProviderIcon(payment.provider)}
{getProviderLabel(payment.provider)}
{getProviderKindBadge(payment.provider)}
</span>
{payment.userMarkedPaidAt && (
<span className="flex items-center gap-1">
<ClockIcon className="w-3 h-3" />
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
</span>
)}
{payment.userMarkedPaidAt && (() => {
const age = getAgeInfo(payment.userMarkedPaidAt);
return (
<span className={clsx('flex items-center gap-1', age?.stale && 'text-amber-600 font-medium')}>
<ClockIcon className="w-3 h-3" />
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
{age && <span>({age.label})</span>}
</span>
);
})()}
</div>
{payment.payerName && (
<p className="text-xs text-amber-600 mt-1 font-medium">
@@ -841,6 +923,55 @@ export default function AdminPaymentsPage() {
})}
</div>
)}
{/* Manual payments the customer never confirmed they may have paid
(bank transfer / TPago received) without pressing "I've paid".
Approving one claims a seat, so it goes through the same
over-capacity confirmation as any approval. */}
{visibleUnclaimedManualPayments.length > 0 && (
<details className="mt-8">
<summary className="cursor-pointer text-sm font-medium text-gray-600 select-none">
{locale === 'es'
? `Pagos manuales sin confirmar por el cliente (${visibleUnclaimedManualPayments.length})`
: `Manual payments not yet confirmed by customer (${visibleUnclaimedManualPayments.length})`}
<span className="block text-xs font-normal text-gray-400 mt-0.5">
{locale === 'es'
? 'Puede que hayan pagado sin presionar "Ya pagué". No reservan lugar hasta ser aprobados.'
: 'They may have paid without pressing "I\'ve paid". These hold no seat until approved.'}
</span>
</summary>
<div className="space-y-3 mt-4">
{visibleUnclaimedManualPayments.map((payment) => {
const age = getAgeInfo(payment.createdAt);
return (
<Card key={payment.id} className="p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-8 h-8 bg-gray-100 rounded-full flex items-center justify-center flex-shrink-0">
{getProviderIcon(payment.provider)}
</div>
<div className="min-w-0">
<p className="text-sm font-medium truncate">
{payment.ticket?.attendeeFirstName} {payment.ticket?.attendeeLastName}
<span className="text-gray-400 font-normal"> · {formatCurrency(payment.amount, payment.currency)}</span>
</p>
<p className="text-xs text-gray-400 truncate">
{payment.event?.title}
{' · '}{getProviderLabel(payment.provider)}
{age && <span> · {age.label}</span>}
</p>
</div>
</div>
<Button onClick={() => setSelectedPayment(payment)} size="sm" variant="outline" className="flex-shrink-0 min-h-[40px]">
{locale === 'es' ? 'Revisar' : 'Review'}
</Button>
</div>
</Card>
);
})}
</div>
</details>
)}
</>
)}
@@ -1004,6 +1135,7 @@ export default function AdminPaymentsPage() {
<td className="px-4 py-3">
<div className="flex items-center gap-1.5 text-xs text-gray-600">
{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}
{getProviderKindBadge(payment.provider)}
</div>
</td>
<td className="px-4 py-3">{getStatusBadge(payment.status)}</td>
@@ -1063,7 +1195,7 @@ export default function AdminPaymentsPage() {
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
<span className="font-medium text-gray-700">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</span>
<span className="text-gray-300">|</span>
<span className="flex items-center gap-1">{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}</span>
<span className="flex items-center gap-1">{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)} {getProviderKindBadge(payment.provider)}</span>
{bookingInfo.ticketCount > 1 && (
<><span className="text-gray-300">|</span><span className="text-purple-600">{bookingInfo.ticketCount} tickets</span></>
)}
+12 -1
View File
@@ -18,7 +18,7 @@ import {
VideoCameraIcon,
} from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import { parseDate, formatCurrency, EVENT_TIMEZONE } from '@/lib/utils';
import clsx from 'clsx';
// ─── Types ───────────────────────────────────────────────────
@@ -273,6 +273,17 @@ function ValidTicketScreen({
{validation.ticket?.attendeeEmail && (
<p className="text-emerald-100 text-lg mb-4">{validation.ticket.attendeeEmail}</p>
)}
{/* Unpaid tickets are valid for entry but the balance is collected at the door */}
{validation.ticket?.paymentStatus === 'unpaid' && (
<div className="bg-orange-500 rounded-2xl px-6 py-3 w-full max-w-sm text-center mb-3">
<p className="font-bold text-lg">UNPAID collect payment</p>
{!!validation.ticket.amountDue && (
<p className="text-orange-100 text-sm">
Balance due: {formatCurrency(validation.ticket.amountDue)}
</p>
)}
</div>
)}
<div className="bg-white/15 rounded-2xl px-6 py-4 w-full max-w-sm space-y-2 text-center">
{validation.event && (
<p className="font-semibold text-lg">{validation.event.title}</p>
+96 -4
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { usersApi, eventsApi, User, Event } from '@/lib/api';
import { parseDate, formatRucDisplay } from '@/lib/utils';
@@ -9,12 +9,26 @@ import Button from '@/components/ui/Button';
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
import Input from '@/components/ui/Input';
import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents';
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast';
import clsx from 'clsx';
type RegisteredRange = '' | '7d' | '30d' | '90d';
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
function getPageNumbers(current: number, totalPages: number): (number | '...')[] {
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
const pages: (number | '...')[] = [1];
const start = Math.max(2, current - 1);
const end = Math.min(totalPages - 1, current + 1);
if (start > 2) pages.push('...');
for (let i = start; i <= end; i++) pages.push(i);
if (end < totalPages - 1) pages.push('...');
pages.push(totalPages);
return pages;
}
function registeredAfterFromRange(range: RegisteredRange): string | undefined {
if (!range) return undefined;
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90;
@@ -35,6 +49,8 @@ export default function AdminUsersPage() {
const [eventFilter, setEventFilter] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [editForm, setEditForm] = useState({
name: '',
@@ -57,9 +73,20 @@ export default function AdminUsersPage() {
eventsApi.getAll().then((res) => setEvents(res.events)).catch(() => {});
}, []);
// When a filter changes, jump back to page 1 before fetching (skipping the
// fetch for the stale page); otherwise fetch for the current page/pageSize.
const filterKey = JSON.stringify([roleFilter, statusFilter, hasBookingsFilter, registeredRange, eventFilter, debouncedSearch]);
const prevFilterKey = useRef(filterKey);
useEffect(() => {
if (prevFilterKey.current !== filterKey) {
prevFilterKey.current = filterKey;
if (page !== 1) {
setPage(1);
return;
}
}
loadUsers();
}, [roleFilter, statusFilter, hasBookingsFilter, registeredRange, eventFilter, debouncedSearch]);
}, [filterKey, page, pageSize]);
const hasActiveFilters =
roleFilter || statusFilter || hasBookingsFilter || registeredRange || eventFilter || searchQuery;
@@ -82,10 +109,16 @@ export default function AdminUsersPage() {
registeredAfter: registeredAfterFromRange(registeredRange),
eventId: eventFilter || undefined,
search: debouncedSearch.trim() || undefined,
pageSize: 200,
page,
pageSize,
});
setUsers(users);
setTotal(total);
// If the current page emptied out (e.g. after deleting the last user on
// it), fall back to the new last page.
if (users.length === 0 && total > 0 && page > 1) {
setPage(Math.max(1, Math.ceil(total / pageSize)));
}
} catch (error) {
toast.error('Failed to load users');
} finally {
@@ -385,6 +418,65 @@ export default function AdminUsersPage() {
)}
</div>
{/* Pagination */}
{total > 0 && (
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-gray-600">
<label htmlFor="users-page-size" className="whitespace-nowrap">Per page</label>
<select
id="users-page-size"
value={pageSize}
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
className="px-2 py-1.5 rounded-btn border border-secondary-light-gray text-sm"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
<span className="text-xs text-gray-500 whitespace-nowrap">
{(page - 1) * pageSize + 1}&ndash;{Math.min(page * pageSize, total)} of {total}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => setPage(page - 1)}
disabled={page <= 1}
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
aria-label="Previous page"
>
<ChevronLeftIcon className="w-4 h-4" />
</button>
{getPageNumbers(page, Math.max(1, Math.ceil(total / pageSize))).map((p, i) =>
p === '...' ? (
<span key={`ellipsis-${i}`} className="px-1.5 text-sm text-gray-400">&hellip;</span>
) : (
<button
key={p}
onClick={() => setPage(p)}
className={clsx(
'min-h-[36px] min-w-[36px] px-2 rounded-btn text-sm',
p === page
? 'bg-primary-yellow text-primary-dark font-semibold'
: 'border border-secondary-light-gray text-gray-600 hover:bg-gray-50'
)}
aria-current={p === page ? 'page' : undefined}
>
{p}
</button>
)
)}
<button
onClick={() => setPage(page + 1)}
disabled={page >= Math.ceil(total / pageSize)}
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
aria-label="Next page"
>
<ChevronRightIcon className="w-4 h-4" />
</button>
</div>
</div>
)}
{/* Mobile Filter BottomSheet */}
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
<div className="space-y-4">
@@ -0,0 +1,22 @@
'use client';
import { ReactNode } from 'react';
import { usePrivacy } from '@/context/PrivacyContext';
export const PRIVACY_MASK = '••••';
/**
* Renders its children normally, but shows a mask while privacy mode is on.
* Use for inline sensitive numbers that can't be hidden without breaking
* the surrounding row/badge.
*/
export default function SensitiveValue({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
const { privacyMode } = usePrivacy();
return <span className={className}>{privacyMode ? PRIVACY_MASK : children}</span>;
}
+66
View File
@@ -0,0 +1,66 @@
'use client';
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
interface PrivacyContextType {
/** true = stats and sensitive data are hidden */
privacyMode: boolean;
setPrivacyMode: (value: boolean) => void;
togglePrivacyMode: () => void;
}
const PrivacyContext = createContext<PrivacyContextType | undefined>(undefined);
// Same key the old per-page useStatsPrivacy hook used ('true' = hidden), so
// existing operators keep their saved preference.
const STORAGE_KEY = 'spanglish-admin-stats-hidden';
export function PrivacyProvider({ children }: { children: ReactNode }) {
const [privacyMode, setPrivacyModeState] = useState(false);
useEffect(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) {
setPrivacyModeState(stored === 'true');
}
} catch {
// localStorage unavailable (private mode etc.) - keep default
}
}, []);
const setPrivacyMode = useCallback((value: boolean) => {
setPrivacyModeState(value);
try {
localStorage.setItem(STORAGE_KEY, String(value));
} catch {
// ignore
}
}, []);
const togglePrivacyMode = useCallback(() => {
setPrivacyModeState((prev) => {
const next = !prev;
try {
localStorage.setItem(STORAGE_KEY, String(next));
} catch {
// ignore
}
return next;
});
}, []);
return (
<PrivacyContext.Provider value={{ privacyMode, setPrivacyMode, togglePrivacyMode }}>
{children}
</PrivacyContext.Provider>
);
}
export function usePrivacy() {
const context = useContext(PrivacyContext);
if (context === undefined) {
throw new Error('usePrivacy must be used within a PrivacyProvider');
}
return context;
}
-41
View File
@@ -1,41 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
const STORAGE_KEY = 'spanglish-admin-stats-hidden';
export function useStatsPrivacy() {
const [showStats, setShowStatsState] = useState(true);
useEffect(() => {
if (typeof window === 'undefined') return;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored !== null) {
setShowStatsState(stored !== 'true');
}
} catch {
// ignore
}
}, []);
const setShowStats = useCallback((value: boolean | ((prev: boolean) => boolean)) => {
setShowStatsState((prev) => {
const next = typeof value === 'function' ? value(prev) : value;
try {
if (typeof window !== 'undefined') {
localStorage.setItem(STORAGE_KEY, String(!next));
}
} catch {
// ignore
}
return next;
});
}, []);
const toggleStats = useCallback(() => {
setShowStats((prev) => !prev);
}, [setShowStats]);
return [showStats, setShowStats, toggleStats] as const;
}
+4
View File
@@ -255,6 +255,10 @@
}
},
"admin": {
"privacy": {
"hide": "Hide Stats",
"show": "Show Stats"
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome back",
+4
View File
@@ -255,6 +255,10 @@
}
},
"admin": {
"privacy": {
"hide": "Ocultar Datos",
"show": "Mostrar Datos"
},
"dashboard": {
"title": "Panel de Control",
"welcome": "Bienvenido de nuevo",
+6 -1
View File
@@ -34,7 +34,12 @@ export async function fetchApi<T>(
const errorMessage = typeof errorData.error === 'string'
? errorData.error
: (errorData.message || JSON.stringify(errorData) || 'Request failed');
throw new Error(errorMessage);
const error = new Error(errorMessage);
// Preserve structured error info (e.g. code: 'EVENT_OVER_CAPACITY') so
// callers can react beyond the message text.
(error as any).code = errorData.code;
(error as any).data = errorData;
throw error;
}
return res.json();
+10 -2
View File
@@ -1,6 +1,14 @@
import { fetchApi } from './client';
import type { Payment, PaymentWithDetails } from './types';
// Mirrors backend/src/lib/paymentProviders.ts: manual gateways need an admin to
// verify the money arrived; automatic ones (lightning) confirm themselves.
export const MANUAL_PAYMENT_PROVIDERS = ['tpago', 'bank_transfer', 'card', 'cash'];
export function isManualProvider(provider: string): boolean {
return MANUAL_PAYMENT_PROVIDERS.includes(provider);
}
export const paymentsApi = {
getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean; eventId?: string; eventIds?: string[] }) => {
const query = new URLSearchParams();
@@ -21,10 +29,10 @@ export const paymentsApi = {
body: JSON.stringify(data),
}),
approve: (id: string, adminNote?: string, sendEmail: boolean = true) =>
approve: (id: string, adminNote?: string, sendEmail: boolean = true, allowOverCapacity: boolean = false) =>
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/approve`, {
method: 'POST',
body: JSON.stringify({ adminNote, sendEmail }),
body: JSON.stringify({ adminNote, sendEmail, allowOverCapacity }),
}),
reject: (id: string, adminNote?: string, sendEmail: boolean = true) =>
+6 -16
View File
@@ -105,30 +105,20 @@ export const ticketsApi = {
body: JSON.stringify(data),
}),
manualCreate: (data: {
eventId: string;
firstName: string;
lastName?: string;
email: string;
phone?: string;
preferredLanguage?: 'en' | 'es';
adminNote?: string;
}) =>
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/manual', {
method: 'POST',
body: JSON.stringify(data),
}),
guestCreate: (data: {
// Unified add-attendee endpoint behind the single Add Ticket modal
// (paid = confirmation + QR, unpaid = pay link + door collection, guest = free comp)
adminAdd: (data: {
eventId: string;
type: 'paid' | 'unpaid' | 'guest';
firstName: string;
lastName?: string;
email?: string;
phone?: string;
preferredLanguage?: 'en' | 'es';
checkinNow?: boolean;
adminNote?: string;
}) =>
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/guest', {
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/add', {
method: 'POST',
body: JSON.stringify(data),
}),
+9 -2
View File
@@ -18,8 +18,9 @@ export interface Event {
bannerUrl?: string;
externalBookingEnabled?: boolean;
externalBookingUrl?: string;
bookedCount?: number;
availableSeats?: number;
bookedCount?: number; // paid seats (confirmed + checked_in)
claimedCount?: number; // "I've paid" claims awaiting admin verification (hold seats)
availableSeats?: number; // capacity - booked - claimed; the server-authoritative number
isFeatured?: boolean;
createdAt: string;
updatedAt: string;
@@ -52,6 +53,7 @@ export interface Ticket {
qrCode: string;
adminNote?: string;
isGuest?: boolean;
paymentStatus?: 'paid' | 'unpaid' | 'comp';
createdAt: string;
event?: Event;
payment?: Payment;
@@ -69,6 +71,8 @@ export interface TicketValidationResult {
attendeeEmail?: string;
attendeePhone?: string;
status: string;
paymentStatus?: 'paid' | 'unpaid' | 'comp';
amountDue?: number;
checkinAt?: string;
checkedInBy?: string;
};
@@ -221,7 +225,10 @@ export interface DashboardData {
totalEvents: number;
totalTickets: number;
confirmedTickets: number;
/** Checkouts opened but never paid nor claimed — informational, holds no seat */
pendingPayments: number;
/** Customer says they paid; needs admin verification — actionable */
awaitingApprovalPayments: number;
totalRevenue: number;
newContacts: number;
totalSubscribers: number;
+30
View File
@@ -205,3 +205,33 @@ export function getTpagoLink(
const key = (count <= 1 ? 'tpagoLink' : `tpagoLink${count}`) as keyof TpagoLinkConfig;
return config[key] || config.tpagoLink || null;
}
/**
* Spots left for an event, trusting the server's `availableSeats` (which uses
* the same seat-holding formula the booking API enforces: paid + claimed
* seats count, abandoned pending bookings don't). Falls back to deriving it
* from the counts for older API responses.
*/
export function eventSpotsLeft(event: {
capacity: number;
bookedCount?: number;
claimedCount?: number;
availableSeats?: number;
}): number {
if (typeof event.availableSeats === 'number') {
return Math.max(0, event.availableSeats);
}
return Math.max(
0,
(event.capacity ?? 0) - (event.bookedCount ?? 0) - (event.claimedCount ?? 0)
);
}
export function isEventSoldOut(event: {
capacity: number;
bookedCount?: number;
claimedCount?: number;
availableSeats?: number;
}): boolean {
return eventSpotsLeft(event) <= 0;
}