5 Commits
Author SHA1 Message Date
MichilisandCursor f5fe87613f Add stable /next and /featured URLs that redirect to the current event.
These short links always resolve to the latest upcoming or featured event so they can be shared without updating when events rotate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 05:35:52 +00:00
MichilisandCursor 613bd7be1d Refactor monolithic modules and harden booking, email, and auth infrastructure.
Split oversized frontend API client, email service, and admin/booking pages into focused modules while preserving import surfaces, and add Redis-backed queues, stale booking cleanup, stronger auth, and scale deployment configs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 07:12:59 +00:00
MichilisandCursor f0e2de2834 Fix Lightning payment UI not updating after LNbits invoice is paid.
Ensure the booking page receives payment confirmation via a streaming SSE proxy, parallel status polling, and more reliable backend event delivery.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 04:15:02 +00:00
MichilisandCursor a6840ea953 Harden auth, payments, and frontend against review findings.
Close exploitable gaps in booking/payment flows, enforce token versioning and account checks, gate sensitive payment data, and add middleware plus input validation across admin routes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 19:59:02 +00:00
Michilis fc4af38e8a Add per-quantity TPago payment links for multi-ticket checkout.
Each ticket quantity (1-5) can have its own TPago link so checkout, emails, and admin config use the correct fixed-amount URL.
2026-06-18 23:59:17 +00:00
103 changed files with 9428 additions and 6072 deletions
+82 -1
View File
@@ -14,7 +14,7 @@ A full-stack web app for organizing and managing language exchange events (Asunc
- **Backend**: Node.js + TypeScript, Hono, Drizzle ORM, SQLite (default) or PostgreSQL
- **Auth**: JWT (via `jose`), **Argon2id** password hashing (with legacy bcrypt verification for older hashes)
- **Email**: `nodemailer` (SMTP) with optional provider config
- **Frontend**: Next.js 14 (App Router), Tailwind CSS, SWR, Heroicons
- **Frontend**: Next.js 14 (App Router), Tailwind CSS, Heroicons
## Local development
@@ -86,6 +86,7 @@ Key settings (see `backend/.env.example` for the full list):
- **URLs/ports**: `PORT`, `API_URL`, `FRONTEND_URL`
- **Email**: `EMAIL_PROVIDER` (`console|smtp|resend`) and corresponding credentials
- **Payments (optional)**: Stripe/MercadoPago/LNbits configuration
- **Scaling (optional)**: `REDIS_URL`, `DB_POOL_MAX`, and `S3_*` (see "Horizontal scaling" below)
### Frontend (`frontend/.env`)
@@ -160,6 +161,86 @@ npm run db:migrate
Then install/enable the systemd services and nginx configs for your server.
## Horizontal scaling
The backend can run as a single instance with zero extra configuration (the
default), or as multiple replicas behind a load balancer. Scaling support is
fully optional and backward compatible: if you set none of the variables below,
the app behaves exactly as before with in-memory state and local-disk uploads.
### Requirements for multiple instances
- **Use PostgreSQL.** Set `DB_TYPE=postgres`. SQLite is a single local file and
cannot be shared safely across instances.
- **Set `REDIS_URL`.** This makes the following subsystems shared across
instances instead of per process:
- distributed cache
- rate limiting (shared sliding/fixed window)
- pub/sub for real-time payment events, so an SSE client connected to one
instance still receives an event when the LNbits webhook lands on another
- distributed locks (so only one instance seeds email templates per boot and
only one instance polls LNbits per pending ticket)
- the email hourly cap (`MAX_EMAILS_PER_HOUR`) becomes a global cap
- **Tune the DB pool.** `DB_POOL_MAX` is the max Postgres connections per
instance (default 10). Keep `DB_POOL_MAX * replicas` below the Postgres
`max_connections` setting (default 100). For example, 5 replicas at
`DB_POOL_MAX=15` uses up to 75 connections.
If Redis is configured but becomes unreachable at runtime, each subsystem
degrades gracefully (rate limiter fails open, cache misses fall through to the
DB, locks proceed) and the API keeps serving rather than crashing.
### Uploads across instances
Media uploads default to local disk (`./uploads`). With more than one instance
you must use shared storage so a file uploaded on one instance is readable on
the others. Two options:
- **S3-compatible storage (recommended):** set `S3_ENDPOINT`, `S3_BUCKET`,
`S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY` (and optionally `S3_PUBLIC_URL`,
`S3_REGION`, `S3_FORCE_PATH_STYLE`). Works with Garage, MinIO, or AWS S3.
- **Shared volume:** mount the same `./uploads` directory (e.g. NFS) into every
instance.
### Real-time payment SSE behind a load balancer
The payment status stream (`/api/lnbits/stream/:ticketId`) is a long-lived SSE
connection. With Redis pub/sub enabled, any instance can deliver the payment
event regardless of which instance holds the socket, so sticky sessions are not
strictly required. Enabling sticky sessions (IP hash) for the SSE path is still
a reasonable optimization.
### Health and observability
`GET /health` always returns 200 and reports Redis connectivity and which
backend each subsystem selected, for example:
```json
{
"status": "ok",
"redis": { "enabled": true, "healthy": true },
"backends": {
"cache": "redis",
"rateLimiter": "redis",
"pubsub": "redis",
"lock": "redis",
"storage": "s3"
}
}
```
The same selection is logged once at startup.
### docker-compose example (N replicas + Redis)
A ready-to-edit snippet lives at `deploy/docker-compose.scale.yml`. It runs
Postgres, Redis, and the API scaled to multiple replicas behind nginx. Bring it
up with:
```bash
docker compose -f deploy/docker-compose.scale.yml up --build --scale api=3
```
## Documentation
- **Specs / notes**: `about/`
+41
View File
@@ -8,6 +8,40 @@ DATABASE_URL=./data/spanglish.db
# For PostgreSQL
# DATABASE_URL=postgresql://user:password@localhost:5432/spanglish
# Max PostgreSQL connections per instance (default 10). When running multiple
# replicas, keep DB_POOL_MAX * replicas below the Postgres max_connections limit.
# DB_POOL_MAX=10
# ---------------------------------------------------------------------------
# Horizontal scaling (all optional)
# ---------------------------------------------------------------------------
# Leave everything below UNSET to run as a single instance with in-memory
# backends and local-disk uploads (zero-config, identical to the original
# behavior). Set them to run multiple API replicas behind a load balancer.
#
# 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_URL=redis://localhost:6379
# 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
# are shared across instances. When unset, uploads are written to ./uploads on
# local disk (the default).
# S3_ENDPOINT=https://garage.example.com
# S3_REGION=garage
# S3_BUCKET=spanglish-media
# S3_ACCESS_KEY_ID=
# S3_SECRET_ACCESS_KEY=
# Public base URL used to build fileUrl for stored objects (CDN or web endpoint).
# If unset, a path-style URL against S3_ENDPOINT/S3_BUCKET is used.
# S3_PUBLIC_URL=https://media.example.com
# Use path-style addressing (true for Garage/MinIO). Defaults to true.
# S3_FORCE_PATH_STYLE=true
# JWT Secret (change in production!)
JWT_SECRET=your-super-secret-key-change-in-production
@@ -73,3 +107,10 @@ SMTP_TLS_REJECT_UNAUTHORIZED=true
# If the limit is reached, queued emails will pause and resume automatically
MAX_EMAILS_PER_HOUR=30
# Pending Booking Cleanup
# Pending bookings whose payment is still unpaid (not awaiting admin approval)
# are cancelled after this many minutes, freeing the seats (default: 30)
PENDING_BOOKING_TTL_MINUTES=30
# How often the cleanup job runs, in milliseconds (default: 300000 = 5 min)
PENDING_BOOKING_CLEANUP_INTERVAL_MS=300000
+2
View File
@@ -13,6 +13,7 @@
"db:import": "tsx src/db/import.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1075.0",
"@hono/node-server": "^1.11.4",
"@hono/swagger-ui": "^0.4.0",
"@hono/zod-openapi": "^0.14.4",
@@ -22,6 +23,7 @@
"dotenv": "^17.2.3",
"drizzle-orm": "^0.31.2",
"hono": "^4.4.7",
"ioredis": "^5.11.1",
"jose": "^5.4.0",
"nanoid": "^5.0.7",
"nodemailer": "^7.0.13",
+3
View File
@@ -12,8 +12,11 @@ const dbType = process.env.DB_TYPE || 'sqlite';
let db: ReturnType<typeof drizzleSqlite> | ReturnType<typeof drizzlePg>;
if (dbType === 'postgres') {
// Cap connections per instance so that, when running multiple replicas,
// DB_POOL_MAX * replicas stays below the Postgres max_connections limit.
const pool = new pg.Pool({
connectionString: process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish',
max: Number(process.env.DB_POOL_MAX || 10),
});
db = drizzlePg(pool, { schema });
} else {
+96 -1
View File
@@ -5,7 +5,7 @@ import { uniqueSlug } from '../lib/slugify.js';
const dbType = process.env.DB_TYPE || 'sqlite';
console.log(`Database type: ${dbType}`);
console.log(`Database URL: ${process.env.DATABASE_URL?.substring(0, 30)}...`);
// Do not log DATABASE_URL: it may contain credentials.
async function migrate() {
console.log('Running migrations...');
@@ -43,6 +43,9 @@ async function migrate() {
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN account_status TEXT NOT NULL DEFAULT 'active'`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
// Magic link tokens table
await (db as any).run(sql`
@@ -259,6 +262,10 @@ async function migrate() {
id TEXT PRIMARY KEY,
tpago_enabled INTEGER NOT NULL DEFAULT 0,
tpago_link TEXT,
tpago_link_2 TEXT,
tpago_link_3 TEXT,
tpago_link_4 TEXT,
tpago_link_5 TEXT,
tpago_instructions TEXT,
tpago_instructions_es TEXT,
bank_transfer_enabled INTEGER NOT NULL DEFAULT 0,
@@ -283,6 +290,13 @@ async function migrate() {
await (db as any).run(sql`ALTER TABLE payment_options ADD COLUMN allow_duplicate_bookings INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
// Add per-quantity TPago link columns to payment_options if they don't exist
for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) {
try {
await (db as any).run(sql.raw(`ALTER TABLE payment_options ADD COLUMN ${col} TEXT`));
} catch (e) { /* column may already exist */ }
}
// Event payment overrides table
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS event_payment_overrides (
@@ -290,6 +304,10 @@ async function migrate() {
event_id TEXT NOT NULL REFERENCES events(id),
tpago_enabled INTEGER,
tpago_link TEXT,
tpago_link_2 TEXT,
tpago_link_3 TEXT,
tpago_link_4 TEXT,
tpago_link_5 TEXT,
tpago_instructions TEXT,
tpago_instructions_es TEXT,
bank_transfer_enabled INTEGER,
@@ -309,6 +327,13 @@ async function migrate() {
)
`);
// Add per-quantity TPago link columns to event_payment_overrides if they don't exist
for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) {
try {
await (db as any).run(sql.raw(`ALTER TABLE event_payment_overrides ADD COLUMN ${col} TEXT`));
} catch (e) { /* column may already exist */ }
}
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS contacts (
id TEXT PRIMARY KEY,
@@ -407,6 +432,18 @@ async function migrate() {
)
`);
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS email_queue (
id TEXT PRIMARY KEY,
params TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at TEXT NOT NULL,
processed_at TEXT
)
`);
// Site settings table
await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS site_settings (
@@ -519,6 +556,9 @@ async function migrate() {
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN account_status VARCHAR(20) NOT NULL DEFAULT 'active'`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
// Magic link tokens table
await (db as any).execute(sql`
@@ -702,6 +742,10 @@ async function migrate() {
id UUID PRIMARY KEY,
tpago_enabled INTEGER NOT NULL DEFAULT 0,
tpago_link VARCHAR(500),
tpago_link_2 VARCHAR(500),
tpago_link_3 VARCHAR(500),
tpago_link_4 VARCHAR(500),
tpago_link_5 VARCHAR(500),
tpago_instructions TEXT,
tpago_instructions_es TEXT,
bank_transfer_enabled INTEGER NOT NULL DEFAULT 0,
@@ -726,12 +770,23 @@ async function migrate() {
await (db as any).execute(sql`ALTER TABLE payment_options ADD COLUMN allow_duplicate_bookings INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
// Add per-quantity TPago link columns to payment_options if they don't exist
for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) {
try {
await (db as any).execute(sql.raw(`ALTER TABLE payment_options ADD COLUMN ${col} VARCHAR(500)`));
} catch (e) { /* column may already exist */ }
}
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS event_payment_overrides (
id UUID PRIMARY KEY,
event_id UUID NOT NULL REFERENCES events(id),
tpago_enabled INTEGER,
tpago_link VARCHAR(500),
tpago_link_2 VARCHAR(500),
tpago_link_3 VARCHAR(500),
tpago_link_4 VARCHAR(500),
tpago_link_5 VARCHAR(500),
tpago_instructions TEXT,
tpago_instructions_es TEXT,
bank_transfer_enabled INTEGER,
@@ -751,6 +806,13 @@ async function migrate() {
)
`);
// Add per-quantity TPago link columns to event_payment_overrides if they don't exist
for (const col of ['tpago_link_2', 'tpago_link_3', 'tpago_link_4', 'tpago_link_5']) {
try {
await (db as any).execute(sql.raw(`ALTER TABLE event_payment_overrides ADD COLUMN ${col} VARCHAR(500)`));
} catch (e) { /* column may already exist */ }
}
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS contacts (
id UUID PRIMARY KEY,
@@ -849,6 +911,18 @@ async function migrate() {
)
`);
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS email_queue (
id UUID PRIMARY KEY,
params TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at TIMESTAMP NOT NULL,
processed_at TIMESTAMP
)
`);
// Site settings table
await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS site_settings (
@@ -930,6 +1004,27 @@ async function migrate() {
`);
}
// Indexes on foreign-key / hot-filter columns (CREATE INDEX IF NOT EXISTS works on both engines)
const indexStatements = [
`CREATE INDEX IF NOT EXISTS tickets_event_id_idx ON tickets(event_id)`,
`CREATE INDEX IF NOT EXISTS tickets_user_id_idx ON tickets(user_id)`,
`CREATE INDEX IF NOT EXISTS tickets_booking_id_idx ON tickets(booking_id)`,
`CREATE INDEX IF NOT EXISTS tickets_status_idx ON tickets(status)`,
`CREATE INDEX IF NOT EXISTS payments_ticket_id_idx ON payments(ticket_id)`,
`CREATE INDEX IF NOT EXISTS payments_status_idx ON payments(status)`,
`CREATE INDEX IF NOT EXISTS email_logs_event_id_idx ON email_logs(event_id)`,
`CREATE INDEX IF NOT EXISTS magic_link_tokens_token_idx ON magic_link_tokens(token)`,
];
for (const stmt of indexStatements) {
try {
if (dbType === 'sqlite') {
await (db as any).run(sql.raw(stmt));
} else {
await (db as any).execute(sql.raw(stmt));
}
} catch (e) { /* index may already exist */ }
}
// Backfill slugs for any events that don't have one yet (shared across DB types).
// Ordered by creation so duplicate titles get deterministic -2, -3 suffixes.
const allEvents = await dbAll<{ id: string; title: string; slug: string | null }>(
+45
View File
@@ -18,6 +18,8 @@ export const sqliteUsers = sqliteTable('users', {
googleId: text('google_id'),
rucNumber: text('ruc_number'),
accountStatus: text('account_status', { enum: ['active', 'unclaimed', 'suspended'] }).notNull().default('active'),
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
tokenVersion: integer('token_version').notNull().default(0),
createdAt: text('created_at').notNull(),
updatedAt: text('updated_at').notNull(),
});
@@ -135,6 +137,10 @@ export const sqlitePaymentOptions = sqliteTable('payment_options', {
// TPago configuration
tpagoEnabled: integer('tpago_enabled', { mode: 'boolean' }).notNull().default(false),
tpagoLink: text('tpago_link'),
tpagoLink2: text('tpago_link_2'),
tpagoLink3: text('tpago_link_3'),
tpagoLink4: text('tpago_link_4'),
tpagoLink5: text('tpago_link_5'),
tpagoInstructions: text('tpago_instructions'),
tpagoInstructionsEs: text('tpago_instructions_es'),
// Bank Transfer configuration
@@ -166,6 +172,10 @@ export const sqliteEventPaymentOverrides = sqliteTable('event_payment_overrides'
// Override flags (null means use global)
tpagoEnabled: integer('tpago_enabled', { mode: 'boolean' }),
tpagoLink: text('tpago_link'),
tpagoLink2: text('tpago_link_2'),
tpagoLink3: text('tpago_link_3'),
tpagoLink4: text('tpago_link_4'),
tpagoLink5: text('tpago_link_5'),
tpagoInstructions: text('tpago_instructions'),
tpagoInstructionsEs: text('tpago_instructions_es'),
bankTransferEnabled: integer('bank_transfer_enabled', { mode: 'boolean' }),
@@ -263,6 +273,18 @@ export const sqliteEmailSettings = sqliteTable('email_settings', {
updatedAt: text('updated_at').notNull(),
});
// Durable email queue. Jobs survive process restarts; a startup recovery step
// resets any 'processing' rows back to 'pending'.
export const sqliteEmailQueue = sqliteTable('email_queue', {
id: text('id').primaryKey(),
params: text('params').notNull(), // JSON-encoded TemplateEmailJobParams
status: text('status', { enum: ['pending', 'processing', 'sent', 'failed'] }).notNull().default('pending'),
attempts: integer('attempts').notNull().default(0),
lastError: text('last_error'),
createdAt: text('created_at').notNull(),
processedAt: text('processed_at'),
});
// Legal Pages table for admin-editable legal content
export const sqliteLegalPages = sqliteTable('legal_pages', {
id: text('id').primaryKey(),
@@ -351,6 +373,8 @@ export const pgUsers = pgTable('users', {
googleId: varchar('google_id', { length: 255 }),
rucNumber: varchar('ruc_number', { length: 15 }),
accountStatus: varchar('account_status', { length: 20 }).notNull().default('active'),
// Incremented to invalidate previously issued JWTs (logout-everywhere, password change/reset)
tokenVersion: pgInteger('token_version').notNull().default(0),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
@@ -467,6 +491,10 @@ export const pgPaymentOptions = pgTable('payment_options', {
id: uuid('id').primaryKey(),
tpagoEnabled: pgInteger('tpago_enabled').notNull().default(0),
tpagoLink: varchar('tpago_link', { length: 500 }),
tpagoLink2: varchar('tpago_link_2', { length: 500 }),
tpagoLink3: varchar('tpago_link_3', { length: 500 }),
tpagoLink4: varchar('tpago_link_4', { length: 500 }),
tpagoLink5: varchar('tpago_link_5', { length: 500 }),
tpagoInstructions: pgText('tpago_instructions'),
tpagoInstructionsEs: pgText('tpago_instructions_es'),
bankTransferEnabled: pgInteger('bank_transfer_enabled').notNull().default(0),
@@ -492,6 +520,10 @@ export const pgEventPaymentOverrides = pgTable('event_payment_overrides', {
eventId: uuid('event_id').notNull().references(() => pgEvents.id),
tpagoEnabled: pgInteger('tpago_enabled'),
tpagoLink: varchar('tpago_link', { length: 500 }),
tpagoLink2: varchar('tpago_link_2', { length: 500 }),
tpagoLink3: varchar('tpago_link_3', { length: 500 }),
tpagoLink4: varchar('tpago_link_4', { length: 500 }),
tpagoLink5: varchar('tpago_link_5', { length: 500 }),
tpagoInstructions: pgText('tpago_instructions'),
tpagoInstructionsEs: pgText('tpago_instructions_es'),
bankTransferEnabled: pgInteger('bank_transfer_enabled'),
@@ -588,6 +620,18 @@ export const pgEmailSettings = pgTable('email_settings', {
updatedAt: timestamp('updated_at').notNull(),
});
// Durable email queue. Jobs survive process restarts; a startup recovery step
// resets any 'processing' rows back to 'pending'.
export const pgEmailQueue = pgTable('email_queue', {
id: uuid('id').primaryKey(),
params: pgText('params').notNull(), // JSON-encoded TemplateEmailJobParams
status: varchar('status', { length: 20 }).notNull().default('pending'),
attempts: pgInteger('attempts').notNull().default(0),
lastError: pgText('last_error'),
createdAt: timestamp('created_at').notNull(),
processedAt: timestamp('processed_at'),
});
// Legal Pages table for admin-editable legal content
export const pgLegalPages = pgTable('legal_pages', {
id: uuid('id').primaryKey(),
@@ -675,6 +719,7 @@ export const auditLogs = dbType === 'postgres' ? pgAuditLogs : sqliteAuditLogs;
export const emailTemplates = dbType === 'postgres' ? pgEmailTemplates : sqliteEmailTemplates;
export const emailLogs = dbType === 'postgres' ? pgEmailLogs : sqliteEmailLogs;
export const emailSettings = dbType === 'postgres' ? pgEmailSettings : sqliteEmailSettings;
export const emailQueue = dbType === 'postgres' ? pgEmailQueue : sqliteEmailQueue;
export const paymentOptions = dbType === 'postgres' ? pgPaymentOptions : sqlitePaymentOptions;
export const eventPaymentOverrides = dbType === 'postgres' ? pgEventPaymentOverrides : sqliteEventPaymentOverrides;
export const magicLinkTokens = dbType === 'postgres' ? pgMagicLinkTokens : sqliteMagicLinkTokens;
+82 -15
View File
@@ -25,6 +25,9 @@ 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 { getLock } from './lib/stores/lock.js';
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js';
const app = new Hono();
@@ -56,6 +59,19 @@ app.use(
})
);
// Baseline security headers on every response.
const isProduction = process.env.NODE_ENV === 'production';
app.use('*', async (c, next) => {
await next();
c.header('X-Content-Type-Options', 'nosniff');
c.header('X-Frame-Options', 'DENY');
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
c.header('X-XSS-Protection', '0');
if (isProduction) {
c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
});
// OpenAPI specification
const openApiSpec = {
openapi: '3.0.0',
@@ -657,11 +673,21 @@ const openApiSpec = {
},
},
},
'/api/events/next': {
get: {
tags: ['Events'],
summary: 'Get next upcoming event (chronological)',
description: 'Get the single earliest upcoming published event, ignoring featured promotion.',
responses: {
200: { description: 'Next event or null' },
},
},
},
'/api/events/next/upcoming': {
get: {
tags: ['Events'],
summary: 'Get next upcoming event',
description: 'Get the single next upcoming published event.',
description: 'Get the featured event if valid, otherwise the single next upcoming published event.',
responses: {
200: { description: 'Next event or null' },
},
@@ -1827,20 +1853,46 @@ const openApiSpec = {
},
};
// OpenAPI JSON endpoint
app.get('/openapi.json', (c) => {
return c.json(openApiSpec);
// API documentation is disabled in production to avoid exposing the full API
// surface (and schema) to anonymous users. Enable locally / in non-prod only.
if (!isProduction) {
// OpenAPI JSON endpoint
app.get('/openapi.json', (c) => {
return c.json(openApiSpec);
});
// Swagger UI
app.get('/api-docs', swaggerUI({ url: '/openapi.json' }));
} else {
app.get('/openapi.json', (c) => c.json({ error: 'Not Found' }, 404));
app.get('/api-docs', (c) => c.json({ error: 'Not Found' }, 404));
}
// Static file serving for uploads.
// Uploads are validated as images at write time, but as defense-in-depth we force
// any non-image path to download as an opaque attachment so a stray/legacy
// .html/.svg can never be rendered (and therefore never execute script) in-origin.
app.use('/uploads/*', async (c, next) => {
await next();
const path = c.req.path.toLowerCase();
const isInlineImage = /\.(jpg|jpeg|png|gif|webp|avif)$/.test(path);
if (!isInlineImage) {
c.header('Content-Disposition', 'attachment');
c.header('Content-Type', 'application/octet-stream');
}
});
// Swagger UI
app.get('/api-docs', swaggerUI({ url: '/openapi.json' }));
// Static file serving for uploads
app.use('/uploads/*', serveStatic({ root: './' }));
// Health check
// Health check.
// Always returns 200 so a transient Redis blip does not cause the load balancer
// to pull a node; Redis/subsystem status is reported in the body for monitoring.
app.get('/health', (c) => {
return c.json({ status: 'ok', timestamp: new Date().toISOString() });
return c.json({
status: 'ok',
timestamp: new Date().toISOString(),
redis: describeRedis(),
backends: describeBackends(),
});
});
// API Routes
@@ -1877,15 +1929,30 @@ const port = parseInt(process.env.PORT || '3001');
// Initialize email queue with the email service reference
initEmailQueue(emailService);
// Initialize email templates on startup
emailService.seedDefaultTemplates().catch(err => {
console.error('[Email] Failed to seed templates:', err);
});
// Periodically expire abandoned pending bookings so they stop holding seats.
startBookingCleanup();
// 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.
getLock()
.withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates())
.then((result) => {
if (result === null) {
console.log('[Email] Template seeding skipped (another instance holds the lock)');
}
})
.catch(err => {
console.error('[Email] Failed to seed templates:', err);
});
console.log(`🚀 Spanglish API server starting on port ${port}`);
console.log(`📚 API docs available at http://localhost:${port}/api-docs`);
console.log(`📋 OpenAPI spec at http://localhost:${port}/openapi.json`);
// Log which backend (memory/redis, local/s3) each subsystem selected.
logSelectedBackends();
serve({
fetch: app.fetch,
port,
+126 -14
View File
@@ -4,10 +4,21 @@ import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { Context } from 'hono';
import { db, dbGet, dbAll, users, magicLinkTokens, userSessions } from '../db/index.js';
import { eq, and, gt } from 'drizzle-orm';
import { eq, and, gt, sql, isNull } from 'drizzle-orm';
import { generateId, getNow, toDbDate } from './utils.js';
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || 'your-super-secret-key-change-in-production');
const DEFAULT_DEV_JWT_SECRET = 'your-super-secret-key-change-in-production';
const rawJwtSecret = process.env.JWT_SECRET;
// Never allow the insecure default in production: forgeable tokens = full account takeover.
if (process.env.NODE_ENV === 'production' && (!rawJwtSecret || rawJwtSecret === DEFAULT_DEV_JWT_SECRET)) {
throw new Error('JWT_SECRET must be set to a strong, unique value in production. Refusing to start with the default secret.');
}
if (!rawJwtSecret) {
console.warn('[auth] JWT_SECRET is not set; using an insecure development default. Set JWT_SECRET in production.');
}
const JWT_SECRET = new TextEncoder().encode(rawJwtSecret || DEFAULT_DEV_JWT_SECRET);
const JWT_ISSUER = 'spanglish';
const JWT_AUDIENCE = 'spanglish-app';
@@ -15,6 +26,7 @@ export interface JWTPayload {
sub: string;
email: string;
role: string;
tokenVersion?: number;
iat: number;
exp: number;
}
@@ -84,23 +96,36 @@ export async function verifyMagicLinkToken(
)
);
// Use a single generic error for all invalid states to avoid leaking token state
const genericError = 'Invalid or expired token';
if (!tokenRecord) {
return { valid: false, error: 'Invalid token' };
return { valid: false, error: genericError };
}
if (tokenRecord.usedAt) {
return { valid: false, error: 'Token already used' };
return { valid: false, error: genericError };
}
if (new Date(tokenRecord.expiresAt) < new Date()) {
return { valid: false, error: 'Token expired' };
return { valid: false, error: genericError };
}
// Mark token as used
await (db as any)
// Atomically consume the token: only the request that flips used_at from NULL wins.
// This prevents a double-spend race where two concurrent requests both pass the
// read-time "not used" check above.
const result: any = await (db as any)
.update(magicLinkTokens)
.set({ usedAt: now })
.where(eq((magicLinkTokens as any).id, tokenRecord.id));
.where(and(
eq((magicLinkTokens as any).id, tokenRecord.id),
isNull((magicLinkTokens as any).usedAt)
));
const affected = result?.changes ?? result?.rowCount ?? 0;
if (affected === 0) {
return { valid: false, error: genericError };
}
return { valid: true, userId: tokenRecord.userId };
}
@@ -167,26 +192,67 @@ export async function invalidateAllUserSessions(userId: string): Promise<void> {
.where(eq((userSessions as any).userId, userId));
}
// Password validation (min 10 characters per spec)
// Small blocklist of common/weak passwords (and obvious app-specific ones).
// Compared case-insensitively after stripping non-alphanumerics so that e.g.
// "P@ssw0rd!" still matches "password".
const COMMON_PASSWORDS = new Set([
'password', 'passw0rd', '123456', '1234567', '12345678', '123456789', '1234567890',
'qwerty', 'qwertyuiop', 'letmein', 'welcome', 'admin', 'administrator', 'iloveyou',
'monkey', 'dragon', 'sunshine', 'princess', 'football', 'baseball', 'abc123',
'spanglish', 'changeme', 'secret', 'master', 'login', 'access',
]);
// Password policy: 10-128 chars, requires a mix of character types, and rejects
// common/weak passwords. Centralized so register/reset/change all share it.
export function validatePassword(password: string): { valid: boolean; error?: string } {
if (password.length < 10) {
return { valid: false, error: 'Password must be at least 10 characters long' };
}
if (password.length > 128) {
return { valid: false, error: 'Password must be at most 128 characters long' };
}
const hasLower = /[a-z]/.test(password);
const hasUpper = /[A-Z]/.test(password);
const hasDigit = /\d/.test(password);
const hasSymbol = /[^A-Za-z0-9]/.test(password);
// Require lowercase, uppercase, and at least one digit or symbol.
if (!hasLower || !hasUpper || !(hasDigit || hasSymbol)) {
return {
valid: false,
error: 'Password must include uppercase and lowercase letters and at least one number or symbol',
};
}
const normalized = password.toLowerCase().replace(/[^a-z0-9]/g, '');
if (COMMON_PASSWORDS.has(normalized)) {
return { valid: false, error: 'Password is too common. Please choose a less guessable password.' };
}
return { valid: true };
}
export async function createToken(userId: string, email: string, role: string): Promise<string> {
const token = await new jose.SignJWT({ sub: userId, email, role })
export async function createToken(userId: string, email: string, role: string, tokenVersion: number = 0): Promise<string> {
const token = await new jose.SignJWT({ sub: userId, email, role, tokenVersion })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setExpirationTime('7d')
.setExpirationTime('1d')
.sign(JWT_SECRET);
return token;
}
// Invalidate all previously issued JWTs for a user (logout-everywhere, password change/reset).
export async function bumpTokenVersion(userId: string): Promise<void> {
await (db as any)
.update(users)
.set({ tokenVersion: sql`${(users as any).tokenVersion} + 1` })
.where(eq((users as any).id, userId));
}
export async function createRefreshToken(userId: string): Promise<string> {
const token = await new jose.SignJWT({ sub: userId, type: 'refresh' })
.setProtectedHeader({ alg: 'HS256' })
@@ -223,10 +289,44 @@ export async function getAuthUser(c: Context): Promise<any | null> {
return null;
}
// Never load the password hash into request context — it is only needed for
// explicit password-verification routes that query it separately.
const user = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, payload.sub))
(db as any)
.select({
id: (users as any).id,
email: (users as any).email,
name: (users as any).name,
phone: (users as any).phone,
role: (users as any).role,
languagePreference: (users as any).languagePreference,
isClaimed: (users as any).isClaimed,
googleId: (users as any).googleId,
rucNumber: (users as any).rucNumber,
accountStatus: (users as any).accountStatus,
tokenVersion: (users as any).tokenVersion,
createdAt: (users as any).createdAt,
updatedAt: (users as any).updatedAt,
})
.from(users)
.where(eq((users as any).id, payload.sub))
);
return user || null;
if (!user) {
return null;
}
// Reject tokens issued before a logout-everywhere / password change
if ((payload.tokenVersion ?? 0) !== (user.tokenVersion ?? 0)) {
return null;
}
// Suspended/unclaimed accounts must not retain API access via an old JWT
if (user.accountStatus && user.accountStatus !== 'active') {
return null;
}
return user;
}
export function requireAuth(roles?: string[]) {
@@ -252,3 +352,15 @@ export async function isFirstUser(): Promise<boolean> {
);
return !result || result.length === 0;
}
/** Fetch only the password hash column (never expose via getAuthUser). */
export async function getUserPasswordHash(userId: string): Promise<string | null> {
const row = await dbGet<any>(
(db as any)
.select({ password: (users as any).password })
.from(users)
.where(eq((users as any).id, userId))
);
const hash = row?.password;
return hash && String(hash).length > 0 ? String(hash) : null;
}
+36
View File
@@ -0,0 +1,36 @@
// Reports which backend each scalable subsystem is using, for the health
// endpoint and startup logging.
import { isRedisEnabled, isRedisHealthy } 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 { getStorage } from './storage.js';
export function describeBackends() {
return {
cache: getCache().backend,
rateLimiter: getRateLimiter().backend,
pubsub: getPubSub().backend,
lock: getLock().backend,
storage: getStorage().backend,
};
}
export function describeRedis() {
return { enabled: isRedisEnabled(), healthy: isRedisHealthy() };
}
/** Log one line per subsystem at startup so the active backend is obvious. */
export function logSelectedBackends(): void {
const b = describeBackends();
const r = describeRedis();
console.log('[startup] Subsystem backends:');
console.log(` redis: ${r.enabled ? 'enabled' : 'disabled (in-memory fallback)'}`);
console.log(` cache: ${b.cache}`);
console.log(` rate limiter: ${b.rateLimiter}`);
console.log(` pub/sub: ${b.pubsub}`);
console.log(` lock: ${b.lock}`);
console.log(` storage: ${b.storage}`);
}
+104
View File
@@ -0,0 +1,104 @@
// Expire stale pending bookings.
//
// When a booking is started, its tickets are created with status 'pending' and
// a 'pending' payment. Pending tickets count toward an event's capacity, so an
// abandoned checkout would otherwise hold those seats forever. This job cancels
// pending tickets whose payment is still 'pending' (i.e. never paid and not
// awaiting admin approval) after a configurable TTL, freeing the seats.
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';
function getTtlMs(): number {
const minutes = parseInt(process.env.PENDING_BOOKING_TTL_MINUTES || '30', 10);
return (Number.isFinite(minutes) && minutes > 0 ? minutes : 30) * 60 * 1000;
}
/**
* Cancel stale pending bookings. Returns the number of tickets cancelled.
*
* A booking is considered stale when its payment is still 'pending' (not
* 'pending_approval', which means an admin is reviewing a manual transfer) and
* older than PENDING_BOOKING_TTL_MINUTES.
*/
export async function cleanupStalePendingBookings(): Promise<number> {
const cutoff = toDbDate(new Date(Date.now() - getTtlMs()));
const stale = await dbAll<{ ticketId: string | null; paymentId: string }>(
(db as any)
.select({
ticketId: (payments as any).ticketId,
paymentId: (payments as any).id,
})
.from(payments)
.where(and(
eq((payments as any).status, 'pending'),
lt((payments as any).createdAt, cutoff)
))
);
if (stale.length === 0) return 0;
const ticketIds = stale.map((s) => s.ticketId).filter((id): id is string => !!id);
const paymentIds = stale.map((s) => s.paymentId);
const now = getNow();
let cancelledTickets = 0;
if (ticketIds.length > 0) {
const result: any = await (db as any)
.update(tickets)
.set({ status: 'cancelled' })
.where(and(
inArray((tickets as any).id, ticketIds),
eq((tickets as any).status, 'pending')
));
cancelledTickets = result?.changes ?? result?.rowCount ?? ticketIds.length;
}
await (db as any)
.update(payments)
.set({ status: 'failed', updatedAt: now })
.where(inArray((payments as any).id, paymentIds));
console.log(
`[BookingCleanup] Expired ${stale.length} stale pending payment(s); ` +
`cancelled ${cancelledTickets} ticket(s).`
);
return cancelledTickets;
}
let cleanupTimer: ReturnType<typeof setInterval> | null = null;
/**
* Start a periodic cleanup of stale pending bookings. Each run is guarded by a
* distributed lock so that, across multiple replicas, only one instance does
* the work per interval.
*/
export function startBookingCleanup(): void {
const intervalMs = parseInt(process.env.PENDING_BOOKING_CLEANUP_INTERVAL_MS || '300000', 10); // 5 min
const run = () => {
getLock()
.withLock('cleanup-pending-bookings', Math.min(intervalMs, 60_000), () =>
cleanupStalePendingBookings()
)
.catch((err) =>
console.error('[BookingCleanup] Run failed:', err?.message || err)
);
};
// Run shortly after startup, then on the interval.
setTimeout(run, 30_000).unref?.();
cleanupTimer = setInterval(run, intervalMs);
cleanupTimer.unref?.();
console.log(`[BookingCleanup] Scheduled every ${Math.round(intervalMs / 1000)}s`);
}
export function stopBookingCleanup(): void {
if (cleanupTimer) {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
}
+56 -1394
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
// High-level booking confirmation email sender.
import { db, dbGet, dbAll, events, tickets } from '../../db/index.js';
import { eq } from 'drizzle-orm';
import { sendTemplateEmail } from './templateService.js';
import { formatDate, formatTime, formatCurrency, getSiteTimezone } from './formatting.js';
/**
* Send booking confirmation email
* Supports multi-ticket bookings - includes all tickets in the booking
*/
export async function sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> {
// Get ticket with event info
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, ticketId))
);
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
if (!event) {
return { success: false, error: 'Event not found' };
}
// Get all tickets in this booking (if multi-ticket)
let allTickets: any[] = [ticket];
if (ticket.bookingId) {
allTickets = await dbAll(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, ticket.bookingId))
);
}
const ticketCount = allTickets.length;
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
// Generate ticket PDF URL (primary ticket, or use combined endpoint for multi)
const apiUrl = process.env.API_URL || 'http://localhost:3001';
const ticketPdfUrl = ticketCount > 1 && ticket.bookingId
? `${apiUrl}/api/tickets/booking/${ticket.bookingId}/pdf`
: `${apiUrl}/api/tickets/${ticket.id}/pdf`;
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Build attendee list for multi-ticket emails
const attendeeNames = allTickets.map(t =>
`${t.attendeeFirstName} ${t.attendeeLastName || ''}`.trim()
).join(', ');
// Calculate total price for multi-ticket bookings
const totalPrice = event.price * ticketCount;
// Get site timezone for proper date/time formatting
const timezone = await getSiteTimezone();
return sendTemplateEmail({
templateSlug: 'booking-confirmation',
to: ticket.attendeeEmail,
toName: attendeeFullName,
locale,
eventId: event.id,
variables: {
attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id,
bookingId: ticket.bookingId || ticket.id,
qrCode: ticket.qrCode || '',
ticketPdfUrl,
eventTitle,
eventDate: formatDate(event.startDatetime, locale, timezone),
eventTime: formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
eventPrice: formatCurrency(event.price, event.currency),
// Multi-ticket specific variables
ticketCount: ticketCount.toString(),
totalPrice: formatCurrency(totalPrice, event.currency),
attendeeNames,
isMultiTicket: ticketCount > 1 ? 'true' : 'false',
},
});
}
+101
View File
@@ -0,0 +1,101 @@
// Event-wide bulk email sending via the background queue.
import { db, dbGet, dbAll, events, tickets } from '../../db/index.js';
import { eq, and } from 'drizzle-orm';
import { enqueueBulkEmails, type TemplateEmailJobParams } from '../emailQueue.js';
import { getTemplate } from './templateService.js';
import { formatDate, formatTime, getSiteTimezone } from './formatting.js';
/**
* Queue emails for event attendees (non-blocking).
* Adds all matching recipients to the background email queue and returns immediately.
* Rate limiting and actual sending is handled by the email queue.
*/
export async function queueEventEmails(params: {
eventId: string;
templateSlug: string;
customVariables?: Record<string, any>;
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
sentBy: string;
}): Promise<{ success: boolean; queuedCount: number; error?: string }> {
const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params;
// Validate event exists
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, eventId))
);
if (!event) {
return { success: false, queuedCount: 0, error: 'Event not found' };
}
// Validate template exists
const template = await getTemplate(templateSlug);
if (!template) {
return { success: false, queuedCount: 0, error: `Template "${templateSlug}" not found` };
}
// Get tickets based on filter
let ticketQuery = (db as any)
.select()
.from(tickets)
.where(eq((tickets as any).eventId, eventId));
if (recipientFilter !== 'all') {
ticketQuery = ticketQuery.where(
and(
eq((tickets as any).eventId, eventId),
eq((tickets as any).status, recipientFilter)
)
);
}
const eventTickets = await dbAll<any>(ticketQuery);
if (eventTickets.length === 0) {
return { success: true, queuedCount: 0, error: 'No recipients found' };
}
// Get site timezone for proper date/time formatting
const timezone = await getSiteTimezone();
// Build individual email jobs for the queue
const jobs: TemplateEmailJobParams[] = eventTickets.map((ticket: any) => {
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const fullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
return {
templateSlug,
to: ticket.attendeeEmail,
toName: fullName,
locale,
eventId: event.id,
sentBy,
variables: {
attendeeName: fullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id,
eventTitle,
eventDate: formatDate(event.startDatetime, locale, timezone),
eventTime: formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
...customVariables,
},
};
});
// Enqueue all emails for background processing
enqueueBulkEmails(jobs);
console.log(`[Email] Queued ${jobs.length} emails for event "${event.title}" (filter: ${recipientFilter})`);
return {
success: true,
queuedCount: jobs.length,
};
}
+69
View File
@@ -0,0 +1,69 @@
// Shared formatting helpers and common template variables for emails.
import { db, dbGet, siteSettings } from '../../db/index.js';
import { getCache } from '../stores/cache.js';
/**
* Get common variables for all emails
*/
export function getCommonVariables(): Record<string, string> {
return {
siteName: 'Spanglish',
siteUrl: process.env.FRONTEND_URL || 'https://spanglish.com',
currentYear: new Date().getFullYear().toString(),
supportEmail: process.env.EMAIL_FROM || 'hello@spanglish.com',
};
}
/**
* Get the site timezone from settings (cached for performance).
* Cached for a short TTL via the cache abstraction (in-memory or Redis).
*/
export async function getSiteTimezone(): Promise<string> {
const cached = await getCache().get<string>('site:timezone');
if (cached) return cached;
const settings = await dbGet<any>(
(db as any).select().from(siteSettings).limit(1)
);
const timezone = settings?.timezone || 'America/Asuncion';
await getCache().set('site:timezone', timezone, 60);
return timezone;
}
/**
* Format date for emails using site timezone
*/
export function formatDate(dateStr: string, locale: string = 'en', timezone: string = 'America/Asuncion'): string {
const date = new Date(dateStr);
return date.toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: timezone,
});
}
/**
* Format time for emails using site timezone
*/
export function formatTime(dateStr: string, locale: string = 'en', timezone: string = 'America/Asuncion'): string {
const date = new Date(dateStr);
return date.toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
hour: '2-digit',
minute: '2-digit',
timeZone: timezone,
});
}
/**
* Format currency for emails. Kept distinct from lib/utils.ts formatCurrency
* because the email output format ("12.345 PYG" / "$10.00 USD") must not change.
*/
export function formatCurrency(amount: number, currency: string = 'PYG'): string {
if (currency === 'PYG') {
return `${amount.toLocaleString('es-PY')} PYG`;
}
return `$${amount.toFixed(2)} ${currency}`;
}
+474
View File
@@ -0,0 +1,474 @@
// High-level payment-related email senders and payment config resolution.
import { db, dbGet, dbAll, events, tickets, payments, paymentOptions, eventPaymentOverrides } from '../../db/index.js';
import { eq } from 'drizzle-orm';
import { sendTemplateEmail } from './templateService.js';
import { formatDate, formatTime, formatCurrency, getSiteTimezone } from './formatting.js';
/**
* Send payment receipt email
*/
export async function sendPaymentReceipt(paymentId: string): Promise<{ success: boolean; error?: string }> {
// Get payment with ticket and event info
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).id, paymentId))
);
if (!payment) {
return { success: false, error: 'Payment not found' };
}
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
if (!event) {
return { success: false, error: 'Event not found' };
}
// Calculate total amount for multi-ticket bookings
let totalAmount = payment.amount;
let ticketCount = 1;
if (ticket.bookingId) {
// Get all payments for this booking
const bookingTickets = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, ticket.bookingId))
);
ticketCount = bookingTickets.length;
// Sum up all payment amounts for the booking
const bookingPayments = await Promise.all(
bookingTickets.map((t: any) =>
dbGet<any>((db as any).select().from(payments).where(eq((payments as any).ticketId, t.id)))
)
);
totalAmount = bookingPayments
.filter((p: any) => p)
.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
}
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const paymentMethodNames: Record<string, Record<string, string>> = {
en: { bancard: 'Card', lightning: 'Lightning (Bitcoin)', cash: 'Cash', bank_transfer: 'Bank Transfer', tpago: 'TPago' },
es: { bancard: 'Tarjeta', lightning: 'Lightning (Bitcoin)', cash: 'Efectivo', bank_transfer: 'Transferencia Bancaria', tpago: 'TPago' },
};
const receiptFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Format amount with ticket count info for multi-ticket bookings
const amountDisplay = ticketCount > 1
? `${formatCurrency(totalAmount, payment.currency)} (${ticketCount} tickets)`
: formatCurrency(totalAmount, payment.currency);
// Get site timezone for proper date/time formatting
const timezone = await getSiteTimezone();
return sendTemplateEmail({
templateSlug: 'payment-receipt',
to: ticket.attendeeEmail,
toName: receiptFullName,
locale,
eventId: event.id,
variables: {
attendeeName: receiptFullName,
ticketId: ticket.bookingId || ticket.id,
eventTitle,
eventDate: formatDate(event.startDatetime, locale, timezone),
paymentAmount: amountDisplay,
paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider,
paymentReference: payment.reference || payment.id,
paymentDate: formatDate(payment.paidAt || payment.createdAt, locale, timezone),
},
});
}
/**
* Get merged payment configuration for an event (global + overrides)
*/
export async function getPaymentConfig(eventId: string): Promise<Record<string, any>> {
// Get global options
const globalOptions = await dbGet<any>(
(db as any)
.select()
.from(paymentOptions)
);
// Get event overrides
const overrides = await dbGet<any>(
(db as any)
.select()
.from(eventPaymentOverrides)
.where(eq((eventPaymentOverrides as any).eventId, eventId))
);
// Defaults
const defaults = {
tpagoEnabled: false,
tpagoLink: null,
tpagoLink2: null,
tpagoLink3: null,
tpagoLink4: null,
tpagoLink5: null,
tpagoInstructions: null,
tpagoInstructionsEs: null,
bankTransferEnabled: false,
bankName: null,
bankAccountHolder: null,
bankAccountNumber: null,
bankAlias: null,
bankPhone: null,
bankNotes: null,
bankNotesEs: null,
};
const global = globalOptions || defaults;
// Merge: override values take precedence if they're not null/undefined
return {
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
tpagoLink2: overrides?.tpagoLink2 ?? global.tpagoLink2,
tpagoLink3: overrides?.tpagoLink3 ?? global.tpagoLink3,
tpagoLink4: overrides?.tpagoLink4 ?? global.tpagoLink4,
tpagoLink5: overrides?.tpagoLink5 ?? global.tpagoLink5,
tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions,
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
bankName: overrides?.bankName ?? global.bankName,
bankAccountHolder: overrides?.bankAccountHolder ?? global.bankAccountHolder,
bankAccountNumber: overrides?.bankAccountNumber ?? global.bankAccountNumber,
bankAlias: overrides?.bankAlias ?? global.bankAlias,
bankPhone: overrides?.bankPhone ?? global.bankPhone,
bankNotes: overrides?.bankNotes ?? global.bankNotes,
bankNotesEs: overrides?.bankNotesEs ?? global.bankNotesEs,
};
}
/**
* Send payment instructions email (for TPago or Bank Transfer)
* This email is sent immediately after user clicks "Continue to Payment"
*/
export async function sendPaymentInstructions(ticketId: string): Promise<{ success: boolean; error?: string }> {
// Get ticket
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, ticketId))
);
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
// Get event
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
if (!event) {
return { success: false, error: 'Event not found' };
}
// Get payment
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticketId))
);
if (!payment) {
return { success: false, error: 'Payment not found' };
}
// Only send for manual payment methods
if (!['bank_transfer', 'tpago'].includes(payment.provider)) {
return { success: false, error: 'Payment instructions email only for bank_transfer or tpago' };
}
// Get merged payment config for this event
const paymentConfig = await getPaymentConfig(event.id);
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Calculate total price for multi-ticket bookings
let totalPrice = event.price;
let ticketCount = 1;
if (ticket.bookingId) {
// Count all tickets in this booking
const bookingTickets = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, ticket.bookingId))
);
ticketCount = bookingTickets.length;
totalPrice = event.price * ticketCount;
}
// Generate a payment reference using booking ID or ticket ID
const paymentReference = `SPG-${(ticket.bookingId || ticket.id).substring(0, 8).toUpperCase()}`;
// Generate the booking URL for returning to payment page
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
const bookingUrl = `${frontendUrl}/booking/${ticket.id}?step=payment`;
// Determine which template to use
const templateSlug = payment.provider === 'tpago'
? 'payment-instructions-tpago'
: 'payment-instructions-bank-transfer';
// Format amount with ticket count info for multi-ticket bookings
const amountDisplay = ticketCount > 1
? `${formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
: formatCurrency(totalPrice, event.currency);
// Get site timezone for proper date/time formatting
const timezone = await getSiteTimezone();
// Build variables based on payment method
const variables: Record<string, any> = {
attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.bookingId || ticket.id,
eventTitle,
eventDate: formatDate(event.startDatetime, locale, timezone),
eventTime: formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
paymentAmount: amountDisplay,
paymentReference,
bookingUrl,
};
// Add payment-method specific variables
if (payment.provider === 'tpago') {
// Select the TPago link matching the number of tickets (1-5), falling back to the base link
const tpagoLinkKey = ticketCount <= 1 ? 'tpagoLink' : `tpagoLink${Math.min(ticketCount, 5)}`;
variables.tpagoLink = paymentConfig[tpagoLinkKey] || paymentConfig.tpagoLink || '';
} else {
// Bank transfer
variables.bankName = paymentConfig.bankName || '';
variables.bankAccountHolder = paymentConfig.bankAccountHolder || '';
variables.bankAccountNumber = paymentConfig.bankAccountNumber || '';
variables.bankAlias = paymentConfig.bankAlias || '';
variables.bankPhone = paymentConfig.bankPhone || '';
}
console.log(`[Email] Sending payment instructions email (${payment.provider}) to ${ticket.attendeeEmail}`);
return sendTemplateEmail({
templateSlug,
to: ticket.attendeeEmail,
toName: attendeeFullName,
locale,
eventId: event.id,
variables,
});
}
/**
* Send payment rejection email
* This email is sent when admin rejects a TPago or Bank Transfer payment
*/
export async function sendPaymentRejectionEmail(paymentId: string): Promise<{ success: boolean; error?: string }> {
// Get payment
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).id, paymentId))
);
if (!payment) {
return { success: false, error: 'Payment not found' };
}
// Get ticket
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
// Get event
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
if (!event) {
return { success: false, error: 'Event not found' };
}
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Generate a new booking URL for the event
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
const newBookingUrl = `${frontendUrl}/book/${event.id}`;
// Get site timezone for proper date/time formatting
const timezone = await getSiteTimezone();
console.log(`[Email] Sending payment rejection email to ${ticket.attendeeEmail}`);
return sendTemplateEmail({
templateSlug: 'payment-rejected',
to: ticket.attendeeEmail,
toName: attendeeFullName,
locale,
eventId: event.id,
variables: {
attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.id,
eventTitle,
eventDate: formatDate(event.startDatetime, locale, timezone),
eventTime: formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
newBookingUrl,
},
});
}
/**
* Send payment reminder email
* This email is sent when admin wants to remind attendee about pending payment
*/
export async function sendPaymentReminder(paymentId: string): Promise<{ success: boolean; error?: string }> {
// Get payment
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).id, paymentId))
);
if (!payment) {
return { success: false, error: 'Payment not found' };
}
// Only send for pending/pending_approval payments
if (!['pending', 'pending_approval'].includes(payment.status)) {
return { success: false, error: 'Payment reminder can only be sent for pending payments' };
}
// Get ticket
const ticket = await dbGet<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).id, payment.ticketId))
);
if (!ticket) {
return { success: false, error: 'Ticket not found' };
}
// Get event
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
if (!event) {
return { success: false, error: 'Event not found' };
}
const locale = ticket.preferredLanguage || 'en';
const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title;
const attendeeFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim();
// Calculate total price for multi-ticket bookings
let totalPrice = event.price;
let ticketCount = 1;
if (ticket.bookingId) {
const bookingTickets = await dbAll<any>(
(db as any)
.select()
.from(tickets)
.where(eq((tickets as any).bookingId, ticket.bookingId))
);
ticketCount = bookingTickets.length;
totalPrice = event.price * ticketCount;
}
// Generate the booking URL for returning to payment page
const frontendUrl = process.env.FRONTEND_URL || 'https://spanglish.com';
const bookingUrl = `${frontendUrl}/booking/${ticket.id}?step=payment`;
// Format amount with ticket count info for multi-ticket bookings
const amountDisplay = ticketCount > 1
? `${formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)`
: formatCurrency(totalPrice, event.currency);
// Get site timezone for proper date/time formatting
const timezone = await getSiteTimezone();
console.log(`[Email] Sending payment reminder email to ${ticket.attendeeEmail}`);
return sendTemplateEmail({
templateSlug: 'payment-reminder',
to: ticket.attendeeEmail,
toName: attendeeFullName,
locale,
eventId: event.id,
variables: {
attendeeName: attendeeFullName,
attendeeEmail: ticket.attendeeEmail,
ticketId: ticket.bookingId || ticket.id,
eventTitle,
eventDate: formatDate(event.startDatetime, locale, timezone),
eventTime: formatTime(event.startDatetime, locale, timezone),
eventLocation: event.location,
eventLocationUrl: event.locationUrl || '',
paymentAmount: amountDisplay,
bookingUrl,
},
});
}
+307
View File
@@ -0,0 +1,307 @@
// Template DB access, seeding, and the core template/custom send + logging logic.
import { db, dbGet, emailTemplates, emailLogs } from '../../db/index.js';
import { eq } from 'drizzle-orm';
import { getNow, generateId } from '../utils.js';
import { replaceTemplateVariables, wrapInBaseTemplate, defaultTemplates } from '../emailTemplates.js';
import { sendEmail } from './transport.js';
import { getCommonVariables } from './formatting.js';
/**
* Get a template by slug
*/
export async function getTemplate(slug: string): Promise<any | null> {
const template = await dbGet(
(db as any)
.select()
.from(emailTemplates)
.where(eq((emailTemplates as any).slug, slug))
);
return template || null;
}
/**
* Seed default templates if they don't exist, and update system templates with latest content
*/
export async function seedDefaultTemplates(): Promise<void> {
console.log('[Email] Checking for default templates...');
for (const template of defaultTemplates) {
const existing = await getTemplate(template.slug);
const now = getNow();
if (!existing) {
console.log(`[Email] Creating template: ${template.name}`);
await (db as any).insert(emailTemplates).values({
id: generateId(),
name: template.name,
slug: template.slug,
subject: template.subject,
subjectEs: template.subjectEs,
bodyHtml: template.bodyHtml,
bodyHtmlEs: template.bodyHtmlEs,
bodyText: template.bodyText,
bodyTextEs: template.bodyTextEs,
description: template.description,
variables: JSON.stringify(template.variables),
isSystem: template.isSystem ? 1 : 0,
isActive: 1,
createdAt: now,
updatedAt: now,
});
} else if (existing.isSystem) {
// Update system templates with latest content from defaults
console.log(`[Email] Updating system template: ${template.name}`);
await (db as any)
.update(emailTemplates)
.set({
subject: template.subject,
subjectEs: template.subjectEs,
bodyHtml: template.bodyHtml,
bodyHtmlEs: template.bodyHtmlEs,
bodyText: template.bodyText,
bodyTextEs: template.bodyTextEs,
description: template.description,
variables: JSON.stringify(template.variables),
updatedAt: now,
})
.where(eq((emailTemplates as any).slug, template.slug));
}
}
console.log('[Email] Default templates check complete');
}
/**
* Send an email using a template
*/
export async function sendTemplateEmail(params: {
templateSlug: string;
to: string;
toName?: string;
variables: Record<string, any>;
locale?: string;
eventId?: string;
sentBy?: string;
}): Promise<{ success: boolean; logId?: string; error?: string }> {
const { templateSlug, to, toName, variables, locale = 'en', eventId, sentBy } = params;
// Get template
const template = await getTemplate(templateSlug);
if (!template) {
return { success: false, error: `Template "${templateSlug}" not found` };
}
// Build variables
const allVariables = {
...getCommonVariables(),
lang: locale,
...variables,
};
// Get localized content
const subject = locale === 'es' && template.subjectEs
? template.subjectEs
: template.subject;
const bodyHtml = locale === 'es' && template.bodyHtmlEs
? template.bodyHtmlEs
: template.bodyHtml;
const bodyText = locale === 'es' && template.bodyTextEs
? template.bodyTextEs
: template.bodyText;
// Replace variables
const finalSubject = replaceTemplateVariables(subject, allVariables);
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables, true);
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
const finalBodyText = bodyText ? replaceTemplateVariables(bodyText, allVariables) : undefined;
// Create log entry
const logId = generateId();
const now = getNow();
await (db as any).insert(emailLogs).values({
id: logId,
templateId: template.id,
eventId: eventId || null,
recipientEmail: to,
recipientName: toName || null,
subject: finalSubject,
bodyHtml: finalBodyHtml,
status: 'pending',
sentBy: sentBy || null,
createdAt: now,
});
// Send email
const result = await sendEmail({
to,
subject: finalSubject,
html: finalBodyHtml,
text: finalBodyText,
});
// Update log with result
if (result.success) {
await (db as any)
.update(emailLogs)
.set({
status: 'sent',
sentAt: getNow(),
})
.where(eq((emailLogs as any).id, logId));
} else {
await (db as any)
.update(emailLogs)
.set({
status: 'failed',
errorMessage: result.error,
})
.where(eq((emailLogs as any).id, logId));
}
return {
success: result.success,
logId,
error: result.error
};
}
/**
* Send a custom email (not from template)
*/
export async function sendCustomEmail(params: {
to: string;
toName?: string;
subject: string;
bodyHtml: string;
bodyText?: string;
replyTo?: string;
eventId?: string;
sentBy?: string | null;
}): Promise<{ success: boolean; logId?: string; error?: string }> {
const { to: rawTo, toName, subject: rawSubject, bodyHtml, bodyText, replyTo: rawReplyTo, eventId, sentBy = null } = params;
// Strip CR/LF from header-bound values to prevent email header injection
// (e.g. an attacker-supplied subject/replyTo smuggling extra headers/recipients).
const stripHeader = (v?: string) => (v ? v.replace(/[\r\n]+/g, ' ').trim() : v);
const to = stripHeader(rawTo) as string;
const subject = stripHeader(rawSubject) as string;
const replyTo = stripHeader(rawReplyTo);
const allVariables = {
...getCommonVariables(),
subject,
};
const finalBodyHtml = wrapInBaseTemplate(bodyHtml, allVariables);
// Create log entry
const logId = generateId();
const now = getNow();
await (db as any).insert(emailLogs).values({
id: logId,
templateId: null,
eventId: eventId || null,
recipientEmail: to,
recipientName: toName || null,
subject,
bodyHtml: finalBodyHtml,
status: 'pending',
sentBy: sentBy || null,
createdAt: now,
});
// Send email
const result = await sendEmail({
to,
subject,
html: finalBodyHtml,
text: bodyText,
replyTo,
});
// Update log
if (result.success) {
await (db as any)
.update(emailLogs)
.set({
status: 'sent',
sentAt: getNow(),
})
.where(eq((emailLogs as any).id, logId));
} else {
await (db as any)
.update(emailLogs)
.set({
status: 'failed',
errorMessage: result.error,
})
.where(eq((emailLogs as any).id, logId));
}
return {
success: result.success,
logId,
error: result.error
};
}
/**
* Resend an email from an existing log entry
*/
export async function resendFromLog(logId: string): Promise<{ success: boolean; error?: string }> {
const log = await dbGet<any>(
(db as any).select().from(emailLogs).where(eq((emailLogs as any).id, logId))
);
if (!log) {
return { success: false, error: 'Email log not found' };
}
if (!log.bodyHtml || !log.subject || !log.recipientEmail) {
return { success: false, error: 'Email log missing required data to resend' };
}
const result = await sendEmail({
to: log.recipientEmail,
subject: log.subject,
html: log.bodyHtml,
text: undefined,
});
const now = getNow();
const currentResendAttempts = (log.resendAttempts ?? 0) + 1;
if (result.success) {
await (db as any)
.update(emailLogs)
.set({
status: 'sent',
sentAt: now,
errorMessage: null,
resendAttempts: currentResendAttempts,
lastResentAt: now,
})
.where(eq((emailLogs as any).id, logId));
} else {
await (db as any)
.update(emailLogs)
.set({
status: 'failed',
errorMessage: result.error,
resendAttempts: currentResendAttempts,
lastResentAt: now,
})
.where(eq((emailLogs as any).id, logId));
}
return {
success: result.success,
error: result.error,
};
}
+308
View File
@@ -0,0 +1,308 @@
// Email transport layer: provider configuration, SMTP setup, and the low-level
// sendEmail router. No template rendering or DB logging happens here.
import nodemailer from 'nodemailer';
import type { Transporter } from 'nodemailer';
// ==================== Types ====================
export interface SendEmailOptions {
to: string | string[];
subject: string;
html: string;
text?: string;
replyTo?: string;
}
export interface SendEmailResult {
success: boolean;
messageId?: string;
error?: string;
}
export type EmailProvider = 'resend' | 'smtp' | 'console';
// ==================== Provider Configuration ====================
function getEmailProvider(): EmailProvider {
const provider = (process.env.EMAIL_PROVIDER || 'console').toLowerCase();
if (provider === 'resend' || provider === 'smtp' || provider === 'console') {
return provider;
}
console.warn(`[Email] Unknown provider "${provider}", falling back to console`);
return 'console';
}
function getFromEmail(): string {
return process.env.EMAIL_FROM || 'noreply@spanglish.com';
}
function getFromName(): string {
return process.env.EMAIL_FROM_NAME || 'Spanglish';
}
/** Provider info for diagnostics endpoints. */
export function getProviderInfo(): { provider: EmailProvider; configured: boolean } {
const provider = getEmailProvider();
let configured = false;
switch (provider) {
case 'resend':
configured = !!(process.env.EMAIL_API_KEY || process.env.RESEND_API_KEY);
break;
case 'smtp':
configured = !!process.env.SMTP_HOST;
break;
case 'console':
configured = true;
break;
}
return { provider, configured };
}
// ==================== SMTP Configuration ====================
interface SMTPConfig {
host: string;
port: number;
secure: boolean;
auth?: {
user: string;
pass: string;
};
}
function getSMTPConfig(): SMTPConfig | null {
const host = process.env.SMTP_HOST;
const port = parseInt(process.env.SMTP_PORT || '587');
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_PASS;
const secure = process.env.SMTP_SECURE === 'true' || port === 465;
if (!host) {
return null;
}
const config: SMTPConfig = {
host,
port,
secure,
};
if (user && pass) {
config.auth = { user, pass };
}
return config;
}
// Cached SMTP transporter
let smtpTransporter: Transporter | null = null;
function getSMTPTransporter(): Transporter | null {
if (smtpTransporter) {
return smtpTransporter;
}
const config = getSMTPConfig();
if (!config) {
console.error('[Email] SMTP configuration missing');
return null;
}
smtpTransporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.auth,
// Additional options for better deliverability
pool: true,
maxConnections: 5,
maxMessages: 100,
// TLS options
tls: {
rejectUnauthorized: process.env.SMTP_TLS_REJECT_UNAUTHORIZED !== 'false',
},
});
// Verify connection configuration
smtpTransporter.verify((error, success) => {
if (error) {
console.error('[Email] SMTP connection verification failed:', error.message);
} else {
console.log('[Email] SMTP server is ready to send emails');
}
});
return smtpTransporter;
}
// ==================== Email Providers ====================
/**
* Send email using Resend API
*/
async function sendWithResend(options: SendEmailOptions): Promise<SendEmailResult> {
const apiKey = process.env.EMAIL_API_KEY || process.env.RESEND_API_KEY;
const fromEmail = getFromEmail();
const fromName = getFromName();
if (!apiKey) {
console.error('[Email] Resend API key not configured');
return { success: false, error: 'Resend API key not configured' };
}
try {
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: `${fromName} <${fromEmail}>`,
to: Array.isArray(options.to) ? options.to : [options.to],
subject: options.subject,
html: options.html,
text: options.text,
reply_to: options.replyTo,
}),
});
const data = await response.json();
if (!response.ok) {
console.error('[Email] Resend API error:', data);
return {
success: false,
error: data.message || data.error || 'Failed to send email'
};
}
console.log('[Email] Email sent via Resend:', data.id);
return {
success: true,
messageId: data.id
};
} catch (error: any) {
console.error('[Email] Resend error:', error);
return {
success: false,
error: error.message || 'Failed to send email via Resend'
};
}
}
/**
* Send email using SMTP (Nodemailer)
*/
async function sendWithSMTP(options: SendEmailOptions): Promise<SendEmailResult> {
const transporter = getSMTPTransporter();
if (!transporter) {
return { success: false, error: 'SMTP not configured' };
}
const fromEmail = getFromEmail();
const fromName = getFromName();
try {
const info = await transporter.sendMail({
from: `"${fromName}" <${fromEmail}>`,
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
replyTo: options.replyTo,
subject: options.subject,
html: options.html,
text: options.text,
});
console.log('[Email] Email sent via SMTP:', info.messageId);
return {
success: true,
messageId: info.messageId
};
} catch (error: any) {
console.error('[Email] SMTP error:', error);
return {
success: false,
error: error.message || 'Failed to send email via SMTP'
};
}
}
/**
* Console logger for development/testing (no actual email sent)
*/
async function sendWithConsole(options: SendEmailOptions): Promise<SendEmailResult> {
const to = Array.isArray(options.to) ? options.to.join(', ') : options.to;
console.log('\n========================================');
console.log('[Email] Console Mode - Email Preview');
console.log('========================================');
console.log(`To: ${to}`);
console.log(`Subject: ${options.subject}`);
console.log(`Reply-To: ${options.replyTo || 'N/A'}`);
console.log('----------------------------------------');
console.log('HTML Body (truncated):');
console.log(options.html?.substring(0, 500) + '...');
console.log('========================================\n');
return {
success: true,
messageId: `console-${Date.now()}`
};
}
// Mask an email address for logs: keep first char + domain (e.g. j***@example.com).
function maskEmail(email: string): string {
const [local, domain] = String(email).split('@');
if (!domain) return '***';
const head = local.slice(0, 1);
return `${head}***@${domain}`;
}
/**
* Main send function that routes to the appropriate provider
*/
export async function sendEmail(options: SendEmailOptions): Promise<SendEmailResult> {
const provider = getEmailProvider();
const recipientCount = Array.isArray(options.to) ? options.to.length : 1;
const sample = Array.isArray(options.to) ? options.to[0] : options.to;
console.log(`[Email] Sending email via ${provider} to ${maskEmail(sample)}${recipientCount > 1 ? ` (+${recipientCount - 1} more)` : ''}`);
switch (provider) {
case 'resend':
return sendWithResend(options);
case 'smtp':
return sendWithSMTP(options);
case 'console':
default:
return sendWithConsole(options);
}
}
/**
* Test email configuration by sending a test email
*/
export async function testConnection(to: string): Promise<SendEmailResult> {
const { provider, configured } = getProviderInfo();
if (!configured) {
return { success: false, error: `Email provider "${provider}" is not configured` };
}
return sendEmail({
to,
subject: 'Spanglish - Email Test',
html: `
<h2>Email Configuration Test</h2>
<p>This is a test email from your Spanglish platform.</p>
<p><strong>Provider:</strong> ${provider}</p>
<p><strong>Timestamp:</strong> ${new Date().toISOString()}</p>
<p>If you received this email, your email configuration is working correctly!</p>
`,
text: `Email Configuration Test\n\nProvider: ${provider}\nTimestamp: ${new Date().toISOString()}\n\nIf you received this email, your email configuration is working correctly!`,
});
}
+161 -50
View File
@@ -1,17 +1,16 @@
// In-memory email queue with rate limiting
// Processes emails asynchronously in the background without blocking the request thread
// Durable email queue with rate limiting.
// Jobs are persisted in the `email_queue` DB table so they survive process
// restarts. Emails are processed asynchronously in the background without
// blocking the request thread.
import { generateId } from './utils.js';
import { eq, and, asc, sql } from 'drizzle-orm';
import { db, dbGet, emailQueue } from '../db/index.js';
import { generateId, getNow } from './utils.js';
import { isRedisEnabled } from './redis.js';
import { getRateLimiter } from './stores/rateLimiter.js';
// ==================== Types ====================
export interface EmailJob {
id: string;
type: 'template';
params: TemplateEmailJobParams;
addedAt: number;
}
export interface TemplateEmailJobParams {
templateSlug: string;
to: string;
@@ -22,6 +21,11 @@ export interface TemplateEmailJobParams {
sentBy?: string;
}
interface ClaimedJob {
id: string;
params: TemplateEmailJobParams;
}
export interface QueueStatus {
queued: number;
processing: boolean;
@@ -31,7 +35,8 @@ export interface QueueStatus {
// ==================== Queue State ====================
const queue: EmailJob[] = [];
// Tracks send timestamps for the per-process (non-Redis) sliding-window rate
// limit. The job backlog itself lives in the database, not in memory.
const sentTimestamps: number[] = [];
let processing = false;
let processTimer: ReturnType<typeof setTimeout> | null = null;
@@ -41,7 +46,6 @@ let _emailService: any = null;
function getEmailService() {
if (!_emailService) {
// Dynamic import to avoid circular dependency
throw new Error('[EmailQueue] Email service not initialized. Call initEmailQueue() first.');
}
return _emailService;
@@ -50,12 +54,31 @@ function getEmailService() {
/**
* Initialize the email queue with a reference to the email service.
* Must be called once at startup.
*
* Also performs crash recovery: any jobs left in the 'processing' state by a
* previous (crashed) process are reset to 'pending' so they get retried.
*/
export function initEmailQueue(emailService: any): void {
_emailService = emailService;
recoverProcessingJobs()
.then((recovered) => {
if (recovered > 0) {
console.log(`[EmailQueue] Recovered ${recovered} in-flight job(s) after restart`);
}
scheduleProcessing();
})
.catch((err) => console.error('[EmailQueue] Recovery failed:', err?.message || err));
console.log('[EmailQueue] Initialized');
}
async function recoverProcessingJobs(): Promise<number> {
const result: any = await (db as any)
.update(emailQueue)
.set({ status: 'pending' })
.where(eq((emailQueue as any).status, 'processing'));
return result?.changes ?? result?.rowCount ?? 0;
}
// ==================== Rate Limiting ====================
function getMaxPerHour(): number {
@@ -74,19 +97,26 @@ function cleanOldTimestamps(): void {
// ==================== Queue Operations ====================
async function insertJob(id: string, params: TemplateEmailJobParams): Promise<void> {
await (db as any).insert(emailQueue).values({
id,
params: JSON.stringify(params),
status: 'pending',
attempts: 0,
createdAt: getNow(),
});
}
/**
* Add a single email job to the queue.
* Returns the job ID.
* Returns the job ID. Persistence happens asynchronously so the caller is not
* blocked; processing is scheduled once the row is written.
*/
export function enqueueEmail(params: TemplateEmailJobParams): string {
const id = generateId();
queue.push({
id,
type: 'template',
params,
addedAt: Date.now(),
});
scheduleProcessing();
insertJob(id, params)
.then(() => scheduleProcessing())
.catch((err) => console.error('[EmailQueue] Failed to enqueue email:', err?.message || err));
return id;
}
@@ -95,31 +125,32 @@ export function enqueueEmail(params: TemplateEmailJobParams): string {
* Returns array of job IDs.
*/
export function enqueueBulkEmails(paramsList: TemplateEmailJobParams[]): string[] {
const ids: string[] = [];
for (const params of paramsList) {
const id = generateId();
queue.push({
id,
type: 'template',
params,
addedAt: Date.now(),
});
ids.push(id);
}
if (ids.length > 0) {
console.log(`[EmailQueue] Queued ${ids.length} emails for background processing`);
scheduleProcessing();
}
const ids = paramsList.map(() => generateId());
if (ids.length === 0) return ids;
Promise.all(paramsList.map((params, i) => insertJob(ids[i], params)))
.then(() => {
console.log(`[EmailQueue] Queued ${ids.length} emails for background processing`);
scheduleProcessing();
})
.catch((err) => console.error('[EmailQueue] Failed to enqueue bulk emails:', err?.message || err));
return ids;
}
/**
* Get current queue status
*/
export function getQueueStatus(): QueueStatus {
export async function getQueueStatus(): Promise<QueueStatus> {
cleanOldTimestamps();
const row = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(emailQueue)
.where(eq((emailQueue as any).status, 'pending'))
);
return {
queued: queue.length,
queued: Number(row?.count || 0),
processing,
sentInLastHour: sentTimestamps.length,
maxPerHour: getMaxPerHour(),
@@ -135,46 +166,125 @@ function scheduleProcessing(): void {
setImmediate(() => processNext());
}
/**
* Atomically claim the oldest pending job by flipping its status to
* 'processing'. Returns null if there is nothing to do. The conditional update
* (WHERE status='pending') guards against two workers claiming the same row.
*/
async function claimNextJob(): Promise<ClaimedJob | null> {
for (let attempt = 0; attempt < 5; attempt++) {
const row = await dbGet<any>(
(db as any)
.select({ id: (emailQueue as any).id, params: (emailQueue as any).params })
.from(emailQueue)
.where(eq((emailQueue as any).status, 'pending'))
.orderBy(asc((emailQueue as any).createdAt))
.limit(1)
);
if (!row) return null;
const result: any = await (db as any)
.update(emailQueue)
.set({ status: 'processing' })
.where(and(
eq((emailQueue as any).id, row.id),
eq((emailQueue as any).status, 'pending')
));
const affected = result?.changes ?? result?.rowCount ?? 0;
if (affected > 0) {
try {
return { id: row.id, params: JSON.parse(row.params) };
} catch {
// Corrupt params: mark failed and move on rather than crash-looping.
await markJob(row.id, 'failed', 'Invalid job params (JSON parse failed)');
continue;
}
}
// Lost the race for this row; try the next pending one.
}
return null;
}
async function markJob(id: string, status: 'sent' | 'failed', error?: string | null): Promise<void> {
const update: any = { status, processedAt: getNow() };
if (status === 'failed') {
update.attempts = sql`${(emailQueue as any).attempts} + 1`;
if (error) update.lastError = error.slice(0, 1000);
}
await (db as any).update(emailQueue).set(update).where(eq((emailQueue as any).id, id));
}
async function releaseJob(id: string): Promise<void> {
await (db as any)
.update(emailQueue)
.set({ status: 'pending' })
.where(eq((emailQueue as any).id, id));
}
async function processNext(): Promise<void> {
if (queue.length === 0) {
let job: ClaimedJob | null;
try {
job = await claimNextJob();
} catch (error: any) {
// Database error while claiming: back off and retry rather than stop.
console.error('[EmailQueue] Failed to claim next job:', error?.message || error);
processTimer = setTimeout(() => processNext(), 5_000);
return;
}
if (!job) {
processing = false;
console.log('[EmailQueue] Queue empty. Processing stopped.');
return;
}
// Rate limit check
// Rate limit check.
// - Without Redis: per-process sliding window.
// - With Redis: a shared hourly counter so the cap applies across all
// instances rather than once per replica.
cleanOldTimestamps();
const maxPerHour = getMaxPerHour();
let waitMs = 0;
if (sentTimestamps.length >= maxPerHour) {
if (isRedisEnabled()) {
const result = await getRateLimiter().consume('email:hourly', maxPerHour, 3_600_000);
if (!result.allowed) {
waitMs = (result.retryAfter ?? 60) * 1000 + 500; // 500ms buffer
}
} else if (sentTimestamps.length >= maxPerHour) {
// Calculate when the oldest timestamp in the window expires
const waitMs = sentTimestamps[0] + 3_600_000 - Date.now() + 500; // 500ms buffer
waitMs = sentTimestamps[0] + 3_600_000 - Date.now() + 500; // 500ms buffer
}
if (waitMs > 0) {
// Put the claimed job back so it is retried after the cooldown.
await releaseJob(job.id);
console.log(
`[EmailQueue] Rate limit reached (${maxPerHour}/hr). ` +
`Pausing for ${Math.ceil(waitMs / 1000)}s. ${queue.length} email(s) remaining.`
`Pausing for ${Math.ceil(waitMs / 1000)}s.`
);
processTimer = setTimeout(() => processNext(), waitMs);
return;
}
// Dequeue and process
const job = queue.shift()!;
try {
const emailService = getEmailService();
await emailService.sendTemplateEmail(job.params);
sentTimestamps.push(Date.now());
await markJob(job.id, 'sent');
console.log(
`[EmailQueue] Sent email ${job.id} to ${job.params.to}. ` +
`Queue: ${queue.length} remaining. Sent this hour: ${sentTimestamps.length}/${maxPerHour}`
`Sent this hour: ${sentTimestamps.length}/${maxPerHour}`
);
} catch (error: any) {
await markJob(job.id, 'failed', error?.message || String(error));
console.error(
`[EmailQueue] Failed to send email ${job.id} to ${job.params.to}:`,
error?.message || error
);
// The sendTemplateEmail method already logs the failure in the email_logs table,
// so we don't need to retry here. The error is logged and we move on.
// The sendTemplateEmail method already logs the failure in the email_logs
// table, so we just record it on the queue row and move on.
}
// Small delay between sends to be gentle on the email server
@@ -182,7 +292,8 @@ async function processNext(): Promise<void> {
}
/**
* Stop processing (for graceful shutdown)
* Stop processing (for graceful shutdown). In-flight and pending jobs remain
* persisted in the database and resume on the next startup.
*/
export function stopQueue(): void {
if (processTimer) {
@@ -190,5 +301,5 @@ export function stopQueue(): void {
processTimer = null;
}
processing = false;
console.log(`[EmailQueue] Stopped. ${queue.length} email(s) remaining in queue.`);
console.log('[EmailQueue] Stopped. Pending jobs remain persisted in the database.');
}
+28 -6
View File
@@ -1216,8 +1216,26 @@ Spanglish`,
},
];
// Helper function to replace template variables
export function replaceTemplateVariables(template: string, variables: Record<string, any>): string {
// Escape HTML-significant characters so substituted variable values can't inject markup.
function escapeHtmlValue(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
// Helper function to replace template variables.
// When `escapeHtml` is true (HTML rendering contexts), substituted *values* are
// HTML-escaped to prevent stored-XSS via attacker-influenced variables (e.g. event
// location, attendee name). The template markup itself is never escaped. Subjects and
// plain-text bodies pass `escapeHtml=false` so they don't show literal entities.
export function replaceTemplateVariables(
template: string,
variables: Record<string, any>,
escapeHtml: boolean = false
): string {
let result = template;
// Handle conditional blocks {{#if variable}}...{{/if}}
@@ -1229,16 +1247,20 @@ export function replaceTemplateVariables(template: string, variables: Record<str
// Replace simple variables {{variable}}
const variableRegex = /\{\{(\w+)\}\}/g;
result = result.replace(variableRegex, (match, varName) => {
return variables[varName] !== undefined ? String(variables[varName]) : match;
if (variables[varName] === undefined) return match;
const raw = String(variables[varName]);
return escapeHtml ? escapeHtmlValue(raw) : raw;
});
return result;
}
// Helper to wrap content in the base template
// Helper to wrap content in the base template.
// `content` is treated as trusted HTML (it is the already-rendered body). The wrapper's
// own variables (subject, year, etc.) are HTML-escaped on substitution.
export function wrapInBaseTemplate(content: string, variables: Record<string, any>): string {
const wrappedContent = baseEmailWrapper.replace('{{content}}', content);
return replaceTemplateVariables(wrappedContent, variables);
const wrappedContent = baseEmailWrapper.replace('{{content}}', () => content);
return replaceTemplateVariables(wrappedContent, variables, true);
}
// Get all available variables for a template by slug
-3
View File
@@ -80,11 +80,8 @@ export async function createInvoice(params: CreateInvoiceParams): Promise<LNbits
}
console.log('Creating LNbits invoice:', {
url: `${config.url}${apiEndpoint}`,
amount: params.amount,
unit: payload.unit,
memo: params.memo,
webhook: params.webhookUrl,
});
const response = await fetch(`${config.url}${apiEndpoint}`, {
+48
View File
@@ -0,0 +1,48 @@
import { Context } from 'hono';
import { getRateLimiter } from './stores/rateLimiter.js';
/**
* Rate limiting helpers.
*
* The actual counting is delegated to a pluggable rate limiter (in-memory by
* default, Redis-backed when REDIS_URL is set) so limits are enforced either
* per instance (single-instance deployments) or across all instances
* (horizontal scaling). See lib/stores/rateLimiter.ts.
*/
/** Best-effort client IP extraction (honours common reverse-proxy headers). */
export function getClientIp(c: Context): string {
const forwarded = c.req.header('x-forwarded-for');
if (forwarded) return forwarded.split(',')[0].trim();
return c.req.header('x-real-ip') || 'unknown';
}
/**
* Consume one unit against a key. Returns whether the request is allowed and,
* when blocked, how many seconds until the window resets.
*/
export function consumeRateLimit(
key: string,
max: number,
windowMs: number
): Promise<{ allowed: boolean; retryAfter?: number }> {
return getRateLimiter().consume(key, max, windowMs);
}
/**
* Hono middleware factory that rate-limits by client IP.
* Use a distinct `prefix` per endpoint group so unrelated routes don't share a bucket.
*/
export function rateLimitMiddleware(opts: { max: number; windowMs: number; prefix: string }) {
return async (c: Context, next: () => Promise<void>) => {
const ip = getClientIp(c);
const result = await consumeRateLimit(`${opts.prefix}:${ip}`, opts.max, opts.windowMs);
if (!result.allowed) {
return c.json(
{ error: 'Too many requests. Please try again later.', retryAfter: result.retryAfter },
429
);
}
await next();
};
}
+93
View File
@@ -0,0 +1,93 @@
// Optional Redis connection manager.
//
// Redis is entirely optional. When REDIS_URL is unset the app runs exactly as
// 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.
import Redis from 'ioredis';
let client: Redis | null = null;
let subscriber: Redis | null = null;
let healthy = false;
let initialized = false;
/** Whether Redis is configured via REDIS_URL. */
export function isRedisEnabled(): boolean {
return !!process.env.REDIS_URL;
}
/** Whether the Redis connection is currently usable. */
export function isRedisHealthy(): boolean {
return isRedisEnabled() && healthy;
}
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.
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
lazyConnect: false,
retryStrategy(times) {
// Capped exponential backoff for reconnects: 200ms, 400ms ... max 5s.
const delay = Math.min(times * 200, 5000);
return delay;
},
});
instance.on('connect', () => {
console.log(`[redis] (${label}) connecting`);
});
instance.on('ready', () => {
healthy = true;
console.log(`[redis] (${label}) ready`);
});
instance.on('error', (err) => {
healthy = false;
console.error(`[redis] (${label}) error:`, err?.message || err);
});
instance.on('reconnecting', () => {
healthy = false;
console.warn(`[redis] (${label}) reconnecting`);
});
instance.on('end', () => {
healthy = false;
console.warn(`[redis] (${label}) connection closed`);
});
return instance;
}
function ensureInit(): void {
if (initialized || !isRedisEnabled()) return;
initialized = true;
client = buildClient('commands');
subscriber = buildClient('subscriber');
}
/** Shared command connection, or null when Redis is not configured. */
export function getRedis(): Redis | null {
ensureInit();
return client;
}
/** Dedicated subscriber connection, or null when Redis is not configured. */
export function getSubscriber(): Redis | null {
ensureInit();
return subscriber;
}
/** Close connections (used for graceful shutdown). */
export async function closeRedis(): Promise<void> {
const tasks: Promise<unknown>[] = [];
if (client) tasks.push(client.quit().catch(() => undefined));
if (subscriber) tasks.push(subscriber.quit().catch(() => undefined));
await Promise.all(tasks);
client = null;
subscriber = null;
initialized = false;
healthy = false;
}
+136
View File
@@ -0,0 +1,136 @@
// Media storage abstraction with two implementations:
// - local: writes to the ./uploads directory and serves via /uploads/* (the
// original behavior, and the zero-config default)
// - s3: stores objects in an S3-compatible bucket (e.g. Garage), so uploads are
// shared across instances instead of living on one container's local disk
//
// S3 is enabled only when S3_ENDPOINT and S3_BUCKET are set. With it unset the
// app behaves exactly as before. A "key" is the object name (e.g. "abc123.jpg").
import { writeFile, mkdir, unlink } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
const UPLOAD_DIR = './uploads';
export interface Storage {
readonly backend: 'local' | 's3';
put(key: string, buffer: Buffer, contentType: string): Promise<void>;
delete(key: string): Promise<void>;
// Public URL to persist as the media record's fileUrl.
publicUrl(key: string): string;
}
/** Whether S3-compatible storage is configured. */
export function isS3Enabled(): boolean {
return !!(process.env.S3_ENDPOINT && process.env.S3_BUCKET);
}
/** Extract the storage key (object name) from a stored fileUrl. */
export function keyFromUrl(fileUrl: string): string {
return fileUrl.split('/').pop() || fileUrl;
}
// ==================== Local implementation ====================
class LocalStorage implements Storage {
readonly backend = 'local' as const;
private async ensureDir(): Promise<void> {
if (!existsSync(UPLOAD_DIR)) {
await mkdir(UPLOAD_DIR, { recursive: true });
}
}
async put(key: string, buffer: Buffer): Promise<void> {
await this.ensureDir();
await writeFile(join(UPLOAD_DIR, key), buffer);
}
async delete(key: string): Promise<void> {
const filepath = join(UPLOAD_DIR, key);
if (existsSync(filepath)) {
await unlink(filepath);
}
}
publicUrl(key: string): string {
return `/uploads/${key}`;
}
}
// ==================== S3 implementation ====================
// Imported lazily so the AWS SDK is only loaded when S3 is actually configured.
type S3ClientType = import('@aws-sdk/client-s3').S3Client;
class S3Storage implements Storage {
readonly backend = 's3' as const;
private client: S3ClientType | null = null;
private bucket = process.env.S3_BUCKET as string;
private async getClient(): Promise<S3ClientType> {
if (this.client) return this.client;
const { S3Client } = await import('@aws-sdk/client-s3');
const forcePathStyle = (process.env.S3_FORCE_PATH_STYLE || 'true') !== 'false';
this.client = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION || 'us-east-1',
forcePathStyle,
credentials:
process.env.S3_ACCESS_KEY_ID && process.env.S3_SECRET_ACCESS_KEY
? {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
}
: undefined,
});
return this.client;
}
async put(key: string, buffer: Buffer, contentType: string): Promise<void> {
const client = await this.getClient();
const { PutObjectCommand } = await import('@aws-sdk/client-s3');
await client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: buffer,
ContentType: contentType,
})
);
}
async delete(key: string): Promise<void> {
const client = await this.getClient();
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3');
await client.send(
new DeleteObjectCommand({
Bucket: this.bucket,
Key: key,
})
);
}
publicUrl(key: string): string {
// Prefer an explicit public base URL (e.g. a CDN or Garage web endpoint).
const base = process.env.S3_PUBLIC_URL;
if (base) {
return `${base.replace(/\/$/, '')}/${key}`;
}
// Fall back to a path-style URL against the configured endpoint.
const endpoint = (process.env.S3_ENDPOINT || '').replace(/\/$/, '');
return `${endpoint}/${this.bucket}/${key}`;
}
}
// ==================== Selection ====================
let instance: Storage | null = null;
export function getStorage(): Storage {
if (!instance) {
instance = isS3Enabled() ? new S3Storage() : new LocalStorage();
}
return instance;
}
+105
View File
@@ -0,0 +1,105 @@
// Cache abstraction with two implementations:
// - memory: per-process Map with TTL expiry (single instance)
// - redis: shared GET / SETEX / DEL with JSON values (all instances)
//
// Values are JSON-serialized. Selection happens once based on REDIS_URL. On any
// Redis error the cache behaves as a miss so callers fall back to their source.
import { getRedis, isRedisEnabled } from '../redis.js';
export interface Cache {
readonly backend: 'memory' | 'redis';
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttlSeconds: number): Promise<void>;
del(key: string): Promise<void>;
}
// ==================== Memory implementation ====================
interface Entry {
value: unknown;
expiresAt: number;
}
class MemoryCache implements Cache {
readonly backend = 'memory' as const;
private store = new Map<string, Entry>();
constructor() {
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, entry] of this.store) {
if (now > entry.expiresAt) this.store.delete(key);
}
}, 60_000);
(cleanup as any).unref?.();
}
async get<T>(key: string): Promise<T | null> {
const entry = this.store.get(key);
if (!entry) return null;
if (Date.now() > entry.expiresAt) {
this.store.delete(key);
return null;
}
return entry.value as T;
}
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
}
async del(key: string): Promise<void> {
this.store.delete(key);
}
}
// ==================== Redis implementation ====================
class RedisCache implements Cache {
readonly backend = 'redis' as const;
async get<T>(key: string): Promise<T | null> {
const redis = getRedis();
if (!redis) return null;
try {
const raw = await redis.get(`cache:${key}`);
if (raw === null) return null;
return JSON.parse(raw) as T;
} catch (err: any) {
console.error('[cache] redis get error:', err?.message || err);
return null;
}
}
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await redis.set(`cache:${key}`, JSON.stringify(value), 'EX', ttlSeconds);
} catch (err: any) {
console.error('[cache] redis set error:', err?.message || err);
}
}
async del(key: string): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await redis.del(`cache:${key}`);
} catch (err: any) {
console.error('[cache] redis del error:', err?.message || err);
}
}
}
// ==================== Selection ====================
let instance: Cache | null = null;
export function getCache(): Cache {
if (!instance) {
instance = isRedisEnabled() ? new RedisCache() : new MemoryCache();
}
return instance;
}
+111
View File
@@ -0,0 +1,111 @@
// Distributed lock abstraction with two implementations:
// - memory: per-process key set with TTL (only meaningful within one instance)
// - redis: SET key token NX PX ttl, released with a compare-and-delete Lua
// script so only the holder can release it
//
// 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.
import { randomUUID } from 'crypto';
import { getRedis, isRedisEnabled } from '../redis.js';
export interface Lock {
readonly backend: 'memory' | 'redis';
// Returns a token when the lock was acquired, or null when already held.
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>;
}
// ==================== Memory implementation ====================
class MemoryLock implements Lock {
readonly backend = 'memory' as const;
private held = new Map<string, { token: string; expiresAt: number }>();
async acquire(key: string, ttlMs: number): Promise<string | null> {
const existing = this.held.get(key);
const now = Date.now();
if (existing && existing.expiresAt > now) {
return null;
}
const token = randomUUID();
this.held.set(key, { token, expiresAt: now + ttlMs });
return token;
}
async release(key: string, token: string): Promise<void> {
const existing = this.held.get(key);
if (existing && existing.token === token) {
this.held.delete(key);
}
}
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null> {
const token = await this.acquire(key, ttlMs);
if (!token) return null;
try {
return await fn();
} finally {
await this.release(key, token);
}
}
}
// ==================== Redis implementation ====================
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 {
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();
}
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();
}
}
async release(key: string, token: string): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await redis.eval(RELEASE_SCRIPT, 1, `lock:${key}`, token);
} catch (err: any) {
console.error('[lock] redis release error:', err?.message || err);
}
}
async withLock<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T | null> {
const token = await this.acquire(key, ttlMs);
if (!token) return null;
try {
return await fn();
} finally {
await this.release(key, token);
}
}
}
// ==================== Selection ====================
let instance: Lock | null = null;
export function getLock(): Lock {
if (!instance) {
instance = isRedisEnabled() ? new RedisLock() : new MemoryLock();
}
return instance;
}
+121
View File
@@ -0,0 +1,121 @@
// Pub/Sub abstraction with two implementations:
// - memory: in-process EventEmitter (single instance only)
// - redis: PUBLISH / SUBSCRIBE so a message published on one instance reaches
// subscribers on every instance
//
// Messages are JSON-serialized. Selection happens once based on REDIS_URL.
import { EventEmitter } from 'events';
import { getRedis, getSubscriber, isRedisEnabled } from '../redis.js';
export type PubSubHandler = (message: any) => void;
export interface PubSub {
readonly backend: 'memory' | 'redis';
publish(channel: string, message: any): Promise<void>;
// Returns an unsubscribe function for this specific handler.
subscribe(channel: string, handler: PubSubHandler): Promise<() => void>;
}
// ==================== Memory implementation ====================
class MemoryPubSub implements PubSub {
readonly backend = 'memory' as const;
private emitter = new EventEmitter();
constructor() {
// SSE fan-out can attach many listeners to the same channel; lift the cap.
this.emitter.setMaxListeners(0);
}
async publish(channel: string, message: any): Promise<void> {
this.emitter.emit(channel, message);
}
async subscribe(channel: string, handler: PubSubHandler): Promise<() => void> {
this.emitter.on(channel, handler);
return () => this.emitter.off(channel, handler);
}
}
// ==================== Redis implementation ====================
class RedisPubSub implements PubSub {
readonly backend = 'redis' as const;
// Per-channel handler sets so a single Redis subscription fans out locally.
private handlers = new Map<string, Set<PubSubHandler>>();
private wired = false;
private ensureWired(): void {
if (this.wired) return;
const sub = getSubscriber();
if (!sub) return;
this.wired = true;
sub.on('message', (channel: string, payload: string) => {
const set = this.handlers.get(channel);
if (!set || set.size === 0) return;
let parsed: any = payload;
try {
parsed = JSON.parse(payload);
} catch {
// Leave as raw string if it was not JSON.
}
for (const handler of set) {
try {
handler(parsed);
} catch (err: any) {
console.error('[pubsub] handler error:', err?.message || err);
}
}
});
}
async publish(channel: string, message: any): Promise<void> {
const redis = getRedis();
if (!redis) return;
try {
await redis.publish(channel, JSON.stringify(message));
} catch (err: any) {
console.error('[pubsub] publish error:', err?.message || err);
}
}
async subscribe(channel: string, handler: PubSubHandler): Promise<() => void> {
this.ensureWired();
const sub = getSubscriber();
if (!sub) return () => undefined;
let set = this.handlers.get(channel);
if (!set) {
set = new Set();
this.handlers.set(channel, set);
try {
await sub.subscribe(channel);
} catch (err: any) {
console.error('[pubsub] subscribe error:', err?.message || err);
}
}
set.add(handler);
return () => {
const current = this.handlers.get(channel);
if (!current) return;
current.delete(handler);
if (current.size === 0) {
this.handlers.delete(channel);
sub.unsubscribe(channel).catch(() => undefined);
}
};
}
}
// ==================== Selection ====================
let instance: PubSub | null = null;
export function getPubSub(): PubSub {
if (!instance) {
instance = isRedisEnabled() ? new RedisPubSub() : new MemoryPubSub();
}
return instance;
}
+98
View File
@@ -0,0 +1,98 @@
// Rate limiter abstraction with two implementations:
// - memory: per-process fixed window (the original behavior)
// - redis: shared fixed window across all instances (INCR + PEXPIRE)
//
// 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 { getRedis, isRedisEnabled } from '../redis.js';
export interface RateLimitResult {
allowed: boolean;
retryAfter?: number;
}
export interface RateLimiter {
readonly backend: 'memory' | 'redis';
consume(key: string, max: number, windowMs: number): Promise<RateLimitResult>;
}
// ==================== Memory implementation ====================
interface Bucket {
count: number;
resetAt: number;
}
class MemoryRateLimiter implements RateLimiter {
readonly backend = 'memory' as const;
private buckets = new Map<string, Bucket>();
constructor() {
// Periodically drop expired buckets so the Map does not grow unbounded.
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, bucket] of this.buckets) {
if (now > bucket.resetAt) this.buckets.delete(key);
}
}, 60_000);
(cleanup as any).unref?.();
}
async consume(key: string, max: number, windowMs: number): Promise<RateLimitResult> {
const now = Date.now();
const bucket = this.buckets.get(key);
if (!bucket || now > bucket.resetAt) {
this.buckets.set(key, { count: 1, resetAt: now + windowMs });
return { allowed: true };
}
bucket.count++;
if (bucket.count > max) {
return { allowed: false, retryAfter: Math.ceil((bucket.resetAt - now) / 1000) };
}
return { allowed: true };
}
}
// ==================== Redis implementation ====================
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);
}
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 };
}
return { allowed: true };
} catch (err: any) {
// Fail open: never block traffic because Redis is unavailable.
console.error('[rateLimiter] redis error, allowing request:', err?.message || err);
return { allowed: true };
}
}
}
// ==================== Selection ====================
let instance: RateLimiter | null = null;
export function getRateLimiter(): RateLimiter {
if (!instance) {
instance = isRedisEnabled() ? new RedisRateLimiter() : new MemoryRateLimiter();
}
return instance;
}
+76 -95
View File
@@ -6,6 +6,16 @@ import { getNow } from '../lib/utils.js';
const adminRouter = new Hono();
// Escape a value for inclusion in a CSV cell (RFC 4180 quoting).
const csvEscape = (value: string) => {
if (value == null) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
};
// Dashboard overview stats (admin)
adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) => {
const now = getNow();
@@ -67,14 +77,14 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
.where(eq((payments as any).status, 'pending'))
);
const paidPayments = await dbAll<any>(
const revenueRow = await dbGet<any>(
(db as any)
.select()
.select({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` })
.from(payments)
.where(eq((payments as any).status, 'paid'))
);
const totalRevenue = paidPayments.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0);
const totalRevenue = Number(revenueRow?.total || 0);
const newContacts = await dbGet<any>(
(db as any)
@@ -110,56 +120,52 @@ adminRouter.get('/dashboard', requireAuth(['admin', 'organizer']), async (c) =>
// Get analytics data (admin)
adminRouter.get('/analytics', requireAuth(['admin']), async (c) => {
// Get events with ticket counts
// Get events with ticket counts using grouped aggregates (avoids 3 queries per event).
const allEvents = await dbAll<any>((db as any).select().from(events));
const eventStats = await Promise.all(
allEvents.map(async (event: any) => {
const ticketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(eq((tickets as any).eventId, event.id))
);
const confirmedCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'confirmed'),
ne((tickets as any).isGuest, 1)
)
)
);
const checkedInCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(tickets)
.where(
and(
eq((tickets as any).eventId, event.id),
eq((tickets as any).status, 'checked_in'),
ne((tickets as any).isGuest, 1)
)
)
);
return {
id: event.id,
title: event.title,
date: event.startDatetime,
capacity: event.capacity,
totalBookings: ticketCount?.count || 0,
confirmedBookings: confirmedCount?.count || 0,
checkedIn: checkedInCount?.count || 0,
revenue: (confirmedCount?.count || 0) * event.price,
};
})
const totalRows = await dbAll<any>(
(db as any)
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
.from(tickets)
.groupBy((tickets as any).eventId)
);
const confirmedRows = await dbAll<any>(
(db as any)
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
.from(tickets)
.where(and(eq((tickets as any).status, 'confirmed'), ne((tickets as any).isGuest, 1)))
.groupBy((tickets as any).eventId)
);
const checkedInRows = await dbAll<any>(
(db as any)
.select({ eventId: (tickets as any).eventId, count: sql<number>`count(*)` })
.from(tickets)
.where(and(eq((tickets as any).status, 'checked_in'), ne((tickets as any).isGuest, 1)))
.groupBy((tickets as any).eventId)
);
const toMap = (rows: any[]) => {
const m = new Map<string, number>();
for (const r of rows) m.set(r.eventId, Number(r.count) || 0);
return m;
};
const totalMap = toMap(totalRows);
const confirmedMap = toMap(confirmedRows);
const checkedInMap = toMap(checkedInRows);
const eventStats = allEvents.map((event: any) => {
const confirmedBookings = confirmedMap.get(event.id) || 0;
return {
id: event.id,
title: event.title,
date: event.startDatetime,
capacity: event.capacity,
totalBookings: totalMap.get(event.id) || 0,
confirmedBookings,
checkedIn: checkedInMap.get(event.id) || 0,
revenue: confirmedBookings * event.price,
};
});
return c.json({
analytics: {
@@ -179,30 +185,25 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
}
const ticketList = await dbAll<any>(query);
const userIds = [...new Set(ticketList.map((t: any) => t.userId).filter(Boolean))];
const eventIds = [...new Set(ticketList.map((t: any) => t.eventId).filter(Boolean))];
const ticketIds = ticketList.map((t: any) => t.id);
const [userRows, eventRows, paymentRows] = await Promise.all([
userIds.length ? dbAll<any>((db as any).select().from(users).where(inArray((users as any).id, userIds))) : Promise.resolve([]),
eventIds.length ? dbAll<any>((db as any).select().from(events).where(inArray((events as any).id, eventIds))) : Promise.resolve([]),
ticketIds.length ? dbAll<any>((db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds))) : Promise.resolve([]),
]);
const usersById = new Map(userRows.map((u: any) => [u.id, u]));
const eventsById = new Map(eventRows.map((e: any) => [e.id, e]));
const paymentsByTicketId = new Map(paymentRows.map((p: any) => [p.ticketId, p]));
// Get user and event details for each ticket
const enrichedTickets = await Promise.all(
ticketList.map(async (ticket: any) => {
const user = await dbGet<any>(
(db as any)
.select()
.from(users)
.where(eq((users as any).id, ticket.userId))
);
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticket.id))
);
const enrichedTickets = ticketList.map((ticket: any) => {
const user = usersById.get(ticket.userId);
const event = eventsById.get(ticket.eventId);
const payment = paymentsByTicketId.get(ticket.id);
return {
ticketId: ticket.id,
@@ -218,8 +219,7 @@ adminRouter.get('/export/tickets', requireAuth(['admin']), async (c) => {
paymentAmount: payment?.amount,
createdAt: ticket.createdAt,
};
})
);
});
return c.json({ tickets: enrichedTickets });
});
@@ -301,16 +301,6 @@ adminRouter.get('/events/:eventId/attendees/export', requireAuth(['admin']), asy
})
);
// Generate CSV
const csvEscape = (value: string) => {
if (value == null) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
};
const columns = [
'Ticket ID', 'Full Name', 'Email', 'Phone',
'Status', 'Checked In', 'Check-in Time', 'Payment Status',
@@ -390,15 +380,6 @@ adminRouter.get('/events/:eventId/tickets/export', requireAuth(['admin']), async
});
}
const csvEscape = (value: string) => {
if (value == null) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
};
const columns = ['Ticket ID', 'Booking ID', 'Attendee Name', 'Status', 'Check-in Time', 'Booked At'];
const rows = ticketList.map((ticket: any) => ({
+93 -46
View File
@@ -14,10 +14,17 @@ import {
createMagicLinkToken,
verifyMagicLinkToken,
invalidateAllUserSessions,
bumpTokenVersion,
requireAuth,
getUserPasswordHash,
} from '../lib/auth.js';
import { generateId, getNow, toDbBool } from '../lib/utils.js';
import { sendEmail } from '../lib/email.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
// Per-IP rate limit for sensitive auth endpoints (registration, login, and all
// email-dispatching flows) to curb credential stuffing and email flooding.
const authRateLimit = rateLimitMiddleware({ max: 20, windowMs: 15 * 60 * 1000, prefix: 'auth' });
// User type that includes all fields (some added in schema updates)
type AuthUser = User & {
@@ -97,8 +104,7 @@ const passwordResetSchema = z.object({
const claimAccountSchema = z.object({
token: z.string(),
password: z.string().min(10, 'Password must be at least 10 characters').optional(),
googleId: z.string().optional(),
password: z.string().min(10, 'Password must be at least 10 characters'),
});
const changePasswordSchema = z.object({
@@ -111,7 +117,7 @@ const googleAuthSchema = z.object({
});
// Register
auth.post('/register', zValidator('json', registerSchema), async (c) => {
auth.post('/register', authRateLimit, zValidator('json', registerSchema), async (c) => {
const data = c.req.valid('json');
// Validate password strength
@@ -161,7 +167,7 @@ auth.post('/register', zValidator('json', registerSchema), async (c) => {
await (db as any).insert(users).values(newUser);
const token = await createToken(id, data.email, newUser.role);
const token = await createToken(id, data.email, newUser.role, 0);
const refreshToken = await createRefreshToken(id);
return c.json({
@@ -179,7 +185,7 @@ auth.post('/register', zValidator('json', registerSchema), async (c) => {
});
// Login with email/password
auth.post('/login', zValidator('json', loginSchema), async (c) => {
auth.post('/login', authRateLimit, zValidator('json', loginSchema), async (c) => {
const data = c.req.valid('json');
// Check rate limit
@@ -223,8 +229,23 @@ auth.post('/login', zValidator('json', loginSchema), async (c) => {
// Clear failed attempts on successful login
clearFailedAttempts(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
// the login.
if (!String(user.password).startsWith('$argon2')) {
try {
const upgradedHash = await hashPassword(data.password);
await (db as any)
.update(users)
.set({ password: upgradedHash })
.where(eq((users as any).id, user.id));
} catch (err: any) {
console.error('[auth] Failed to upgrade legacy password hash:', err?.message || err);
}
}
const token = await createToken(user.id, user.email, user.role);
const token = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
const refreshToken = await createRefreshToken(user.id);
return c.json({
@@ -244,7 +265,7 @@ auth.post('/login', zValidator('json', loginSchema), async (c) => {
});
// Request magic link login
auth.post('/magic-link/request', zValidator('json', magicLinkRequestSchema), async (c) => {
auth.post('/magic-link/request', authRateLimit, zValidator('json', magicLinkRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await dbGet<any>(
@@ -285,7 +306,7 @@ auth.post('/magic-link/request', zValidator('json', magicLinkRequestSchema), asy
});
// Verify magic link and login
auth.post('/magic-link/verify', zValidator('json', magicLinkVerifySchema), async (c) => {
auth.post('/magic-link/verify', authRateLimit, zValidator('json', magicLinkVerifySchema), async (c) => {
const { token } = c.req.valid('json');
const verification = await verifyMagicLinkToken(token, 'login');
@@ -302,7 +323,7 @@ auth.post('/magic-link/verify', zValidator('json', magicLinkVerifySchema), async
return c.json({ error: 'Invalid token' }, 400);
}
const authToken = await createToken(user.id, user.email, user.role);
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
const refreshToken = await createRefreshToken(user.id);
return c.json({
@@ -322,7 +343,7 @@ auth.post('/magic-link/verify', zValidator('json', magicLinkVerifySchema), async
});
// Request password reset
auth.post('/password-reset/request', zValidator('json', passwordResetRequestSchema), async (c) => {
auth.post('/password-reset/request', authRateLimit, zValidator('json', passwordResetRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await dbGet<any>(
@@ -363,7 +384,7 @@ auth.post('/password-reset/request', zValidator('json', passwordResetRequestSche
});
// Reset password
auth.post('/password-reset/confirm', zValidator('json', passwordResetSchema), async (c) => {
auth.post('/password-reset/confirm', authRateLimit, zValidator('json', passwordResetSchema), async (c) => {
const { token, password } = c.req.valid('json');
// Validate password strength
@@ -389,14 +410,15 @@ auth.post('/password-reset/confirm', zValidator('json', passwordResetSchema), as
})
.where(eq((users as any).id, verification.userId));
// Invalidate all existing sessions for security
// Invalidate all existing sessions/JWTs for security
await invalidateAllUserSessions(verification.userId!);
await bumpTokenVersion(verification.userId!);
return c.json({ message: 'Password reset successfully. Please log in with your new password.' });
});
// Claim unclaimed account
auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema), async (c) => {
auth.post('/claim-account/request', authRateLimit, zValidator('json', magicLinkRequestSchema), async (c) => {
const { email } = c.req.valid('json');
const user = await dbGet<any>(
@@ -411,8 +433,8 @@ auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema),
return c.json({ error: 'Account is already claimed' }, 400);
}
// Create claim token (expires in 24 hours)
const token = await createMagicLinkToken(user.id, 'claim_account', 24 * 60);
// Create claim token (expires in 1 hour)
const token = await createMagicLinkToken(user.id, 'claim_account', 60);
const claimLink = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/claim-account?token=${token}`;
// Send email
@@ -425,7 +447,7 @@ auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema),
<p>An account was created for you during booking. Click below to set up your login credentials.</p>
<p><a href="${claimLink}" style="background-color: #3B82F6; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">Claim Account</a></p>
<p>Or copy this link: ${claimLink}</p>
<p>This link expires in 24 hours.</p>
<p>This link expires in 1 hour.</p>
`,
});
} catch (error) {
@@ -436,12 +458,8 @@ auth.post('/claim-account/request', zValidator('json', magicLinkRequestSchema),
});
// Complete account claim
auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), async (c) => {
const { token, password, googleId } = c.req.valid('json');
if (!password && !googleId) {
return c.json({ error: 'Please provide either a password or link a Google account' }, 400);
}
auth.post('/claim-account/confirm', authRateLimit, zValidator('json', claimAccountSchema), async (c) => {
const { token, password } = c.req.valid('json');
const verification = await verifyMagicLinkToken(token, 'claim_account');
@@ -449,25 +467,21 @@ auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), asyn
return c.json({ error: verification.error }, 400);
}
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
const now = getNow();
// Only set a password here. Linking a Google account requires a verified Google
// ID token via /google; we never trust a client-supplied googleId.
const updates: Record<string, any> = {
isClaimed: toDbBool(true),
accountStatus: 'active',
password: await hashPassword(password),
updatedAt: now,
};
if (password) {
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return c.json({ error: passwordValidation.error }, 400);
}
updates.password = await hashPassword(password);
}
if (googleId) {
updates.googleId = googleId;
}
await (db as any)
.update(users)
.set(updates)
@@ -477,7 +491,7 @@ auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), asyn
(db as any).select().from(users).where(eq((users as any).id, verification.userId))
);
const authToken = await createToken(user.id, user.email, user.role);
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
const refreshToken = await createRefreshToken(user.id);
return c.json({
@@ -498,13 +512,14 @@ auth.post('/claim-account/confirm', zValidator('json', claimAccountSchema), asyn
});
// Google OAuth login/register
auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
auth.post('/google', authRateLimit, zValidator('json', googleAuthSchema), async (c) => {
const { credential } = c.req.valid('json');
try {
// Verify Google token
// In production, use Google's library to verify: https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${credential}`);
// Verify the Google ID token. Google's tokeninfo endpoint validates the
// signature and expiry server-side; we additionally enforce the audience so a
// token minted for a different OAuth client cannot be replayed against us.
const response = await fetch(`https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(credential)}`);
if (!response.ok) {
return c.json({ error: 'Invalid Google token' }, 400);
@@ -515,11 +530,29 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
email: string;
name: string;
email_verified: string;
aud?: string;
exp?: string;
};
if (googleData.email_verified !== 'true') {
// email_verified can be returned as boolean true or string "true"
if (String(googleData.email_verified) !== 'true') {
return c.json({ error: 'Google email not verified' }, 400);
}
// Enforce audience when a client ID is configured (closes token-confusion attacks)
const expectedAud = process.env.GOOGLE_CLIENT_ID;
if (expectedAud) {
if (googleData.aud !== expectedAud) {
return c.json({ error: 'Invalid Google token audience' }, 400);
}
} else {
console.warn('[auth] GOOGLE_CLIENT_ID is not set; skipping audience verification for Google login.');
}
// Reject expired tokens (defense-in-depth; tokeninfo also rejects them)
if (googleData.exp && Number(googleData.exp) * 1000 < Date.now()) {
return c.json({ error: 'Google token expired' }, 400);
}
const { sub: googleId, email, name } = googleData;
@@ -584,7 +617,7 @@ auth.post('/google', zValidator('json', googleAuthSchema), async (c) => {
user = newUser;
}
const authToken = await createToken(user.id, user.email, user.role);
const authToken = await createToken(user.id, user.email, user.role, user.tokenVersion ?? 0);
const refreshToken = await createRefreshToken(user.id);
return c.json({
@@ -643,8 +676,9 @@ auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSc
}
// Verify current password if user has one
if (user.password) {
const validPassword = await verifyPassword(currentPassword, user.password);
const existingHash = await getUserPasswordHash(user.id);
if (existingHash) {
const validPassword = await verifyPassword(currentPassword, existingHash);
if (!validPassword) {
return c.json({ error: 'Current password is incorrect' }, 400);
}
@@ -660,12 +694,25 @@ auth.post('/change-password', requireAuth(), zValidator('json', changePasswordSc
updatedAt: now,
})
.where(eq((users as any).id, user.id));
// Invalidate all previously issued JWTs so a stolen old token can't outlive the change,
// then hand the current client a fresh token so it stays logged in on this device.
await bumpTokenVersion(user.id);
const refreshedUser = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, user.id))
);
const newToken = await createToken(user.id, user.email, user.role, refreshedUser?.tokenVersion ?? 0);
return c.json({ message: 'Password changed successfully' });
return c.json({ message: 'Password changed successfully', token: newToken });
});
// Logout (client-side token removal, but we can log the action)
// Logout - invalidate all previously issued JWTs for this user (logout everywhere)
auth.post('/logout', async (c) => {
const user = await getAuthUser(c);
if (user) {
await invalidateAllUserSessions(user.id);
await bumpTokenVersion(user.id);
}
return c.json({ message: 'Logged out successfully' });
});
+32 -32
View File
@@ -4,25 +4,17 @@ import { z } from 'zod';
import { db, dbGet, dbAll, contacts, emailSubscribers, legalSettings } from '../db/index.js';
import { eq, desc } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
import { generateId, getNow, sanitizeHtml } from '../lib/utils.js';
import { emailService } from '../lib/email.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
const contactsRouter = new Hono();
// ==================== Sanitization Helpers ====================
// Per-IP rate limit for public, unauthenticated write endpoints (contact form,
// newsletter subscribe/unsubscribe) to prevent spam and email flooding.
const publicFormLimit = rateLimitMiddleware({ max: 5, windowMs: 10 * 60 * 1000, prefix: 'contacts' });
/**
* Sanitize a string to prevent HTML injection
* Escapes HTML special characters
*/
function sanitizeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
// ==================== Sanitization Helpers ====================
/**
* Sanitize email header values to prevent email header injection
@@ -33,14 +25,14 @@ function sanitizeHeaderValue(str: string): string {
}
const createContactSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10),
name: z.string().min(2).max(200),
email: z.string().email().max(254),
message: z.string().min(10).max(5000),
});
const subscribeSchema = z.object({
email: z.string().email(),
name: z.string().optional(),
email: z.string().email().max(254),
name: z.string().max(200).optional(),
});
const updateContactSchema = z.object({
@@ -48,7 +40,7 @@ const updateContactSchema = z.object({
});
// Submit contact form (public)
contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
contactsRouter.post('/', publicFormLimit, zValidator('json', createContactSchema), async (c) => {
const data = c.req.valid('json');
const now = getNow();
const id = generateId();
@@ -125,7 +117,7 @@ contactsRouter.post('/', zValidator('json', createContactSchema), async (c) => {
});
// Subscribe to newsletter (public)
contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c) => {
contactsRouter.post('/subscribe', publicFormLimit, zValidator('json', subscribeSchema), async (c) => {
const data = c.req.valid('json');
// Check if already subscribed
@@ -166,28 +158,30 @@ contactsRouter.post('/subscribe', zValidator('json', subscribeSchema), async (c)
});
// Unsubscribe from newsletter (public)
contactsRouter.post('/unsubscribe', zValidator('json', z.object({ email: z.string().email() })), async (c) => {
contactsRouter.post('/unsubscribe', publicFormLimit, zValidator('json', z.object({ email: z.string().email().max(254) })), async (c) => {
const { email } = c.req.valid('json');
const existing = await dbGet<any>(
(db as any).select().from(emailSubscribers).where(eq((emailSubscribers as any).email, email))
);
if (!existing) {
return c.json({ error: 'Email not found' }, 404);
// Always return the same response whether or not the address exists, to avoid
// leaking which emails are subscribed (enumeration).
if (existing && existing.status !== 'unsubscribed') {
await (db as any)
.update(emailSubscribers)
.set({ status: 'unsubscribed' })
.where(eq((emailSubscribers as any).id, existing.id));
}
await (db as any)
.update(emailSubscribers)
.set({ status: 'unsubscribed' })
.where(eq((emailSubscribers as any).id, existing.id));
return c.json({ message: 'Successfully unsubscribed' });
return c.json({ message: 'If this email was subscribed, it has been unsubscribed.' });
});
// Get all contacts (admin)
contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
const status = c.req.query('status');
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '100', 10) || 100, 1), 500);
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
let query = (db as any).select().from(contacts);
@@ -195,7 +189,9 @@ contactsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
query = query.where(eq((contacts as any).status, status));
}
const result = await dbAll(query.orderBy(desc((contacts as any).createdAt)));
const result = await dbAll(
query.orderBy(desc((contacts as any).createdAt)).limit(limit).offset(offset)
);
return c.json({ contacts: result });
});
@@ -255,6 +251,8 @@ contactsRouter.delete('/:id', requireAuth(['admin']), async (c) => {
// Get all subscribers (admin)
contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), async (c) => {
const status = c.req.query('status');
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '100', 10) || 100, 1), 1000);
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
let query = (db as any).select().from(emailSubscribers);
@@ -262,7 +260,9 @@ contactsRouter.get('/subscribers/list', requireAuth(['admin', 'marketing']), asy
query = query.where(eq((emailSubscribers as any).status, status));
}
const result = await dbAll(query.orderBy(desc((emailSubscribers as any).createdAt)));
const result = await dbAll(
query.orderBy(desc((emailSubscribers as any).createdAt)).limit(limit).offset(offset)
);
return c.json({ subscribers: result });
});
+43 -36
View File
@@ -2,8 +2,8 @@ import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, dbAll, users, tickets, payments, events, invoices, User } from '../db/index.js';
import { eq, desc, and, gt, sql } from 'drizzle-orm';
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, hashPassword, validatePassword } from '../lib/auth.js';
import { eq, desc, and, gt, sql, inArray } from 'drizzle-orm';
import { requireAuth, getUserSessions, invalidateSession, invalidateAllUserSessions, bumpTokenVersion, createToken, hashPassword, validatePassword, getUserPasswordHash } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
// User type that includes all fields (some added in schema updates)
@@ -37,6 +37,8 @@ dashboard.get('/profile', async (c) => {
const now = new Date();
const membershipDays = Math.floor((now.getTime() - createdDate.getTime()) / (1000 * 60 * 60 * 24));
const hasPassword = !!(await getUserPasswordHash(user.id));
return c.json({
profile: {
id: user.id,
@@ -47,7 +49,7 @@ dashboard.get('/profile', async (c) => {
rucNumber: user.rucNumber,
isClaimed: user.isClaimed,
accountStatus: user.accountStatus,
hasPassword: !!user.password,
hasPassword,
hasGoogleLinked: !!user.googleId,
memberSince: user.createdAt,
membershipDays,
@@ -103,34 +105,31 @@ dashboard.get('/tickets', async (c) => {
.where(eq((tickets as any).userId, user.id))
.orderBy(desc((tickets as any).createdAt))
);
// Batch-fetch related events, payments, and invoices (avoids N+1 per ticket).
const eventIds = [...new Set(userTickets.map((t: any) => t.eventId).filter(Boolean))];
const ticketIds = userTickets.map((t: any) => t.id);
const eventRows = eventIds.length
? await dbAll<any>((db as any).select().from(events).where(inArray((events as any).id, eventIds)))
: [];
const paymentRows = ticketIds.length
? await dbAll<any>((db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds)))
: [];
const eventsById = new Map(eventRows.map((e: any) => [e.id, e]));
const paymentsByTicketId = new Map(paymentRows.map((p: any) => [p.ticketId, p]));
const paidPaymentIds = paymentRows.filter((p: any) => p.status === 'paid').map((p: any) => p.id);
const invoiceRows = paidPaymentIds.length
? await dbAll<any>((db as any).select().from(invoices).where(inArray((invoices as any).paymentId, paidPaymentIds)))
: [];
const invoicesByPaymentId = new Map(invoiceRows.map((inv: any) => [inv.paymentId, inv]));
// Get event details for each ticket
const ticketsWithEvents = await Promise.all(
userTickets.map(async (ticket: any) => {
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(eq((events as any).id, ticket.eventId))
);
const payment = await dbGet<any>(
(db as any)
.select()
.from(payments)
.where(eq((payments as any).ticketId, ticket.id))
);
// Check for invoice
let invoice: any = null;
if (payment && payment.status === 'paid') {
invoice = await dbGet<any>(
(db as any)
.select()
.from(invoices)
.where(eq((invoices as any).paymentId, payment.id))
);
}
const ticketsWithEvents = userTickets.map((ticket: any) => {
const event = eventsById.get(ticket.eventId);
const payment = paymentsByTicketId.get(ticket.id);
const invoice = payment && payment.status === 'paid' ? invoicesByPaymentId.get(payment.id) : null;
return {
...ticket,
@@ -162,8 +161,7 @@ dashboard.get('/tickets', async (c) => {
createdAt: invoice.createdAt,
} : null,
};
})
);
});
return c.json({ tickets: ticketsWithEvents });
});
@@ -452,13 +450,22 @@ dashboard.delete('/sessions/:id', async (c) => {
return c.json({ message: 'Session revoked' });
});
// Revoke all sessions (logout everywhere)
// Revoke all sessions (logout everywhere). Bumping the token version invalidates
// every previously issued JWT for this user, which is the actual enforcement
// mechanism (auth is stateless JWT, not DB-session based).
dashboard.post('/sessions/revoke-all', async (c) => {
const user = (c as any).get('user') as AuthUser;
await invalidateAllUserSessions(user.id);
await bumpTokenVersion(user.id);
// Issue a fresh token so the current device stays signed in
const refreshed = await dbGet<any>(
(db as any).select().from(users).where(eq((users as any).id, user.id))
);
const token = await createToken(user.id, user.email, user.role, refreshed?.tokenVersion ?? 0);
return c.json({ message: 'All sessions revoked. Please log in again.' });
return c.json({ message: 'All other sessions revoked.', token });
});
// Set password (for users without one)
@@ -471,7 +478,7 @@ dashboard.post('/set-password', zValidator('json', setPasswordSchema), async (c)
const { password } = c.req.valid('json');
// Check if user already has a password
if (user.password) {
if (await getUserPasswordHash(user.id)) {
return c.json({ error: 'Password already set. Use change password instead.' }, 400);
}
@@ -502,7 +509,7 @@ dashboard.post('/unlink-google', async (c) => {
return c.json({ error: 'Google account not linked' }, 400);
}
if (!user.password) {
if (!(await getUserPasswordHash(user.id))) {
return c.json({ error: 'Cannot unlink Google without a password set' }, 400);
}
+75 -31
View File
@@ -1,4 +1,6 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
import { eq, desc, and, or, sql } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
@@ -9,6 +11,50 @@ import { getQueueStatus } from '../lib/emailQueue.js';
const emailsRouter = new Hono();
const slugPattern = /^[a-z0-9-]+$/;
const createTemplateSchema = z.object({
name: z.string().min(1).max(255),
slug: z.string().min(1).max(100).regex(slugPattern),
subject: z.string().min(1).max(500),
subjectEs: z.string().max(500).optional().nullable(),
bodyHtml: z.string().min(1).max(200_000),
bodyHtmlEs: z.string().max(200_000).optional().nullable(),
bodyText: z.string().max(200_000).optional().nullable(),
bodyTextEs: z.string().max(200_000).optional().nullable(),
description: z.string().max(2000).optional().nullable(),
variables: z.array(z.any()).max(100).optional(),
});
const updateTemplateSchema = createTemplateSchema.partial().extend({
isActive: z.boolean().optional(),
});
const sendCustomEmailSchema = z.object({
to: z.string().email().max(254),
toName: z.string().max(200).optional(),
subject: z.string().min(1).max(500),
bodyHtml: z.string().min(1).max(200_000),
bodyText: z.string().max(200_000).optional(),
eventId: z.string().optional(),
});
const emailLogsQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).optional().default(50),
offset: z.coerce.number().int().min(0).optional().default(0),
});
// Safely parse a stored JSON variables column; a corrupt row must not 500 the route.
function safeParseVariables(raw: any): any[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
// ==================== Template Routes ====================
// Get all email templates
@@ -20,7 +66,7 @@ emailsRouter.get('/templates', requireAuth(['admin', 'organizer']), async (c) =>
// Parse variables JSON for each template
const parsedTemplates = templates.map((t: any) => ({
...t,
variables: t.variables ? JSON.parse(t.variables) : [],
variables: safeParseVariables(t.variables),
isSystem: Boolean(t.isSystem),
isActive: Boolean(t.isActive),
}));
@@ -46,7 +92,7 @@ emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c
return c.json({
template: {
...template,
variables: template.variables ? JSON.parse(template.variables) : [],
variables: safeParseVariables(template.variables),
isSystem: Boolean(template.isSystem),
isActive: Boolean(template.isActive),
}
@@ -54,14 +100,10 @@ emailsRouter.get('/templates/:id', requireAuth(['admin', 'organizer']), async (c
});
// Create new email template
emailsRouter.post('/templates', requireAuth(['admin']), async (c) => {
const body = await c.req.json();
emailsRouter.post('/templates', requireAuth(['admin']), zValidator('json', createTemplateSchema), async (c) => {
const body = c.req.valid('json');
const { name, slug, subject, subjectEs, bodyHtml, bodyHtmlEs, bodyText, bodyTextEs, description, variables } = body;
if (!name || !slug || !subject || !bodyHtml) {
return c.json({ error: 'Name, slug, subject, and bodyHtml are required' }, 400);
}
// Check if slug already exists
const existing = await dbGet<any>(
(db as any).select().from(emailTemplates).where(eq((emailTemplates as any).slug, slug))
@@ -104,9 +146,9 @@ emailsRouter.post('/templates', requireAuth(['admin']), async (c) => {
});
// Update email template
emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
emailsRouter.put('/templates/:id', requireAuth(['admin']), zValidator('json', updateTemplateSchema), async (c) => {
const { id } = c.req.param();
const body = await c.req.json();
const body = c.req.valid('json');
const existing = await dbGet<any>(
(db as any)
@@ -121,22 +163,22 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
const updateData: any = { updatedAt: getNow() };
// Only allow updating certain fields for system templates
const systemProtectedFields = ['slug', 'isSystem'];
// System templates cannot have their slug or isSystem flag changed; only the
// editable fields below are applied.
const allowedFields = ['name', 'subject', 'subjectEs', 'bodyHtml', 'bodyHtmlEs', 'bodyText', 'bodyTextEs', 'description', 'variables', 'isActive'];
if (!existing.isSystem) {
allowedFields.push('slug');
}
for (const field of allowedFields) {
if (body[field] !== undefined) {
const value = (body as Record<string, unknown>)[field];
if (value !== undefined) {
if (field === 'variables') {
updateData[field] = JSON.stringify(body[field]);
updateData[field] = JSON.stringify(value);
} else if (field === 'isActive') {
updateData[field] = body[field] ? 1 : 0;
updateData[field] = value ? 1 : 0;
} else {
updateData[field] = body[field];
updateData[field] = value;
}
}
}
@@ -156,7 +198,7 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), async (c) => {
return c.json({
template: {
...updated,
variables: updated.variables ? JSON.parse(updated.variables) : [],
variables: safeParseVariables(updated.variables),
isSystem: Boolean(updated.isSystem),
isActive: Boolean(updated.isActive),
},
@@ -203,16 +245,22 @@ emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), a
const body = await c.req.json();
const { templateSlug, customVariables, recipientFilter } = body;
if (!templateSlug) {
if (!templateSlug || typeof templateSlug !== 'string') {
return c.json({ error: 'Template slug is required' }, 400);
}
const allowedFilters = ['confirmed', 'pending', 'all', 'checked_in'];
const filter = recipientFilter || 'confirmed';
if (!allowedFilters.includes(filter)) {
return c.json({ error: `Invalid recipientFilter. Allowed: ${allowedFilters.join(', ')}` }, 400);
}
// Queue emails for background processing instead of sending synchronously
const result = await emailService.queueEventEmails({
eventId,
templateSlug,
customVariables,
recipientFilter: recipientFilter || 'confirmed',
recipientFilter: filter,
sentBy: user?.id,
});
@@ -220,14 +268,9 @@ emailsRouter.post('/send/event/:eventId', requireAuth(['admin', 'organizer']), a
});
// Send custom email to specific recipients
emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), async (c) => {
emailsRouter.post('/send/custom', requireAuth(['admin', 'organizer']), zValidator('json', sendCustomEmailSchema), async (c) => {
const user = (c as any).get('user');
const body = await c.req.json();
const { to, toName, subject, bodyHtml, bodyText, eventId } = body;
if (!to || !subject || !bodyHtml) {
return c.json({ error: 'Recipient (to), subject, and bodyHtml are required' }, 400);
}
const { to, toName, subject, bodyHtml, bodyText, eventId } = c.req.valid('json');
const result = await emailService.sendCustomEmail({
to,
@@ -272,7 +315,7 @@ emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) =>
: template.bodyHtml;
const finalSubject = replaceTemplateVariables(subject, allVariables);
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables);
const finalBodyContent = replaceTemplateVariables(bodyHtml, allVariables, true);
const finalBodyHtml = wrapInBaseTemplate(finalBodyContent, { ...allVariables, subject: finalSubject });
return c.json({
@@ -288,8 +331,9 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId');
const status = c.req.query('status');
const search = c.req.query('search');
const limit = parseInt(c.req.query('limit') || '50');
const offset = parseInt(c.req.query('offset') || '0');
// Clamp pagination so a NaN / out-of-range value can't produce undefined query behaviour.
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '50', 10) || 50, 1), 200);
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
let query = (db as any).select().from(emailLogs);
@@ -441,7 +485,7 @@ emailsRouter.post('/test', requireAuth(['admin']), async (c) => {
// Get email queue status
emailsRouter.get('/queue/status', requireAuth(['admin']), async (c) => {
const status = getQueueStatus();
const status = await getQueueStatus();
return c.json({ status });
});
+108 -68
View File
@@ -127,8 +127,13 @@ const baseEventSchema = z.object({
currency: z.string().default('PYG'),
capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50),
status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'),
// Accept relative paths (/uploads/...) or full URLs
bannerUrl: z.string().optional().nullable().or(z.literal('')),
// Accept relative paths (/uploads/...) or http(s) URLs only — reject schemes like
// javascript:/data: that could be reflected into an href/src on the frontend.
bannerUrl: z.string()
.refine((v) => v === '' || v.startsWith('/') || /^https?:\/\//i.test(v), {
message: 'Banner URL must be a relative path or an http(s) URL',
})
.optional().nullable().or(z.literal('')),
// External booking support - accept boolean or number (0/1 from DB)
externalBookingEnabled: z.union([z.boolean(), z.number()]).transform(normalizeBoolean).default(false),
externalBookingUrl: z.string().url().optional().nullable().or(z.literal('')),
@@ -166,51 +171,59 @@ const updateEventSchema = baseEventSchema.partial().refine(
eventsRouter.get('/', async (c) => {
const status = c.req.query('status');
const upcoming = c.req.query('upcoming');
let query = (db as any).select().from(events);
if (status) {
query = query.where(eq((events as any).status, status));
}
// Only privileged users may see non-public events (drafts, archived, etc.).
// Anonymous/regular callers are restricted to published events regardless of
// any client-supplied status filter, so drafts cannot leak.
const authUser: any = await getAuthUser(c);
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
const conditions: any[] = [];
if (upcoming === 'true') {
const now = getNow();
query = query.where(
and(
eq((events as any).status, 'published'),
gte((events as any).startDatetime, now)
)
);
// Upcoming feed is always published + future-dated, for everyone.
conditions.push(eq((events as any).status, 'published'));
conditions.push(gte((events as any).startDatetime, getNow()));
} else if (isPrivileged) {
// Admins/staff may filter by any status (or list everything when unset).
if (status) {
conditions.push(eq((events as any).status, status));
}
} else {
// Public listing: published events only, regardless of any status param.
conditions.push(eq((events as any).status, 'published'));
}
let query = (db as any).select().from(events);
if (conditions.length > 0) {
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
}
const result = await dbAll(query.orderBy(desc((events as any).startDatetime)));
// Get ticket counts for each event
const eventsWithCounts = await Promise.all(
result.map(async (event: any) => {
// 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;
return {
...normalized,
bookedCount,
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
};
})
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>();
for (const row of countRows) {
countByEvent.set(row.eventId, Number(row.count) || 0);
}
const eventsWithCounts = result.map((event: any) => {
const normalized = normalizeEvent(event);
const bookedCount = countByEvent.get(event.id) || 0;
return {
...normalized,
bookedCount,
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
};
});
return c.json({ events: eventsWithCounts });
});
@@ -223,6 +236,15 @@ eventsRouter.get('/:id', async (c) => {
if (!event) {
return c.json({ error: 'Event not found' }, 404);
}
// Draft events are only visible to privileged users (admin preview); hide from public.
if ((event as any).status === 'draft') {
const authUser: any = await getAuthUser(c);
const isPrivileged = !!authUser && ['admin', 'organizer', 'staff', 'marketing'].includes(authUser.role);
if (!isPrivileged) {
return c.json({ error: 'Event not found' }, 404);
}
}
// Count confirmed AND checked_in tickets (checked_in were previously confirmed)
// This ensures check-in doesn't affect capacity/spots_left
@@ -272,6 +294,45 @@ async function getEventTicketCount(eventId: string): Promise<number> {
return ticketCount?.count || 0;
}
// Get the earliest upcoming published event with ticket counts (ignores featured promotion)
async function getNextChronologicalUpcoming(): Promise<any | null> {
const now = getNow();
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(
and(
eq((events as any).status, 'published'),
gte((events as any).startDatetime, now)
)
)
.orderBy((events as any).startDatetime)
.limit(1)
);
if (!event) {
return null;
}
const bookedCount = await getEventTicketCount(event.id);
const normalized = normalizeEvent(event);
return {
...normalized,
bookedCount,
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
};
}
// Get next upcoming event (public) - earliest upcoming published event, ignores featured promotion
eventsRouter.get('/next', async (c) => {
const event = await getNextChronologicalUpcoming();
if (!event) {
return c.json({ event: null });
}
return c.json({ event: { ...event, isFeatured: false } });
});
// Get next upcoming event (public) - returns featured event if valid, otherwise next upcoming
eventsRouter.get('/next/upcoming', async (c) => {
const now = getNow();
@@ -335,34 +396,13 @@ eventsRouter.get('/next/upcoming', async (c) => {
}
// Fallback: get the next upcoming published event
const event = await dbGet<any>(
(db as any)
.select()
.from(events)
.where(
and(
eq((events as any).status, 'published'),
gte((events as any).startDatetime, now)
)
)
.orderBy((events as any).startDatetime)
.limit(1)
);
const event = await getNextChronologicalUpcoming();
if (!event) {
return c.json({ event: null });
}
const bookedCount = await getEventTicketCount(event.id);
const normalized = normalizeEvent(event);
return c.json({
event: {
...normalized,
bookedCount,
availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount),
isFeatured: false,
},
});
return c.json({ event: { ...event, isFeatured: false } });
});
// Create event (admin/organizer only)
+35
View File
@@ -6,6 +6,22 @@ import { getNow, generateId } from '../lib/utils.js';
const faqRouter = new Hono();
// Upper bounds for admin-supplied FAQ content (guards against accidental/abusive huge payloads)
const MAX_QUESTION_LEN = 1000;
const MAX_ANSWER_LEN = 20000;
// Returns an error message if any provided field exceeds its limit, else null.
function faqLengthError(fields: { question?: any; questionEs?: any; answer?: any; answerEs?: any }): string | null {
const check = (v: any, max: number, label: string) =>
typeof v === 'string' && v.length > max ? `${label} must be at most ${max} characters` : null;
return (
check(fields.question, MAX_QUESTION_LEN, 'Question') ||
check(fields.questionEs, MAX_QUESTION_LEN, 'Question (ES)') ||
check(fields.answer, MAX_ANSWER_LEN, 'Answer') ||
check(fields.answerEs, MAX_ANSWER_LEN, 'Answer (ES)')
);
}
// ==================== Public Routes ====================
// Get FAQ list for public (only enabled; optional filter for homepage)
@@ -98,6 +114,11 @@ faqRouter.post('/admin', requireAuth(['admin']), async (c) => {
return c.json({ error: 'Question and answer (EN) are required' }, 400);
}
const lengthError = faqLengthError({ question, questionEs, answer, answerEs });
if (lengthError) {
return c.json({ error: lengthError }, 400);
}
const now = getNow();
const id = generateId();
@@ -157,6 +178,11 @@ faqRouter.put('/admin/:id', requireAuth(['admin']), async (c) => {
return c.json({ error: 'FAQ not found' }, 404);
}
const lengthError = faqLengthError({ question, questionEs, answer, answerEs });
if (lengthError) {
return c.json({ error: lengthError }, 400);
}
const updateData: Record<string, unknown> = {
updatedAt: getNow(),
};
@@ -209,6 +235,15 @@ faqRouter.post('/admin/reorder', requireAuth(['admin']), async (c) => {
return c.json({ error: 'ids array is required' }, 400);
}
// Verify every id exists before applying ranks (prevents silent no-ops on bad input).
const existingRows = await dbAll<any>(
(db as any).select({ id: (faqQuestions as any).id }).from(faqQuestions)
);
const existingIds = new Set(existingRows.map((r: any) => r.id));
if (ids.some((id: string) => !existingIds.has(id))) {
return c.json({ error: 'One or more FAQ ids are invalid' }, 400);
}
const now = getNow();
for (let i = 0; i < ids.length; i++) {
await (db as any)
+18 -18
View File
@@ -9,24 +9,6 @@ import path from 'path';
const legalPagesRouter = new Hono();
// Helper: Convert plain text to simple markdown
// Preserves paragraphs and line breaks, nothing fancy
function textToMarkdown(text: string): string {
if (!text) return '';
// Split into paragraphs (double newlines)
const paragraphs = text.split(/\n\s*\n/);
// Process each paragraph
const processed = paragraphs.map(para => {
// Replace single newlines with double spaces + newline for markdown line breaks
return para.trim().replace(/\n/g, ' \n');
});
// Join paragraphs with double newlines
return processed.join('\n\n');
}
// Helper: Convert markdown to plain text for editing
function markdownToText(markdown: string): string {
if (!markdown) return '';
@@ -162,6 +144,13 @@ legalPagesRouter.get('/', async (c) => {
legalPagesRouter.get('/:slug', async (c) => {
const { slug } = c.req.param();
const locale = c.req.query('locale') || 'en';
// Reject anything that isn't a simple slug. The filesystem fallback below builds a
// path from this value, so an unconstrained slug (e.g. "../../etc/passwd") would
// allow path traversal / arbitrary file reads.
if (!/^[a-z0-9-]+$/.test(slug)) {
return c.json({ error: 'Legal page not found' }, 404);
}
// First try to get from database
const page = await dbGet<any>(
@@ -275,6 +264,17 @@ legalPagesRouter.put('/admin/:slug', requireAuth(['admin']), async (c) => {
if (!enContent && !esContent) {
return c.json({ error: 'At least one language content is required' }, 400);
}
// Bound the sizes of admin-supplied content to avoid unbounded payloads.
const MAX_CONTENT_LEN = 200000; // ~200 KB of markdown per language
const MAX_TITLE_LEN = 255;
const tooLong = (v: any, max: number) => typeof v === 'string' && v.length > max;
if (tooLong(enContent, MAX_CONTENT_LEN) || tooLong(esContent, MAX_CONTENT_LEN)) {
return c.json({ error: `Content must be at most ${MAX_CONTENT_LEN} characters` }, 400);
}
if (tooLong(title, MAX_TITLE_LEN) || tooLong(titleEs, MAX_TITLE_LEN)) {
return c.json({ error: `Title must be at most ${MAX_TITLE_LEN} characters` }, 400);
}
const existing = await dbGet(
(db as any)
+133 -45
View File
@@ -1,19 +1,30 @@
import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming';
import { db, dbGet, dbAll, tickets, payments } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { eq, and } from 'drizzle-orm';
import { getNow } from '../lib/utils.js';
import { verifyWebhookPayment, getPaymentStatus } from '../lib/lnbits.js';
import emailService from '../lib/email.js';
import { getPubSub } from '../lib/stores/pubsub.js';
import { getLock } from '../lib/stores/lock.js';
const lnbitsRouter = new Hono();
// Store for active SSE connections (ticketId -> Set of response writers)
const activeConnections = new Map<string, Set<(data: any) => void>>();
// Local SSE connections owned by THIS process (ticketId -> Set of response writers).
// Cross-instance delivery is handled by pub/sub: see paymentChannel below.
const activeConnections = new Map<string, Set<(data: any) => Promise<void>>>();
// Pub/sub unsubscribe handles per ticket (one local subscription per ticket).
const channelUnsubs = new Map<string, () => void>();
// Store for active background checkers (ticketId -> intervalId)
const activeCheckers = new Map<string, NodeJS.Timeout>();
/** Pub/sub channel that carries payment events for a ticket. */
function paymentChannel(ticketId: string): string {
return `payment:${ticketId}`;
}
/**
* LNbits webhook payload structure
*/
@@ -32,32 +43,71 @@ interface LNbitsWebhookPayload {
}
/**
* Notify all connected clients for a ticket
* Notify every client for a ticket across all instances.
*
* Publishes to the ticket's pub/sub channel. In single-instance / in-memory
* mode this is an in-process broadcast; with Redis it reaches whichever
* instance(s) actually hold the SSE socket(s) for this ticket.
*/
function notifyClients(ticketId: string, data: any) {
async function notifyClients(ticketId: string, data: any) {
await getPubSub().publish(paymentChannel(ticketId), data);
}
/**
* Deliver an event to the SSE sockets held by THIS process for a ticket.
* Invoked by the pub/sub subscription handler.
*/
async function deliverLocal(ticketId: string, data: any) {
const connections = activeConnections.get(ticketId);
if (connections) {
connections.forEach(send => {
try {
send(data);
} catch (e) {
// Connection might be closed
}
});
await Promise.all(
Array.from(connections).map(async (send) => {
try {
await send(data);
} catch (e) {
// Connection might be closed
}
})
);
}
}
// Distributed lock tokens for the per-ticket poller (ticketId -> token).
const checkerLockTokens = new Map<string, string>();
/** Release the per-ticket poller lock if this process holds it. */
function releaseCheckerLock(ticketId: string) {
const token = checkerLockTokens.get(ticketId);
if (token) {
checkerLockTokens.delete(ticketId);
void getLock().release(`checker:${ticketId}`, token);
}
}
/**
* Start background payment checking for a ticket
* Start background payment checking for a ticket.
*
* Only one instance should poll LNbits per ticket, so we take a distributed
* lock for the lifetime of the poll. Other instances skip polling and instead
* receive the result via pub/sub. With no Redis configured the lock is a local
* no-op and behavior matches the original single-instance polling.
*/
function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
// Don't start if already checking
async function startBackgroundChecker(ticketId: string, paymentHash: string, expirySeconds: number = 900) {
// Don't start if already checking on this instance
if (activeCheckers.has(ticketId)) {
return;
}
const startTime = Date.now();
const expiryMs = expirySeconds * 1000;
const lockToken = await getLock().acquire(`checker:${ticketId}`, expiryMs);
if (!lockToken) {
// Another instance is already polling this ticket.
return;
}
checkerLockTokens.set(ticketId, lockToken);
const startTime = Date.now();
let checkCount = 0;
console.log(`Starting background checker for ticket ${ticketId}, expires in ${expirySeconds}s`);
@@ -71,7 +121,8 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec
console.log(`Invoice expired for ticket ${ticketId}`);
clearInterval(checkInterval);
activeCheckers.delete(ticketId);
notifyClients(ticketId, { type: 'expired', ticketId });
releaseCheckerLock(ticketId);
await notifyClients(ticketId, { type: 'expired', ticketId });
return;
}
@@ -82,9 +133,10 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec
console.log(`Payment confirmed for ticket ${ticketId} (check #${checkCount})`);
clearInterval(checkInterval);
activeCheckers.delete(ticketId);
releaseCheckerLock(ticketId);
await handlePaymentComplete(ticketId, paymentHash);
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash });
}
} catch (error) {
console.error(`Error checking payment for ticket ${ticketId}:`, error);
@@ -102,6 +154,7 @@ function stopBackgroundChecker(ticketId: string) {
if (interval) {
clearInterval(interval);
activeCheckers.delete(ticketId);
releaseCheckerLock(ticketId);
}
}
@@ -111,13 +164,23 @@ function stopBackgroundChecker(ticketId: string) {
*/
lnbitsRouter.post('/webhook', async (c) => {
try {
// Optional shared-secret gate: if LNBITS_WEBHOOK_SECRET is configured, the
// webhook URL must carry a matching ?token=... (set when the invoice is created).
const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || '';
if (webhookSecret) {
const provided = c.req.query('token') || c.req.header('x-webhook-secret') || '';
if (provided !== webhookSecret) {
console.warn('LNbits webhook rejected: invalid or missing secret');
return c.json({ received: true, processed: false }, 401);
}
}
const payload: LNbitsWebhookPayload = await c.req.json();
// Log identifiers only (no full payload / PII)
console.log('LNbits webhook received:', {
paymentHash: payload.payment_hash,
status: payload.status,
amount: payload.amount,
extra: payload.extra,
});
// Verify the payment is actually complete by checking with LNbits
@@ -135,13 +198,27 @@ lnbitsRouter.post('/webhook', async (c) => {
return c.json({ received: true, processed: false }, 200);
}
// CRITICAL: bind the paid hash to this ticket's own invoice. Without this, a
// valid paid hash from any other invoice could be replayed with an arbitrary
// ticketId to confirm tickets for free.
const ticketPayment = await dbGet<any>(
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
);
if (!ticketPayment || ticketPayment.reference !== payload.payment_hash) {
console.warn('LNbits webhook rejected: payment hash does not match the ticket invoice', {
ticketId,
paymentHash: payload.payment_hash,
});
return c.json({ received: true, processed: false }, 200);
}
// Stop background checker since webhook confirmed payment
stopBackgroundChecker(ticketId);
await handlePaymentComplete(ticketId, payload.payment_hash);
// Notify connected clients via SSE
notifyClients(ticketId, { type: 'paid', ticketId, paymentHash: payload.payment_hash });
await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash: payload.payment_hash });
return c.json({ received: true, processed: true }, 200);
} catch (error) {
@@ -186,15 +263,13 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
console.log(`Multi-ticket booking detected: ${ticketsToConfirm.length} tickets to confirm`);
}
// Confirm all tickets in the booking
// Confirm all tickets in the booking (idempotent: only flip pending -> confirmed)
for (const ticket of ticketsToConfirm) {
// Update ticket status to confirmed
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.where(eq((tickets as any).id, ticket.id));
.where(and(eq((tickets as any).id, ticket.id), eq((tickets as any).status, 'pending')));
// Update payment status to paid
await (db as any)
.update(payments)
.set({
@@ -203,7 +278,7 @@ async function handlePaymentComplete(ticketId: string, paymentHash: string) {
paidAt: now,
updatedAt: now,
})
.where(eq((payments as any).ticketId, ticket.id));
.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})`);
}
@@ -242,38 +317,45 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
return c.json({ error: 'Ticket not found' }, 404);
}
// If already paid, return immediately
if (ticket.status === 'confirmed') {
return c.json({ type: 'already_paid', ticketId }, 200);
}
// Get payment to start background checker
const payment = await dbGet<any>(
(db as any).select().from(payments).where(eq((payments as any).ticketId, ticketId))
);
// Start background checker if not already running
if (payment?.reference && !activeCheckers.has(ticketId)) {
startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry
// Start background checker if not already running (only while still pending)
if (ticket.status !== 'confirmed' && payment?.reference && !activeCheckers.has(ticketId)) {
await startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry
}
// Prevent proxies/CDNs from buffering the event stream so events flush immediately.
c.header('Cache-Control', 'no-cache, no-transform');
c.header('X-Accel-Buffering', 'no');
return streamSSE(c, async (stream) => {
// Register this connection
if (!activeConnections.has(ticketId)) {
activeConnections.set(ticketId, new Set());
}
const sendEvent = (data: any) => {
stream.writeSSE({ data: JSON.stringify(data), event: 'payment' });
const sendEvent = async (data: any) => {
await stream.writeSSE({ data: JSON.stringify(data), event: 'payment' });
};
// If already paid, notify over SSE and close (EventSource can parse this).
if (ticket.status === 'confirmed') {
await sendEvent({ type: 'already_paid', ticketId });
return;
}
// Register this connection. The first local connection for a ticket also
// subscribes to the ticket's pub/sub channel so events published by any
// instance (webhook or background checker) are delivered to these sockets.
if (!activeConnections.has(ticketId)) {
activeConnections.set(ticketId, new Set());
const unsub = await getPubSub().subscribe(paymentChannel(ticketId), (data) => {
void deliverLocal(ticketId, data);
});
channelUnsubs.set(ticketId, unsub);
}
activeConnections.get(ticketId)!.add(sendEvent);
// Send initial status
await stream.writeSSE({
data: JSON.stringify({ type: 'connected', ticketId }),
event: 'payment'
});
await sendEvent({ type: 'connected', ticketId });
// Keep connection alive with heartbeat
const heartbeat = setInterval(async () => {
@@ -292,6 +374,12 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => {
connections.delete(sendEvent);
if (connections.size === 0) {
activeConnections.delete(ticketId);
// Drop the pub/sub subscription once no local sockets remain.
const unsub = channelUnsubs.get(ticketId);
if (unsub) {
unsub();
channelUnsubs.delete(ticketId);
}
}
}
});
+66 -39
View File
@@ -1,24 +1,51 @@
import { Hono } from 'hono';
import { db, dbGet, dbAll, media } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { eq, and } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { generateId, getNow } from '../lib/utils.js';
import { writeFile, mkdir, unlink } from 'fs/promises';
import { existsSync } from 'fs';
import { join, extname } from 'path';
import { getStorage, keyFromUrl } from '../lib/storage.js';
const mediaRouter = new Hono();
const UPLOAD_DIR = './uploads';
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif'];
const MAX_FILE_SIZE =
(Number(process.env.MEDIA_MAX_UPLOAD_MB || '10') || 10) * 1024 * 1024; // default 10MB
// Ensure upload directory exists
async function ensureUploadDir() {
if (!existsSync(UPLOAD_DIR)) {
await mkdir(UPLOAD_DIR, { recursive: true });
/**
* Detect a real image type from the file's magic bytes (content sniffing).
* Returns the canonical mime + extension, or null if the content is not an
* allowed image. We deliberately ignore the client-supplied filename and
* Content-Type so an attacker cannot store e.g. an .html/.svg payload.
*/
function detectImageType(buf: Buffer): { mime: string; ext: string } | null {
if (buf.length < 12) return null;
// JPEG: FF D8 FF
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
return { mime: 'image/jpeg', ext: '.jpg' };
}
// PNG: 89 50 4E 47 0D 0A 1A 0A
if (
buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 &&
buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a
) {
return { mime: 'image/png', ext: '.png' };
}
// GIF: "GIF87a" / "GIF89a"
if (buf.toString('ascii', 0, 6) === 'GIF87a' || buf.toString('ascii', 0, 6) === 'GIF89a') {
return { mime: 'image/gif', ext: '.gif' };
}
// WEBP: "RIFF"...."WEBP"
if (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') {
return { mime: 'image/webp', ext: '.webp' };
}
// AVIF / HEIF: "....ftyp" with an avif/heic brand
if (buf.toString('ascii', 4, 8) === 'ftyp') {
const brand = buf.toString('ascii', 8, 12);
if (brand === 'avif' || brand === 'avis') {
return { mime: 'image/avif', ext: '.avif' };
}
}
return null;
}
// Upload image
@@ -31,28 +58,28 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
return c.json({ error: 'No file provided' }, 400);
}
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
return c.json({ error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, AVIF' }, 400);
}
// Validate file size
// Validate file size (cheap check before reading the whole buffer)
if (file.size > MAX_FILE_SIZE) {
const mb = Math.round((MAX_FILE_SIZE / (1024 * 1024)) * 10) / 10;
return c.json({ error: `File too large. Maximum size: ${mb}MB` }, 400);
}
await ensureUploadDir();
// Generate unique filename
const id = generateId();
const ext = extname(file.name) || '.jpg';
const filename = `${id}${ext}`;
const filepath = join(UPLOAD_DIR, filename);
// Write file
// Read the bytes and validate the *content* (not the client-provided type/name)
const arrayBuffer = await file.arrayBuffer();
await writeFile(filepath, Buffer.from(arrayBuffer));
const buffer = Buffer.from(arrayBuffer);
const detected = detectImageType(buffer);
if (!detected) {
return c.json({ error: 'Invalid file. Allowed: JPEG, PNG, GIF, WebP, AVIF' }, 400);
}
// Generate unique filename using the *detected* extension (ignore client filename)
const id = generateId();
const filename = `${id}${detected.ext}`;
// Persist via the storage backend (local disk or S3-compatible object store).
const storage = getStorage();
await storage.put(filename, buffer, detected.mime);
// Get related info from form data
const relatedId = body['relatedId'] as string | undefined;
@@ -62,7 +89,7 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => {
const now = getNow();
const mediaRecord = {
id,
fileUrl: `/uploads/${filename}`,
fileUrl: storage.publicUrl(filename),
type: 'image' as const,
relatedId: relatedId || null,
relatedType: relatedType || null,
@@ -108,12 +135,9 @@ mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => {
return c.json({ error: 'Media not found' }, 404);
}
// Delete file from disk
// Delete the underlying object from the storage backend.
try {
const filepath = join('.', mediaRecord.fileUrl);
if (existsSync(filepath)) {
await unlink(filepath);
}
await getStorage().delete(keyFromUrl(mediaRecord.fileUrl));
} catch (error) {
console.error('Failed to delete file:', error);
}
@@ -128,17 +152,20 @@ mediaRouter.delete('/:id', requireAuth(['admin', 'organizer']), async (c) => {
mediaRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
const relatedType = c.req.query('relatedType');
const relatedId = c.req.query('relatedId');
const limit = Math.min(Math.max(parseInt(c.req.query('limit') || '200', 10) || 200, 1), 500);
const offset = Math.max(parseInt(c.req.query('offset') || '0', 10) || 0, 0);
// Combine filters into a single where() — chaining .where() replaces the prior condition in Drizzle.
const conditions: any[] = [];
if (relatedType) conditions.push(eq((media as any).relatedType, relatedType));
if (relatedId) conditions.push(eq((media as any).relatedId, relatedId));
let query = (db as any).select().from(media);
if (relatedType) {
query = query.where(eq((media as any).relatedType, relatedType));
}
if (relatedId) {
query = query.where(eq((media as any).relatedId, relatedId));
if (conditions.length > 0) {
query = query.where(conditions.length === 1 ? conditions[0] : and(...conditions));
}
const result = await dbAll(query);
const result = await dbAll(query.limit(limit).offset(offset));
return c.json({ media: result });
});
+57 -3
View File
@@ -1,9 +1,9 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, paymentOptions, eventPaymentOverrides, events } from '../db/index.js';
import { db, dbGet, paymentOptions, eventPaymentOverrides, events, tickets } from '../db/index.js';
import { eq } from 'drizzle-orm';
import { requireAuth } from '../lib/auth.js';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, getNow, convertBooleansForDb } from '../lib/utils.js';
const paymentOptionsRouter = new Hono();
@@ -18,6 +18,10 @@ const booleanOrNumber = z.union([z.boolean(), z.number()]).transform((val) => {
const updatePaymentOptionsSchema = z.object({
tpagoEnabled: booleanOrNumber.optional(),
tpagoLink: z.string().optional().nullable(),
tpagoLink2: z.string().optional().nullable(),
tpagoLink3: z.string().optional().nullable(),
tpagoLink4: z.string().optional().nullable(),
tpagoLink5: z.string().optional().nullable(),
tpagoInstructions: z.string().optional().nullable(),
tpagoInstructionsEs: z.string().optional().nullable(),
bankTransferEnabled: booleanOrNumber.optional(),
@@ -36,10 +40,31 @@ const updatePaymentOptionsSchema = z.object({
allowDuplicateBookings: booleanOrNumber.optional(),
});
/** Strip bank account numbers from payment options for anonymous callers. */
function publicPaymentOptions(merged: Record<string, any>) {
return {
...merged,
bankName: null,
bankAccountHolder: null,
bankAccountNumber: null,
bankAlias: null,
bankPhone: null,
tpagoLink: null,
tpagoLink2: null,
tpagoLink3: null,
tpagoLink4: null,
tpagoLink5: null,
};
}
// Schema for event-level overrides
const updateEventOverridesSchema = z.object({
tpagoEnabled: booleanOrNumber.optional().nullable(),
tpagoLink: z.string().optional().nullable(),
tpagoLink2: z.string().optional().nullable(),
tpagoLink3: z.string().optional().nullable(),
tpagoLink4: z.string().optional().nullable(),
tpagoLink5: z.string().optional().nullable(),
tpagoInstructions: z.string().optional().nullable(),
tpagoInstructionsEs: z.string().optional().nullable(),
bankTransferEnabled: booleanOrNumber.optional().nullable(),
@@ -68,6 +93,10 @@ paymentOptionsRouter.get('/', requireAuth(['admin']), async (c) => {
paymentOptions: {
tpagoEnabled: false,
tpagoLink: null,
tpagoLink2: null,
tpagoLink3: null,
tpagoLink4: null,
tpagoLink5: null,
tpagoInstructions: null,
tpagoInstructionsEs: null,
bankTransferEnabled: false,
@@ -139,6 +168,7 @@ paymentOptionsRouter.put('/', requireAuth(['admin']), zValidator('json', updateP
// Get payment options for a specific event (merged with global)
paymentOptionsRouter.get('/event/:eventId', async (c) => {
const eventId = c.req.param('eventId');
const ticketId = c.req.query('ticketId');
// Get the event first to verify it exists
const event = await dbGet(
@@ -171,6 +201,10 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
const defaults = {
tpagoEnabled: false,
tpagoLink: null,
tpagoLink2: null,
tpagoLink3: null,
tpagoLink4: null,
tpagoLink5: null,
tpagoInstructions: null,
tpagoInstructionsEs: null,
bankTransferEnabled: false,
@@ -193,6 +227,10 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
const merged = {
tpagoEnabled: overrides?.tpagoEnabled ?? global.tpagoEnabled,
tpagoLink: overrides?.tpagoLink ?? global.tpagoLink,
tpagoLink2: overrides?.tpagoLink2 ?? global.tpagoLink2,
tpagoLink3: overrides?.tpagoLink3 ?? global.tpagoLink3,
tpagoLink4: overrides?.tpagoLink4 ?? global.tpagoLink4,
tpagoLink5: overrides?.tpagoLink5 ?? global.tpagoLink5,
tpagoInstructions: overrides?.tpagoInstructions ?? global.tpagoInstructions,
tpagoInstructionsEs: overrides?.tpagoInstructionsEs ?? global.tpagoInstructionsEs,
bankTransferEnabled: overrides?.bankTransferEnabled ?? global.bankTransferEnabled,
@@ -209,8 +247,24 @@ paymentOptionsRouter.get('/event/:eventId', async (c) => {
cashInstructionsEs: overrides?.cashInstructionsEs ?? global.cashInstructionsEs,
};
// Full bank/TPago credentials are only returned when the caller proves they hold
// a valid ticket for this event (the ticket UUID is the booking capability token),
// or when an authenticated admin/organizer requests them.
let revealSensitive = false;
const authUser: any = await getAuthUser(c);
if (authUser && ['admin', 'organizer'].includes(authUser.role)) {
revealSensitive = true;
} else if (ticketId) {
const ticket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, ticketId))
);
if (ticket && ticket.eventId === eventId && ticket.status !== 'cancelled') {
revealSensitive = true;
}
}
return c.json({
paymentOptions: merged,
paymentOptions: revealSensitive ? merged : publicPaymentOptions(merged),
hasOverrides: !!overrides,
});
});
+87 -64
View File
@@ -162,6 +162,29 @@ paymentsRouter.get('/pending-approval', requireAuth(['admin', 'organizer']), asy
return c.json({ payments: enrichedPayments });
});
// Get payment statistics (admin) — registered before /:id so "stats" is not parsed as an id
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const [totalRow, pendingRow, paidRow, refundedRow, failedRow, revenueRow] = await Promise.all([
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments)),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'pending'))),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'paid'))),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'refunded'))),
dbGet<any>((db as any).select({ count: sql<number>`count(*)` }).from(payments).where(eq((payments as any).status, 'failed'))),
dbGet<any>((db as any).select({ total: sql<number>`COALESCE(SUM(${(payments as any).amount}), 0)` }).from(payments).where(eq((payments as any).status, 'paid'))),
]);
return c.json({
stats: {
total: Number(totalRow?.count || 0),
pending: Number(pendingRow?.count || 0),
paid: Number(paidRow?.count || 0),
refunded: Number(refundedRow?.count || 0),
failed: Number(failedRow?.count || 0),
totalRevenue: Number(revenueRow?.total || 0),
},
});
});
// Get payment by ID (admin)
paymentsRouter.get('/:id', requireAuth(['admin', 'organizer']), async (c) => {
const id = c.req.param('id');
@@ -387,26 +410,40 @@ paymentsRouter.post('/:id/reject', requireAuth(['admin', 'organizer']), zValidat
}
const now = getNow();
// Update payment status to failed
await (db as any)
.update(payments)
.set({
status: 'failed',
paidByAdminId: user.id,
adminNote: adminNote || payment.adminNote,
updatedAt: now,
})
.where(eq((payments as any).id, id));
// Cancel the ticket - booking is no longer valid after rejection
await (db as any)
.update(tickets)
.set({
status: 'cancelled',
updatedAt: now,
})
.where(eq((tickets as any).id, payment.ticketId));
// Determine all tickets in this booking (multi-ticket bookings must be rejected together)
const rejectTicket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
);
let ticketsToReject: any[] = rejectTicket ? [rejectTicket] : [];
if (rejectTicket?.bookingId) {
ticketsToReject = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, rejectTicket.bookingId))
);
console.log(`[Payment] Rejecting multi-ticket booking: ${rejectTicket.bookingId}, ${ticketsToReject.length} tickets`);
}
for (const t of ticketsToReject) {
// Fail the payment for each ticket in the booking
await (db as any)
.update(payments)
.set({
status: 'failed',
paidByAdminId: user.id,
adminNote: adminNote || payment.adminNote,
updatedAt: now,
})
.where(eq((payments as any).ticketId, (t as any).id));
// Cancel the ticket - booking is no longer valid after rejection
await (db as any)
.update(tickets)
.set({
status: 'cancelled',
updatedAt: now,
})
.where(eq((tickets as any).id, (t as any).id));
}
// Send rejection email asynchronously (for manual payment methods only, if sendEmail is true)
if (sendEmail !== false && ['bank_transfer', 'tpago'].includes(payment.provider)) {
@@ -529,56 +566,42 @@ paymentsRouter.post('/:id/refund', requireAuth(['admin']), async (c) => {
}
const now = getNow();
// Update payment status
await (db as any)
.update(payments)
.set({ status: 'refunded', updatedAt: now })
.where(eq((payments as any).id, id));
// Cancel associated ticket
await (db as any)
.update(tickets)
.set({ status: 'cancelled' })
.where(eq((tickets as any).id, payment.ticketId));
// Refund all tickets/payments in the booking (multi-ticket bookings refund together)
const refundTicket = await dbGet<any>(
(db as any).select().from(tickets).where(eq((tickets as any).id, payment.ticketId))
);
let ticketsToRefund: any[] = refundTicket ? [refundTicket] : [];
if (refundTicket?.bookingId) {
ticketsToRefund = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, refundTicket.bookingId))
);
console.log(`[Payment] Refunding multi-ticket booking: ${refundTicket.bookingId}, ${ticketsToRefund.length} tickets`);
}
for (const t of ticketsToRefund) {
// Only refund payments that were actually paid; leave others untouched
await (db as any)
.update(payments)
.set({ status: 'refunded', updatedAt: now })
.where(and(eq((payments as any).ticketId, (t as any).id), eq((payments as any).status, 'paid')));
await (db as any)
.update(tickets)
.set({ status: 'cancelled' })
.where(eq((tickets as any).id, (t as any).id));
}
return c.json({ message: 'Refund processed successfully' });
});
// Payment webhook (for Stripe/MercadoPago)
// Not implemented: there is deliberately NO status mutation here. Until provider
// signature verification is implemented, accepting webhooks would let anyone forge
// a "paid" status. Returns 501 and never updates payments/tickets.
paymentsRouter.post('/webhook', async (c) => {
// This would handle webhook notifications from payment providers
// Implementation depends on which provider is used
const body = await c.req.json();
// Log webhook for debugging
console.log('Payment webhook received:', body);
// TODO: Implement provider-specific webhook handling
// - Verify webhook signature
// - Update payment status
// - Update ticket status
return c.json({ received: true });
});
// Get payment statistics (admin)
paymentsRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const allPayments = await dbAll<any>((db as any).select().from(payments));
const stats = {
total: allPayments.length,
pending: allPayments.filter((p: any) => p.status === 'pending').length,
paid: allPayments.filter((p: any) => p.status === 'paid').length,
refunded: allPayments.filter((p: any) => p.status === 'refunded').length,
failed: allPayments.filter((p: any) => p.status === 'failed').length,
totalRevenue: allPayments
.filter((p: any) => p.status === 'paid')
.reduce((sum: number, p: any) => sum + Number(p.amount || 0), 0),
};
return c.json({ stats });
console.warn('Payment webhook received but provider webhooks are not implemented (no signature verification).');
return c.json({ error: 'Webhook handling is not implemented' }, 501);
});
export default paymentsRouter;
+13 -2
View File
@@ -17,9 +17,20 @@ interface UserContext {
const siteSettingsRouter = new Hono<{ Variables: { user: UserContext } }>();
// Validation schema for updating site settings
// Validate against the runtime's IANA timezone database (rejects arbitrary strings
// that later get fed into Intl.DateTimeFormat on the frontend).
const isValidTimezone = (tz: string): boolean => {
try {
Intl.DateTimeFormat('en-US', { timeZone: tz });
return true;
} catch {
return false;
}
};
const updateSiteSettingsSchema = z.object({
timezone: z.string().optional(),
siteName: z.string().optional(),
timezone: z.string().refine(isValidTimezone, { message: 'Invalid timezone' }).optional(),
siteName: z.string().max(255).optional(),
siteDescription: z.string().optional().nullable(),
siteDescriptionEs: z.string().optional().nullable(),
contactEmail: z.string().email().optional().nullable().or(z.literal('')),
+277 -83
View File
@@ -1,11 +1,12 @@
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js';
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, eventPaymentOverrides, siteSettings, isSqlite } from '../db/index.js';
import { eq, and, or, sql, inArray } from 'drizzle-orm';
import { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
import { rateLimitMiddleware } from '../lib/rateLimit.js';
import emailService from '../lib/email.js';
import { generateTicketPDF, generateCombinedTicketsPDF } from '../lib/pdf.js';
@@ -17,6 +18,9 @@ const attendeeSchema = z.object({
lastName: z.string().min(2).optional().or(z.literal('')),
});
// Maximum tickets a single buyer can book at once (enforced server-side)
const MAX_TICKETS_PER_BOOKING = 5;
const createTicketSchema = z.object({
eventId: z.string(),
firstName: z.string().min(2),
@@ -24,12 +28,30 @@ const createTicketSchema = z.object({
email: z.string().email(),
phone: z.string().min(6).optional().or(z.literal('')),
preferredLanguage: z.enum(['en', 'es']).optional(),
paymentMethod: z.enum(['bancard', 'lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
// 'bancard' intentionally excluded: no checkout integration exists for it
paymentMethod: z.enum(['lightning', 'cash', 'bank_transfer', 'tpago']).default('cash'),
ruc: z.string().regex(/^\d{6,10}$/, 'Invalid RUC format').optional().or(z.literal('')),
// Optional: array of attendees for multi-ticket booking
attendees: z.array(attendeeSchema).optional(),
// Optional: array of attendees for multi-ticket booking (capped at MAX_TICKETS_PER_BOOKING)
attendees: z.array(attendeeSchema).min(1).max(MAX_TICKETS_PER_BOOKING).optional(),
});
// Maps a payment provider to the merged payment-option flag that enables it
function isPaymentMethodEnabled(method: string, merged: Record<string, any>): boolean {
const truthy = (v: any) => v === true || v === 1;
switch (method) {
case 'tpago':
return truthy(merged.tpagoEnabled);
case 'bank_transfer':
return truthy(merged.bankTransferEnabled);
case 'lightning':
return truthy(merged.lightningEnabled);
case 'cash':
return truthy(merged.cashEnabled);
default:
return false;
}
}
const updateTicketSchema = z.object({
status: z.enum(['pending', 'confirmed', 'cancelled', 'checked_in']).optional(),
adminNote: z.string().optional(),
@@ -60,6 +82,11 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
: [{ firstName: data.firstName, lastName: data.lastName }];
const ticketCount = attendeesList.length;
// Enforce the per-booking ticket cap server-side (UI also caps, but the API is authoritative)
if (ticketCount < 1 || ticketCount > MAX_TICKETS_PER_BOOKING) {
return c.json({ error: `You can book between 1 and ${MAX_TICKETS_PER_BOOKING} tickets per order.` }, 400);
}
// Get event
const event = await dbGet<any>(
@@ -72,9 +99,28 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
if (!['published', 'unlisted'].includes(event.status)) {
return c.json({ error: 'Event is not available for booking' }, 400);
}
// Validate the requested payment method is actually enabled for this event
// (merge global options with any event-level overrides; override wins when not null)
const globalPaymentOptions = await dbGet<any>(
(db as any).select().from(paymentOptions)
);
const eventOverrides = await dbGet<any>(
(db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, data.eventId))
);
const mergedPaymentOptions: Record<string, any> = {
tpagoEnabled: eventOverrides?.tpagoEnabled ?? globalPaymentOptions?.tpagoEnabled ?? false,
bankTransferEnabled: eventOverrides?.bankTransferEnabled ?? globalPaymentOptions?.bankTransferEnabled ?? false,
lightningEnabled: eventOverrides?.lightningEnabled ?? globalPaymentOptions?.lightningEnabled ?? true,
cashEnabled: eventOverrides?.cashEnabled ?? globalPaymentOptions?.cashEnabled ?? true,
};
if (!isPaymentMethodEnabled(data.paymentMethod, mergedPaymentOptions)) {
return c.json({ error: 'Selected payment method is not available for this event' }, 400);
}
// Check capacity - count confirmed AND checked_in tickets
// (checked_in were previously confirmed, check-in doesn't affect capacity)
// 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).
const existingTicketCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
@@ -82,7 +128,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
.where(
and(
eq((tickets as any).eventId, data.eventId),
sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`
sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')`
)
)
);
@@ -128,13 +174,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
}
// Check for duplicate booking (unless allowDuplicateBookings is enabled)
const globalOptions = await dbGet<any>(
(db as any)
.select()
.from(paymentOptions)
);
const allowDuplicateBookings = globalOptions?.allowDuplicateBookings ?? false;
const allowDuplicateBookings = globalPaymentOptions?.allowDuplicateBookings ?? false;
if (!allowDuplicateBookings) {
const existingTicket = await dbGet<any>(
@@ -156,52 +196,151 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
// Generate booking ID to group multiple tickets
const bookingId = generateId();
// Create tickets for each attendee
const createdTickets: any[] = [];
const createdPayments: any[] = [];
for (let i = 0; i < attendeesList.length; i++) {
const attendee = attendeesList[i];
const ticketId = generateId();
const qrCode = generateTicketCode();
const newTicket = {
id: ticketId,
bookingId: ticketCount > 1 ? bookingId : null, // Only set bookingId for multi-ticket bookings
userId: user.id,
eventId: data.eventId,
attendeeFirstName: attendee.firstName,
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
attendeeEmail: data.email, // Buyer's email for all tickets
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
attendeeRuc: data.ruc || null,
preferredLanguage: data.preferredLanguage || null,
status: 'pending',
qrCode,
checkinAt: null,
createdAt: now,
};
await (db as any).insert(tickets).values(newTicket);
createdTickets.push(newTicket);
// Create payment record for each ticket
const paymentId = generateId();
const newPayment = {
id: paymentId,
ticketId,
provider: data.paymentMethod,
amount: event.price,
currency: event.currency,
status: 'pending',
reference: null,
createdAt: now,
updatedAt: now,
};
await (db as any).insert(payments).values(newPayment);
createdPayments.push(newPayment);
// Atomically re-check capacity and insert tickets/payments inside a transaction
// so concurrent bookings cannot oversell the same seats (TOCTOU race).
class BookingCapacityError extends Error {
constructor(public code: 'SOLD_OUT' | 'NOT_ENOUGH', public available?: number) {
super(code);
}
}
let createdTickets: any[] = [];
let createdPayments: any[] = [];
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 reserved = Number(countRow?.count || 0);
if (isEventSoldOut(event.capacity, reserved)) {
throw new BookingCapacityError('SOLD_OUT');
}
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
if (ticketCount > seatsLeft) {
throw new BookingCapacityError('NOT_ENOUGH', seatsLeft);
}
for (let i = 0; i < attendeesList.length; i++) {
const attendee = attendeesList[i];
const ticketId = generateId();
const qrCode = generateTicketCode();
const newTicket = {
id: ticketId,
bookingId: ticketCount > 1 ? bookingId : null,
userId: user.id,
eventId: data.eventId,
attendeeFirstName: attendee.firstName,
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
attendeeEmail: data.email,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
attendeeRuc: data.ruc || null,
preferredLanguage: data.preferredLanguage || null,
status: 'pending',
qrCode,
checkinAt: null,
createdAt: now,
};
tx.insert(tickets).values(newTicket).run();
createdTickets.push(newTicket);
const paymentId = generateId();
const newPayment = {
id: paymentId,
ticketId,
provider: data.paymentMethod,
amount: event.price,
currency: event.currency,
status: 'pending',
reference: null,
createdAt: now,
updatedAt: now,
};
tx.insert(payments).values(newPayment).run();
createdPayments.push(newPayment);
}
});
} 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 reserved = Number(countRow?.count || 0);
if (isEventSoldOut(event.capacity, reserved)) {
throw new BookingCapacityError('SOLD_OUT');
}
const seatsLeft = calculateAvailableSeats(event.capacity, reserved);
if (ticketCount > seatsLeft) {
throw new BookingCapacityError('NOT_ENOUGH', seatsLeft);
}
for (let i = 0; i < attendeesList.length; i++) {
const attendee = attendeesList[i];
const ticketId = generateId();
const qrCode = generateTicketCode();
const newTicket = {
id: ticketId,
bookingId: ticketCount > 1 ? bookingId : null,
userId: user.id,
eventId: data.eventId,
attendeeFirstName: attendee.firstName,
attendeeLastName: attendee.lastName && attendee.lastName.trim() ? attendee.lastName.trim() : null,
attendeeEmail: data.email,
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
attendeeRuc: data.ruc || null,
preferredLanguage: data.preferredLanguage || null,
status: 'pending',
qrCode,
checkinAt: null,
createdAt: now,
};
await tx.insert(tickets).values(newTicket);
createdTickets.push(newTicket);
const paymentId = generateId();
const newPayment = {
id: paymentId,
ticketId,
provider: data.paymentMethod,
amount: event.price,
currency: event.currency,
status: 'pending',
reference: null,
createdAt: now,
updatedAt: now,
};
await tx.insert(payments).values(newPayment);
createdPayments.push(newPayment);
}
});
}
} catch (err: any) {
if (err instanceof BookingCapacityError) {
if (err.code === 'SOLD_OUT') {
return c.json({ error: 'Event is sold out' }, 400);
}
return c.json({
error: `Not enough seats available. Only ${err.available} spot(s) remaining.`,
}, 400);
}
throw err;
}
const primaryTicket = createdTickets[0];
@@ -221,11 +360,26 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
});
}
// If Lightning payment, create LNbits invoice
// If Lightning payment, create LNbits invoice (skip for free events — confirm immediately)
let lnbitsInvoice = null;
const totalPrice = event.price * ticketCount;
if (data.paymentMethod === 'lightning' && totalPrice > 0) {
// Free events: no payment step required — confirm tickets immediately
if (totalPrice === 0) {
for (const t of createdTickets) {
await (db as any)
.update(tickets)
.set({ status: 'confirmed' })
.where(and(eq((tickets as any).id, t.id), eq((tickets as any).status, 'pending')));
await (db as any)
.update(payments)
.set({ status: 'paid', paidAt: now, updatedAt: now })
.where(and(eq((payments as any).ticketId, t.id), eq((payments as any).status, 'pending')));
}
emailService.sendBookingConfirmation(primaryTicket.id).catch(err => {
console.error('[Email] Failed to send free-booking confirmation:', err);
});
} else if (data.paymentMethod === 'lightning') {
if (!isLNbitsConfigured()) {
// Delete the tickets and payments we just created
for (const payment of createdPayments) {
@@ -241,6 +395,11 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
try {
const apiUrl = process.env.API_URL || 'http://localhost:3001';
// Include the webhook secret (if configured) so the callback can be authenticated
const webhookSecret = process.env.LNBITS_WEBHOOK_SECRET || '';
const webhookUrl = webhookSecret
? `${apiUrl}/api/lnbits/webhook?token=${encodeURIComponent(webhookSecret)}`
: `${apiUrl}/api/lnbits/webhook`;
// Pass the fiat currency directly to LNbits - it handles conversion automatically
// For multi-ticket, use total price
@@ -248,7 +407,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
amount: totalPrice,
unit: event.currency, // LNbits supports fiat currencies like USD, PYG, etc.
memo: `Spanglish: ${event.title} - ${fullName}${ticketCount > 1 ? ` (${ticketCount} tickets)` : ''}`,
webhookUrl: `${apiUrl}/api/lnbits/webhook`,
webhookUrl,
expiry: 900, // 15 minutes expiry for faster UX
extra: {
ticketId: primaryTicket.id,
@@ -610,6 +769,9 @@ ticketsRouter.get('/search', requireAuth(['admin', 'organizer', 'staff']), async
});
// Get ticket by ID
// Capability-based access: the unguessable ticket UUID acts as the access token for
// guest bookings (no account required). For anonymous callers we withhold attendee PII
// (email/phone/RUC); the full record is only returned to the owner or admin/staff.
ticketsRouter.get('/:id', async (c) => {
const id = c.req.param('id');
@@ -631,13 +793,30 @@ ticketsRouter.get('/:id', async (c) => {
(db as any).select().from(payments).where(eq((payments as any).ticketId, id))
);
return c.json({
ticket: {
...ticket,
event,
payment,
},
});
// Count how many tickets belong to this booking (for per-quantity payment links)
let bookingTicketCount = 1;
if (ticket.bookingId) {
const bookingTickets = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
);
bookingTicketCount = bookingTickets.length || 1;
}
// Determine whether the requester is the owner or an admin/staff member
const authUser: any = await getAuthUser(c);
const isPrivileged = !!authUser && (
['admin', 'organizer', 'staff'].includes(authUser.role) || authUser.id === ticket.userId
);
const ticketPayload: any = { ...ticket, event, payment, bookingTicketCount };
if (!isPrivileged) {
// Strip attendee PII for anonymous capability-based access
delete ticketPayload.attendeeEmail;
delete ticketPayload.attendeePhone;
delete ticketPayload.attendeeRuc;
}
return c.json({ ticket: ticketPayload });
});
// Update ticket status (admin/organizer)
@@ -975,7 +1154,7 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
// User marks payment as sent (for manual payment methods: bank_transfer, tpago)
// This sets status to "pending_approval" and notifies admin
ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
ticketsRouter.post('/:id/mark-payment-sent', rateLimitMiddleware({ max: 10, windowMs: 10 * 60 * 1000, prefix: 'mark-payment-sent' }), async (c) => {
const id = c.req.param('id');
const body = await c.req.json().catch(() => ({}));
const { payerName } = body;
@@ -1029,18 +1208,33 @@ ticketsRouter.post('/:id/mark-payment-sent', async (c) => {
const now = getNow();
// Update payment status to pending_approval
await (db as any)
.update(payments)
.set({
status: 'pending_approval',
userMarkedPaidAt: now,
payerName: payerName?.trim() || null,
updatedAt: now,
})
.where(eq((payments as any).id, payment.id));
// Update payment status to pending_approval for this ticket and any siblings
// in a multi-ticket booking (mirrors the approve flow's bookingId fan-out).
let ticketsToMark: any[] = [ticket];
if (ticket.bookingId) {
ticketsToMark = await dbAll<any>(
(db as any).select().from(tickets).where(eq((tickets as any).bookingId, ticket.bookingId))
);
}
for (const t of ticketsToMark) {
await (db as any)
.update(payments)
.set({
status: 'pending_approval',
userMarkedPaidAt: now,
payerName: payerName?.trim() || null,
updatedAt: now,
})
.where(
and(
eq((payments as any).ticketId, (t as any).id),
eq((payments as any).status, 'pending')
)
);
}
// Get updated payment
// Get updated payment for the requested ticket
const updatedPayment = await dbGet(
(db as any)
.select()
+23 -23
View File
@@ -50,6 +50,29 @@ usersRouter.get('/', requireAuth(['admin']), async (c) => {
return c.json({ users: result });
});
// Get user statistics (admin) — registered before /:id so "stats" is not parsed as a user id
usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const totalUsers = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(users)
);
const adminCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(users)
.where(eq((users as any).role, 'admin'))
);
return c.json({
stats: {
total: totalUsers?.count || 0,
admins: adminCount?.count || 0,
},
});
});
// Get user by ID (admin or self)
usersRouter.get('/:id', requireAuth(['admin', 'organizer', 'staff', 'marketing', 'user']), async (c) => {
const id = c.req.param('id');
@@ -286,27 +309,4 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
}
});
// Get user statistics (admin)
usersRouter.get('/stats/overview', requireAuth(['admin']), async (c) => {
const totalUsers = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(users)
);
const adminCount = await dbGet<any>(
(db as any)
.select({ count: sql<number>`count(*)` })
.from(users)
.where(eq((users as any).role, 'admin'))
);
return c.json({
stats: {
total: totalUsers?.count || 0,
admins: adminCount?.count || 0,
},
});
});
export default usersRouter;
+81
View File
@@ -0,0 +1,81 @@
# Example docker-compose for running the Spanglish API as multiple replicas
# behind nginx, with Redis for shared state and Postgres as the database.
#
# This is a starting point, not a turnkey production setup. It expects a
# Dockerfile at backend/Dockerfile that builds the API and runs it on PORT.
#
# Bring it up with N API replicas:
# docker compose -f deploy/docker-compose.scale.yml up --build --scale api=3
#
# No em dashes are used in this file by design.
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: spanglish
POSTGRES_PASSWORD: spanglish
POSTGRES_DB: spanglish
# Raise max_connections if DB_POOL_MAX * replicas approaches the default 100.
command: ["postgres", "-c", "max_connections=200"]
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U spanglish"]
interval: 5s
timeout: 3s
retries: 10
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redisdata:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
api:
build:
context: ../backend
environment:
NODE_ENV: production
PORT: "3001"
DB_TYPE: postgres
DATABASE_URL: postgresql://spanglish:spanglish@postgres:5432/spanglish
DB_POOL_MAX: "15"
REDIS_URL: redis://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.
# If you omit these, mount a shared volume at /app/uploads on every replica.
# S3_ENDPOINT: http://garage:3900
# S3_REGION: garage
# S3_BUCKET: spanglish-media
# S3_ACCESS_KEY_ID: ""
# S3_SECRET_ACCESS_KEY: ""
# S3_PUBLIC_URL: http://localhost:8080/media
expose:
- "3001"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
# Load balancer across the scaled api replicas. nginx resolves the "api"
# service name via Docker's embedded DNS, which round-robins across replicas.
lb:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./nginx.scale.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- api
volumes:
pgdata:
redisdata:
+19
View File
@@ -43,6 +43,25 @@ server {
access_log /var/log/nginx/spanglish_frontend_access.log;
error_log /var/log/nginx/spanglish_frontend_error.log;
# LNbits payment SSE stream - must not be buffered or events won't flush in
# real time. Regex location takes precedence over the /api prefix below.
location ~ ^/api/lnbits/stream/ {
proxy_pass http://spanglish_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection '';
# Disable buffering/caching for Server-Sent Events
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_connect_timeout 300s;
}
# Proxy /api to backend
location /api {
proxy_pass http://spanglish_backend;
+29
View File
@@ -0,0 +1,29 @@
# nginx load balancer for the scaled "api" service in docker-compose.scale.yml.
# Uses Docker's embedded DNS resolver so newly scaled replicas are discovered
# without editing a static upstream list.
server {
listen 80;
# Docker embedded DNS. valid=10s re-resolves so scaling up/down is picked up.
resolver 127.0.0.11 valid=10s;
location / {
# Use a variable so nginx defers resolution to request time (round-robin
# across all replicas of the "api" service).
set $api_upstream http://api:3001;
proxy_pass $api_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Server-Sent Events: do not buffer the payment status stream.
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
proxy_set_header Connection "";
proxy_http_version 1.1;
}
}
+35 -12
View File
@@ -1,28 +1,51 @@
/** @type {import('next').NextConfig} */
// Backend origin for API/upload proxying. Configurable per environment instead of
// being hardcoded to localhost.
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
// Extra image hosts can be allowed via a comma-separated env var (e.g. a CDN).
const extraImageHosts = (process.env.NEXT_PUBLIC_IMAGE_HOSTS || '')
.split(',')
.map((h) => h.trim())
.filter(Boolean)
.map((hostname) => ({ protocol: 'https', hostname }));
const securityHeaders = [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'X-XSS-Protection', value: '0' },
{ key: 'Permissions-Policy', value: 'camera=(self), microphone=(), geolocation=()' },
];
const nextConfig = {
images: {
domains: ['localhost', 'images.unsplash.com'],
// Restrict remote image sources to a known allowlist instead of allowing any
// https host (which let the Next image optimizer be used as an open proxy).
remotePatterns: [
{
protocol: 'https',
hostname: '**',
},
{
protocol: 'http',
hostname: 'localhost',
port: '3001',
},
{ protocol: 'https', hostname: 'images.unsplash.com' },
{ protocol: 'http', hostname: 'localhost', port: '3001' },
...extraImageHosts,
],
},
async headers() {
return [
{
source: '/:path*',
headers: securityHeaders,
},
];
},
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://localhost:3001/api/:path*',
destination: `${BACKEND_URL}/api/:path*`,
},
{
source: '/uploads/:path*',
destination: 'http://localhost:3001/uploads/:path*',
destination: `${BACKEND_URL}/uploads/:path*`,
},
];
},
+1 -2
View File
@@ -22,8 +22,7 @@
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"swr": "^2.2.5"
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@types/node": "^20.14.9",
@@ -0,0 +1,83 @@
import { useEffect } from 'react';
import toast from 'react-hot-toast';
import { ticketsApi } from '@/lib/api';
import type { BookingStep } from '../_types';
/**
* Watch for Lightning payment confirmation while on the paying step.
* SSE gives instant updates; a 3s poll runs in parallel as a safety net so a
* buffered/stuck stream (e.g. a proxy that doesn't flush SSE) can't strand the UI.
*/
export function useLightningWatcher(
step: BookingStep,
ticketId: string | undefined,
locale: string,
setPaymentPending: (value: boolean) => void,
setStep: (value: BookingStep) => void
) {
useEffect(() => {
if (step !== 'paying' || !ticketId) return;
let settled = false;
let pollTimer: ReturnType<typeof setTimeout> | null = null;
const confirmPaid = () => {
if (settled) return;
settled = true;
toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!');
setPaymentPending(false);
setStep('success');
};
const expire = () => {
if (settled) return;
settled = true;
toast.error(locale === 'es' ? 'La factura ha expirado' : 'Invoice has expired');
setPaymentPending(false);
};
// Always same-origin so the streaming proxy route handler is used (it
// bypasses the rewrite, which buffers SSE).
const eventSource = new EventSource(`/api/lnbits/stream/${ticketId}`);
eventSource.addEventListener('payment', (event) => {
try {
const data = JSON.parse((event as MessageEvent).data);
if (data.type === 'paid' || data.type === 'already_paid') {
confirmPaid();
} else if (data.type === 'expired') {
expire();
}
} catch (e) {
console.error('Error parsing payment event:', e);
}
});
eventSource.onerror = () => {
// SSE failed or was closed; the poll below remains the source of truth.
eventSource.close();
};
const poll = async () => {
try {
const status = await ticketsApi.checkPaymentStatus(ticketId);
if (status.isPaid) {
confirmPaid();
return;
}
} catch (error) {
console.error('Error checking payment status:', error);
}
if (!settled) {
pollTimer = setTimeout(poll, 3000);
}
};
pollTimer = setTimeout(poll, 3000);
return () => {
settled = true;
eventSource.close();
if (pollTimer) clearTimeout(pollTimer);
};
}, [step, ticketId, locale]);
}
@@ -0,0 +1,131 @@
import toast from 'react-hot-toast';
import { PaymentOptionsConfig } from '@/lib/api';
import {
CreditCardIcon,
BanknotesIcon,
BoltIcon,
BuildingLibraryIcon,
} from '@heroicons/react/24/outline';
import type { PaymentMethod, BookingResult } from '../_types';
export const rucPattern = /^\d{6,10}$/;
/** Format RUC input: digits only, max 10. */
export function formatRuc(value: string): string {
return value.replace(/\D/g, '').slice(0, 10);
}
/** Truncate a long invoice string for display. */
export function truncateInvoice(invoice: string, chars: number = 20): string {
if (invoice.length <= chars * 2) return invoice;
return `${invoice.slice(0, chars)}...${invoice.slice(-chars)}`;
}
/** Copy a Lightning invoice to the clipboard with localized feedback. */
export function copyInvoiceToClipboard(invoice: string, locale: string): void {
navigator.clipboard.writeText(invoice).then(() => {
toast.success(locale === 'es' ? '¡Copiado!' : 'Copied!');
}).catch(() => {
toast.error(locale === 'es' ? 'Error al copiar' : 'Failed to copy');
});
}
export interface PaymentMethodOption {
id: PaymentMethod;
icon: typeof CreditCardIcon;
label: string;
description: string;
badge?: string;
}
/** Build the list of selectable payment methods from the event config. */
export function buildPaymentMethods(
paymentConfig: PaymentOptionsConfig | null,
locale: string
): PaymentMethodOption[] {
const paymentMethods: PaymentMethodOption[] = [];
if (paymentConfig?.lightningEnabled) {
paymentMethods.push({
id: 'lightning',
icon: BoltIcon,
label: 'Bitcoin Lightning',
description: locale === 'es' ? 'Pago instantáneo con Bitcoin' : 'Instant payment with Bitcoin',
badge: locale === 'es' ? 'Instantáneo' : 'Instant',
});
}
if (paymentConfig?.tpagoEnabled) {
paymentMethods.push({
id: 'tpago',
icon: CreditCardIcon,
label: locale === 'es' ? 'TPago / Tarjetas de Crédito' : 'TPago / Credit Cards',
description: locale === 'es' ? 'Pagá con tarjetas de crédito locales o internacionales' : 'Pay with local or international credit cards',
badge: locale === 'es' ? 'Manual' : 'Manual',
});
}
if (paymentConfig?.bankTransferEnabled) {
paymentMethods.push({
id: 'bank_transfer',
icon: BuildingLibraryIcon,
label: locale === 'es' ? 'Transferencia Bancaria Local' : 'Local Bank Transfer',
description: locale === 'es' ? 'Pago por transferencia bancaria en Paraguay' : 'Pay via Paraguayan bank transfer',
badge: locale === 'es' ? 'Manual' : 'Manual',
});
}
if (paymentConfig?.cashEnabled) {
paymentMethods.push({
id: 'cash',
icon: BanknotesIcon,
label: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event',
description: locale === 'es' ? 'Paga cuando llegues al evento' : 'Pay when you arrive at the event',
badge: locale === 'es' ? 'Manual' : 'Manual',
});
}
return paymentMethods;
}
export interface SuccessContent {
title: string;
description: string;
iconColor: string;
iconTextColor: string;
}
/** Resolve the success-screen copy based on the payment method used. */
export function getSuccessContent(
bookingResult: BookingResult | null,
locale: string,
t: (key: string) => string
): SuccessContent {
if (bookingResult?.paymentMethod === 'cash') {
return {
title: locale === 'es' ? '¡Reserva Recibida!' : 'Reservation Received!',
description: locale === 'es'
? 'Tu lugar está reservado. El pago se realizará en el evento.'
: 'Your spot is reserved. Payment will be collected at the event.',
iconColor: 'bg-yellow-100',
iconTextColor: 'text-yellow-600',
};
}
if (bookingResult?.paymentMethod === 'lightning') {
// For Lightning, if we're on success step, payment was confirmed
return {
title: locale === 'es' ? '¡Pago Confirmado!' : 'Payment Confirmed!',
description: locale === 'es'
? '¡Tu reserva está confirmada! Te esperamos en el evento.'
: 'Your booking is confirmed! See you at the event.',
iconColor: 'bg-green-100',
iconTextColor: 'text-green-600',
};
}
return {
title: t('booking.success.title'),
description: t('booking.success.description'),
iconColor: 'bg-green-100',
iconTextColor: 'text-green-600',
};
}
@@ -0,0 +1,447 @@
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import {
CalendarIcon,
MapPinIcon,
UserGroupIcon,
CurrencyDollarIcon,
ArrowLeftIcon,
CheckCircleIcon,
UserIcon,
} from '@heroicons/react/24/outline';
import { Event } from '@/lib/api';
import { formatPrice } from '@/lib/utils';
import type { AttendeeInfo, BookingFormData } from '../_types';
import type { PaymentMethodOption } from '../_logic/booking';
interface BookingFormStepProps {
event: Event;
locale: string;
t: (key: string) => string;
spotsLeft: number;
isSoldOut: boolean;
ticketQuantity: number;
formData: BookingFormData;
setFormData: React.Dispatch<React.SetStateAction<BookingFormData>>;
errors: Partial<Record<keyof BookingFormData, string>>;
attendees: AttendeeInfo[];
setAttendees: React.Dispatch<React.SetStateAction<AttendeeInfo[]>>;
attendeeErrors: { [key: number]: string };
setAttendeeErrors: React.Dispatch<React.SetStateAction<{ [key: number]: string }>>;
handleRucChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleRucBlur: () => void;
paymentMethods: PaymentMethodOption[];
agreedToTerms: boolean;
setAgreedToTerms: (value: boolean) => void;
termsError: string | null;
submitting: boolean;
onSubmit: (e: React.FormEvent) => void;
formatDate: (dateStr: string) => string;
fmtTime: (dateStr: string) => string;
}
export function BookingFormStep({
event,
locale,
t,
spotsLeft,
isSoldOut,
ticketQuantity,
formData,
setFormData,
errors,
attendees,
setAttendees,
attendeeErrors,
setAttendeeErrors,
handleRucChange,
handleRucBlur,
paymentMethods,
agreedToTerms,
setAgreedToTerms,
termsError,
submitting,
onSubmit,
formatDate,
fmtTime,
}: BookingFormStepProps) {
return (
<div className="section-padding bg-secondary-gray min-h-screen">
<div className="container-page max-w-2xl">
<Link
href={`/events/${event.slug}`}
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-6"
>
<ArrowLeftIcon className="w-4 h-4" />
{t('common.back')}
</Link>
{/* Event Summary - Always Visible */}
<Card className="mb-6 overflow-hidden">
<div className="bg-primary-yellow/20 p-4 border-b border-primary-yellow/30">
<h2 className="font-bold text-lg text-primary-dark">
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
</h2>
</div>
<div className="p-4 space-y-2 text-sm">
<div className="flex items-center gap-3">
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
<span>{formatDate(event.startDatetime)} {fmtTime(event.startDatetime)}</span>
</div>
<div className="flex items-center gap-3">
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
<span>{event.location}</span>
</div>
{!event.externalBookingEnabled && (
<div className="flex items-center gap-3">
<UserGroupIcon className="w-5 h-5 text-primary-yellow" />
<span>{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}</span>
</div>
)}
<div className="flex items-center gap-3">
<CurrencyDollarIcon className="w-5 h-5 text-primary-yellow" />
<span className="font-bold text-lg">
{event.price === 0
? t('events.details.free')
: formatPrice(event.price, event.currency)}
</span>
{event.price > 0 && (
<span className="text-gray-400 text-sm">
{locale === 'es' ? 'por persona' : 'per person'}
</span>
)}
</div>
{/* Ticket quantity and total */}
{ticketQuantity > 1 && (
<div className="mt-3 pt-3 border-t border-secondary-light-gray">
<div className="flex items-center justify-between">
<span className="text-gray-600">
{locale === 'es' ? 'Tickets' : 'Tickets'}: <span className="font-semibold">{ticketQuantity}</span>
</span>
<span className="font-bold text-lg text-primary-dark">
{locale === 'es' ? 'Total' : 'Total'}: {formatPrice(event.price * ticketQuantity, event.currency)}
</span>
</div>
</div>
)}
</div>
</Card>
{isSoldOut ? (
<Card className="p-8 text-center">
<UserGroupIcon className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<h2 className="text-xl font-bold text-gray-700">{t('events.details.soldOut')}</h2>
<p className="text-gray-500 mt-2">{t('booking.form.soldOutMessage')}</p>
</Card>
) : (
<form onSubmit={onSubmit}>
{/* User Information Section */}
<Card className="mb-6 p-6">
<h3 className="font-bold text-lg mb-4 text-primary-dark flex items-center gap-2">
{attendees.length > 0 && (
<span className="w-6 h-6 rounded-full bg-primary-yellow text-primary-dark text-sm font-bold flex items-center justify-center">
1
</span>
)}
{t('booking.form.personalInfo')}
{attendees.length > 0 && (
<span className="text-sm font-normal text-gray-500">
({locale === 'es' ? 'Asistente principal' : 'Primary attendee'})
</span>
)}
</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label={t('booking.form.firstName')}
value={formData.firstName}
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
placeholder={t('booking.form.firstNamePlaceholder')}
error={errors.firstName}
required
/>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium text-gray-700">
{t('booking.form.lastName')}
</label>
<span className="text-xs text-gray-400">
({locale === 'es' ? 'Opcional' : 'Optional'})
</span>
</div>
<Input
value={formData.lastName}
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
placeholder={t('booking.form.lastNamePlaceholder')}
error={errors.lastName}
/>
</div>
</div>
<div>
<Input
label={t('booking.form.email')}
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder={t('booking.form.emailPlaceholder')}
error={errors.email}
required
/>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium text-gray-700">
{t('booking.form.phone')}
</label>
<span className="text-xs text-gray-400">
({locale === 'es' ? 'Opcional' : 'Optional'})
</span>
</div>
<Input
type="tel"
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
placeholder={t('booking.form.phonePlaceholder')}
error={errors.phone}
/>
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium text-gray-700">
{t('booking.form.ruc')}
</label>
<span className="text-xs text-gray-400">
{t('booking.form.rucOptional')}
</span>
</div>
<Input
value={formData.ruc}
onChange={handleRucChange}
onBlur={handleRucBlur}
placeholder={t('booking.form.rucPlaceholder')}
error={errors.ruc}
inputMode="numeric"
maxLength={10}
aria-label={t('booking.form.ruc')}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('booking.form.preferredLanguage')}
</label>
<select
value={formData.preferredLanguage}
onChange={(e) => setFormData({ ...formData, preferredLanguage: e.target.value as 'en' | 'es' })}
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
>
<option value="en">English</option>
<option value="es">Español</option>
</select>
</div>
</div>
</Card>
{/* Additional Attendees Section (for multi-ticket bookings) */}
{attendees.length > 0 && (
<Card className="mb-6 p-6">
<h3 className="font-bold text-lg mb-4 text-primary-dark flex items-center gap-2">
<UserIcon className="w-5 h-5 text-primary-yellow" />
{locale === 'es' ? 'Información de los Otros Asistentes' : 'Other Attendees Information'}
</h3>
<p className="text-sm text-gray-600 mb-4">
{locale === 'es'
? 'Ingresa el nombre de cada asistente adicional. Cada persona recibirá su propio ticket.'
: 'Enter the name for each additional attendee. Each person will receive their own ticket.'}
</p>
<div className="space-y-4">
{attendees.map((attendee, index) => (
<div key={index} className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center gap-2 mb-3">
<span className="w-6 h-6 rounded-full bg-primary-yellow text-primary-dark text-sm font-bold flex items-center justify-center">
{index + 2}
</span>
<span className="font-medium text-gray-700">
{locale === 'es' ? `Asistente ${index + 2}` : `Attendee ${index + 2}`}
</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label={t('booking.form.firstName')}
value={attendee.firstName}
onChange={(e) => {
const newAttendees = [...attendees];
newAttendees[index].firstName = e.target.value;
setAttendees(newAttendees);
if (attendeeErrors[index]) {
const newErrors = { ...attendeeErrors };
delete newErrors[index];
setAttendeeErrors(newErrors);
}
}}
placeholder={t('booking.form.firstNamePlaceholder')}
error={attendeeErrors[index]}
required
/>
<div>
<div className="flex items-center gap-2 mb-1">
<label className="block text-sm font-medium text-gray-700">
{t('booking.form.lastName')}
</label>
<span className="text-xs text-gray-400">
({locale === 'es' ? 'Opcional' : 'Optional'})
</span>
</div>
<Input
value={attendee.lastName}
onChange={(e) => {
const newAttendees = [...attendees];
newAttendees[index].lastName = e.target.value;
setAttendees(newAttendees);
}}
placeholder={t('booking.form.lastNamePlaceholder')}
/>
</div>
</div>
</div>
))}
</div>
</Card>
)}
{/* Payment Selection Section */}
<Card className="mb-6 p-6">
<h3 className="font-bold text-lg mb-4 text-primary-dark">
{t('booking.form.paymentMethod')}
</h3>
<div className="space-y-3">
{paymentMethods.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{locale === 'es'
? 'No hay métodos de pago disponibles para este evento.'
: 'No payment methods available for this event.'}
</div>
) : (
<>
{paymentMethods.map((method) => (
<button
key={method.id}
type="button"
onClick={() => setFormData({ ...formData, paymentMethod: method.id })}
className={`w-full p-4 rounded-lg border-2 transition-all text-left flex items-start gap-4 ${
formData.paymentMethod === method.id
? 'border-primary-yellow bg-primary-yellow/10'
: 'border-secondary-light-gray hover:border-gray-300'
}`}
>
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
formData.paymentMethod === method.id
? 'bg-primary-yellow'
: 'bg-gray-100'
}`}>
<method.icon className={`w-5 h-5 ${
formData.paymentMethod === method.id
? 'text-primary-dark'
: 'text-gray-500'
}`} />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium text-primary-dark">{method.label}</p>
{method.badge && (
<span className={`text-xs px-2 py-0.5 rounded-full ${
method.badge === 'Instant' || method.badge === 'Instantáneo'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600'
}`}>
{method.badge}
</span>
)}
</div>
<p className="text-sm text-gray-500">{method.description}</p>
</div>
{formData.paymentMethod === method.id && (
<CheckCircleIcon className="w-6 h-6 text-primary-yellow ml-auto flex-shrink-0" />
)}
</button>
))}
</>
)}
</div>
</Card>
{/* Terms & Privacy agreement */}
<Card className="mb-6 p-6">
<div className="flex items-start gap-3">
<input
id="booking-terms-agree"
type="checkbox"
checked={agreedToTerms}
onChange={(e) => setAgreedToTerms(e.target.checked)}
aria-required="true"
aria-invalid={termsError ? true : undefined}
aria-describedby={termsError ? 'booking-terms-error' : undefined}
className="h-5 w-5 mt-0.5 flex-shrink-0 accent-primary-yellow rounded focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:ring-offset-2 cursor-pointer"
/>
<label
htmlFor="booking-terms-agree"
className="text-sm text-gray-500 leading-relaxed cursor-pointer select-none"
>
{t('booking.form.termsAgreePart1')}
<Link
href={`/legal/terms-policy${locale === 'es' ? '?locale=es' : ''}`}
target="_blank"
rel="noopener noreferrer"
className="text-secondary-blue hover:text-brand-navy underline"
>
{t('booking.form.termsOfService')}
</Link>
{t('booking.form.termsAgreePart2')}
<Link
href={`/legal/privacy-policy${locale === 'es' ? '?locale=es' : ''}`}
target="_blank"
rel="noopener noreferrer"
className="text-secondary-blue hover:text-brand-navy underline"
>
{t('booking.form.privacyPolicy')}
</Link>
{t('booking.form.termsAgreePart3')}
</label>
</div>
{termsError && (
<p id="booking-terms-error" className="mt-1.5 text-sm text-red-600">
{termsError}
</p>
)}
</Card>
{/* Submit Button */}
<Button
type="submit"
size="lg"
className="w-full"
isLoading={submitting}
disabled={paymentMethods.length === 0 || !agreedToTerms}
>
{formData.paymentMethod === 'cash'
? t('booking.form.reserveSpot')
: formData.paymentMethod === 'lightning'
? t('booking.form.proceedPayment')
: locale === 'es' ? 'Continuar al Pago' : 'Continue to Payment'
}
</Button>
</form>
)}
</div>
</div>
);
}
@@ -0,0 +1,249 @@
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import {
CreditCardIcon,
BuildingLibraryIcon,
CheckCircleIcon,
ArrowTopRightOnSquareIcon,
} from '@heroicons/react/24/outline';
import { Event, PaymentOptionsConfig } from '@/lib/api';
import { formatPrice, getTpagoLink } from '@/lib/utils';
import type { BookingResult } from '../_types';
interface ManualPaymentStepProps {
bookingResult: BookingResult;
event: Event;
paymentConfig: PaymentOptionsConfig;
locale: string;
paidUnderDifferentName: boolean;
setPaidUnderDifferentName: (value: boolean) => void;
payerName: string;
setPayerName: (value: string) => void;
markingPaid: boolean;
onMarkPaymentSent: () => void;
}
export function ManualPaymentStep({
bookingResult,
event,
paymentConfig,
locale,
paidUnderDifferentName,
setPaidUnderDifferentName,
payerName,
setPayerName,
markingPaid,
onMarkPaymentSent,
}: ManualPaymentStepProps) {
const isBankTransfer = bookingResult.paymentMethod === 'bank_transfer';
const isTpago = bookingResult.paymentMethod === 'tpago';
const ticketCount = bookingResult.ticketCount || 1;
const totalAmount = (event?.price || 0) * ticketCount;
const tpagoLink = getTpagoLink(paymentConfig, ticketCount);
return (
<div className="section-padding">
<div className="container-page max-w-xl">
<Card className="p-6">
<div className="text-center mb-6">
<div className={`w-16 h-16 rounded-full ${isBankTransfer ? 'bg-green-100' : 'bg-blue-100'} flex items-center justify-center mx-auto mb-4`}>
{isBankTransfer ? (
<BuildingLibraryIcon className="w-8 h-8 text-green-600" />
) : (
<CreditCardIcon className="w-8 h-8 text-blue-600" />
)}
</div>
<h1 className="text-xl font-bold text-primary-dark mb-2">
{locale === 'es' ? 'Completa tu Pago' : 'Complete Your Payment'}
</h1>
<p className="text-gray-600">
{locale === 'es'
? 'Sigue las instrucciones para completar tu pago'
: 'Follow the instructions to complete your payment'}
</p>
</div>
{/* Amount to pay */}
<div className="bg-gray-50 rounded-lg p-4 mb-6 text-center">
<p className="text-sm text-gray-500 mb-1">
{locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}
</p>
<p className="text-2xl font-bold text-primary-dark">
{event?.price !== undefined ? formatPrice(totalAmount, event.currency) : ''}
</p>
{ticketCount > 1 && (
<p className="text-sm text-gray-500 mt-1">
{ticketCount} tickets × {formatPrice(event?.price || 0, event?.currency || 'PYG')}
</p>
)}
</div>
{/* Bank Transfer Details */}
{isBankTransfer && (
<div className="space-y-4 mb-6">
<h3 className="font-semibold text-gray-900">
{locale === 'es' ? 'Datos Bancarios' : 'Bank Details'}
</h3>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 space-y-3">
{paymentConfig.bankName && (
<div className="flex justify-between">
<span className="text-gray-600">{locale === 'es' ? 'Banco' : 'Bank'}:</span>
<span className="font-medium">{paymentConfig.bankName}</span>
</div>
)}
{paymentConfig.bankAccountHolder && (
<div className="flex justify-between">
<span className="text-gray-600">{locale === 'es' ? 'Titular' : 'Account Holder'}:</span>
<span className="font-medium">{paymentConfig.bankAccountHolder}</span>
</div>
)}
{paymentConfig.bankAccountNumber && (
<div className="flex justify-between">
<span className="text-gray-600">{locale === 'es' ? 'Nro. Cuenta' : 'Account Number'}:</span>
<span className="font-medium font-mono">{paymentConfig.bankAccountNumber}</span>
</div>
)}
{paymentConfig.bankAlias && (
<div className="flex justify-between">
<span className="text-gray-600">Alias:</span>
<span className="font-medium">{paymentConfig.bankAlias}</span>
</div>
)}
{paymentConfig.bankPhone && (
<div className="flex justify-between">
<span className="text-gray-600">{locale === 'es' ? 'Teléfono' : 'Phone'}:</span>
<span className="font-medium">{paymentConfig.bankPhone}</span>
</div>
)}
</div>
{(locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes) && (
<p className="text-sm text-gray-600">
{locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes}
</p>
)}
</div>
)}
{/* TPago Link */}
{isTpago && (
<div className="space-y-4 mb-6">
<h3 className="font-semibold text-gray-900">
{locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'}
</h3>
{tpagoLink && (
<a
href={tpagoLink}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full px-6 py-4 bg-blue-600 text-white rounded-btn hover:bg-blue-700 transition-colors font-medium"
>
<ArrowTopRightOnSquareIcon className="w-5 h-5" />
{locale === 'es' ? 'Abrir TPago para Pagar' : 'Open TPago to Pay'}
</a>
)}
{(locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions) && (
<p className="text-sm text-gray-600">
{locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions}
</p>
)}
</div>
)}
{/* Reference */}
<div className="bg-gray-100 rounded-lg p-3 mb-6">
<p className="text-xs text-gray-500 mb-1">
{locale === 'es' ? 'Referencia de tu reserva' : 'Your booking reference'}
</p>
<p className="font-mono font-bold text-lg">{bookingResult.qrCode}</p>
</div>
{/* Manual verification notice */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<div className="flex gap-3">
<div className="flex-shrink-0">
<svg className="w-5 h-5 text-blue-600 mt-0.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
</svg>
</div>
<div className="text-sm text-blue-800">
<p className="font-medium mb-1">
{locale === 'es' ? 'Verificación manual' : 'Manual verification'}
</p>
<p className="text-blue-700">
{locale === 'es'
? 'El equipo de Spanglish revisará el pago manualmente. Tu reserva solo será confirmada después de recibir un email de confirmación de nuestra parte.'
: 'The Spanglish team will review the payment manually. Your booking is only confirmed after you receive a confirmation email from us.'}
</p>
</div>
</div>
</div>
{/* Paid under different name option */}
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={paidUnderDifferentName}
onChange={(e) => {
setPaidUnderDifferentName(e.target.checked);
if (!e.target.checked) setPayerName('');
}}
className="mt-1 w-4 h-4 text-primary-yellow border-gray-300 rounded focus:ring-primary-yellow"
/>
<div>
<span className="font-medium text-gray-700">
{locale === 'es'
? 'El pago está a nombre de otra persona'
: 'The payment is under another person\'s name'}
</span>
<p className="text-xs text-gray-500 mt-1">
{locale === 'es'
? 'Marcá esta opción si el pago fue realizado por un familiar o tercero.'
: 'Check this option if the payment was made by a family member or a third party.'}
</p>
</div>
</label>
{paidUnderDifferentName && (
<div className="mt-3 pl-7">
<Input
label={locale === 'es' ? 'Nombre del pagador' : 'Payer name'}
value={payerName}
onChange={(e) => setPayerName(e.target.value)}
placeholder={locale === 'es' ? 'Nombre completo del titular de la cuenta' : 'Full name of account holder'}
required
/>
</div>
)}
</div>
{/* Warning before I Have Paid button */}
<p className="text-sm text-center text-amber-700 font-medium mb-3">
{locale === 'es'
? 'Solo haz clic aquí después de haber completado el pago.'
: 'Only click this after you have actually completed the payment.'}
</p>
{/* I Have Paid Button */}
<Button
onClick={onMarkPaymentSent}
isLoading={markingPaid}
size="lg"
className="w-full"
disabled={paidUnderDifferentName && !payerName.trim()}
>
<CheckCircleIcon className="w-5 h-5 mr-2" />
{locale === 'es' ? 'Ya Realicé el Pago' : 'I Have Paid'}
</Button>
<p className="text-xs text-center text-gray-500 mt-4">
{locale === 'es'
? 'Tu reserva será confirmada una vez que verifiquemos el pago'
: 'Your booking will be confirmed once we verify the payment'}
</p>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,81 @@
import { QRCodeSVG } from 'qrcode.react';
import Card from '@/components/ui/Card';
import { BoltIcon, ClipboardDocumentIcon } from '@heroicons/react/24/outline';
import { copyInvoiceToClipboard, truncateInvoice } from '../_logic/booking';
import type { LightningInvoice } from '../_types';
interface PayingStepProps {
invoice: LightningInvoice;
qrCode: string;
locale: string;
}
export function PayingStep({ invoice, qrCode, locale }: PayingStepProps) {
return (
<div className="section-padding">
<div className="container-page max-w-md">
<Card className="p-6 text-center">
{/* Amount - prominent at top */}
<div className="mb-4">
{invoice.fiatAmount && invoice.fiatCurrency && (
<p className="text-2xl font-bold text-primary-dark">
{invoice.fiatAmount.toLocaleString()} {invoice.fiatCurrency}
</p>
)}
<p className="text-orange-600 font-medium">
{invoice.amount.toLocaleString()} sats
</p>
</div>
{/* QR Code - clickable to copy */}
<div
className="bg-white p-4 rounded-lg shadow-inner inline-block mb-4 cursor-pointer hover:shadow-md transition-shadow"
onClick={() => copyInvoiceToClipboard(invoice.paymentRequest, locale)}
title={locale === 'es' ? 'Clic para copiar' : 'Click to copy'}
>
<QRCodeSVG
value={invoice.paymentRequest.toUpperCase()}
size={200}
level="M"
includeMargin={false}
/>
</div>
{/* Invoice string - truncated, clickable */}
<div
className="bg-secondary-gray rounded-lg p-3 mb-4 cursor-pointer hover:bg-gray-200 transition-colors"
onClick={() => copyInvoiceToClipboard(invoice.paymentRequest, locale)}
>
<p className="font-mono text-xs text-gray-600 flex items-center justify-center gap-2">
<ClipboardDocumentIcon className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{truncateInvoice(invoice.paymentRequest, 16)}</span>
</p>
<p className="text-xs text-gray-400 mt-1">
{locale === 'es' ? 'Toca para copiar' : 'Tap to copy'}
</p>
</div>
{/* Open in Wallet - primary action */}
<a
href={`lightning:${invoice.paymentRequest}`}
className="inline-flex items-center justify-center gap-2 w-full px-6 py-3 bg-orange-500 text-white rounded-btn hover:bg-orange-600 transition-colors font-medium mb-4"
>
<BoltIcon className="w-5 h-5" />
{locale === 'es' ? 'Abrir en Billetera' : 'Open in Wallet'}
</a>
{/* Status indicator */}
<div className="flex items-center justify-center gap-2 text-gray-500 text-sm">
<div className="animate-spin w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full" />
<span>{locale === 'es' ? 'Esperando pago...' : 'Waiting for payment...'}</span>
</div>
{/* Ticket reference - small */}
<p className="text-xs text-gray-400 mt-3">
{locale === 'es' ? 'Ref' : 'Ref'}: {qrCode}
</p>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,76 @@
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { ClockIcon, TicketIcon } from '@heroicons/react/24/outline';
import { Event } from '@/lib/api';
import type { BookingResult } from '../_types';
interface PendingApprovalStepProps {
bookingResult: BookingResult;
event: Event | null;
locale: string;
t: (key: string) => string;
formatDate: (dateStr: string) => string;
fmtTime: (dateStr: string) => string;
}
export function PendingApprovalStep({
bookingResult,
event,
locale,
t,
formatDate,
fmtTime,
}: PendingApprovalStepProps) {
return (
<div className="section-padding">
<div className="container-page max-w-xl">
<Card className="p-8 text-center">
<div className="w-16 h-16 rounded-full bg-yellow-100 flex items-center justify-center mx-auto mb-6">
<ClockIcon className="w-10 h-10 text-yellow-600" />
</div>
<h1 className="text-2xl font-bold text-primary-dark mb-2">
{locale === 'es' ? '¡Pago en Verificación!' : 'Payment Being Verified!'}
</h1>
<p className="text-gray-600 mb-6">
{locale === 'es'
? 'Estamos verificando tu pago. Recibirás un email de confirmación una vez aprobado.'
: 'We are verifying your payment. You will receive a confirmation email once approved.'}
</p>
<div className="bg-secondary-gray rounded-lg p-6 mb-6">
<div className="flex items-center justify-center gap-2 mb-4">
<TicketIcon className="w-6 h-6 text-primary-yellow" />
<span className="font-mono text-lg font-bold">{bookingResult.qrCode}</span>
</div>
<div className="text-sm text-gray-600 space-y-2">
<p><strong>{t('booking.success.event')}:</strong> {event?.title}</p>
<p><strong>{t('booking.success.date')}:</strong> {event && formatDate(event.startDatetime)}</p>
<p><strong>{t('booking.success.time')}:</strong> {event && fmtTime(event.startDatetime)}</p>
<p><strong>{t('booking.success.location')}:</strong> {event?.location}</p>
</div>
</div>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800 text-sm">
{locale === 'es'
? 'La verificación del pago puede tomar hasta 24 horas hábiles. Por favor revisa tu email regularmente.'
: 'Payment verification may take up to 24 business hours. Please check your email regularly.'}
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link href="/events">
<Button variant="outline">{t('booking.success.browseEvents')}</Button>
</Link>
<Link href="/">
<Button>{t('booking.success.backHome')}</Button>
</Link>
</div>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,144 @@
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
CheckCircleIcon,
TicketIcon,
ArrowDownTrayIcon,
} from '@heroicons/react/24/outline';
import { Event } from '@/lib/api';
import { getSuccessContent } from '../_logic/booking';
import type { BookingResult } from '../_types';
interface SuccessStepProps {
bookingResult: BookingResult;
event: Event;
locale: string;
t: (key: string) => string;
formatDate: (dateStr: string) => string;
fmtTime: (dateStr: string) => string;
}
export function SuccessStep({
bookingResult,
event,
locale,
t,
formatDate,
fmtTime,
}: SuccessStepProps) {
const successContent = getSuccessContent(bookingResult, locale, t);
return (
<div className="section-padding">
<div className="container-page max-w-2xl">
<Card className="p-8 text-center">
<div className={`w-16 h-16 rounded-full ${successContent.iconColor} flex items-center justify-center mx-auto mb-6`}>
<CheckCircleIcon className={`w-10 h-10 ${successContent.iconTextColor}`} />
</div>
<h1 className="text-2xl font-bold text-primary-dark mb-2">
{successContent.title}
</h1>
<p className="text-gray-600 mb-6">
{successContent.description}
</p>
<div className="bg-secondary-gray rounded-lg p-6 mb-6">
{/* Multi-ticket indicator */}
{bookingResult.ticketCount && bookingResult.ticketCount > 1 && (
<div className="mb-4 pb-4 border-b border-gray-300">
<p className="text-lg font-semibold text-primary-dark">
{locale === 'es'
? `${bookingResult.ticketCount} tickets reservados`
: `${bookingResult.ticketCount} tickets booked`}
</p>
<p className="text-sm text-gray-500">
{locale === 'es'
? 'Cada asistente recibirá su propio código QR'
: 'Each attendee will receive their own QR code'}
</p>
</div>
)}
<div className="flex items-center justify-center gap-2 mb-4">
<TicketIcon className="w-6 h-6 text-primary-yellow" />
<span className="font-mono text-lg font-bold">{bookingResult.qrCode}</span>
{bookingResult.ticketCount && bookingResult.ticketCount > 1 && (
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded-full">
+{bookingResult.ticketCount - 1} {locale === 'es' ? 'más' : 'more'}
</span>
)}
</div>
<div className="text-sm text-gray-600 space-y-2">
<p><strong>{t('booking.success.event')}:</strong> {event.title}</p>
<p><strong>{t('booking.success.date')}:</strong> {formatDate(event.startDatetime)}</p>
<p><strong>{t('booking.success.time')}:</strong> {fmtTime(event.startDatetime)}</p>
<p><strong>{t('booking.success.location')}:</strong> {event.location}</p>
</div>
</div>
{bookingResult.paymentMethod === 'cash' && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800 text-sm">
<strong>{t('booking.success.cashNote')}:</strong> {t('booking.success.cashDescription')}
</p>
</div>
)}
{bookingResult.paymentMethod === 'bancard' && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<p className="text-blue-800 text-sm">
{t('booking.success.cardNote')}
</p>
</div>
)}
{bookingResult.paymentMethod === 'lightning' && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<p className="text-green-800 text-sm flex items-center gap-2">
<CheckCircleIcon className="w-5 h-5" />
{locale === 'es'
? '¡Pago con Bitcoin Lightning recibido exitosamente!'
: 'Bitcoin Lightning payment received successfully!'}
</p>
</div>
)}
<p className="text-sm text-gray-500 mb-6">
{t('booking.success.emailSent')}
</p>
{/* Download Ticket Button - only for instant confirmation (Lightning) */}
{bookingResult.paymentMethod === 'lightning' && (
<div className="mb-6">
<a
href={bookingResult.bookingId
? `/api/tickets/booking/${bookingResult.bookingId}/pdf`
: `/api/tickets/${bookingResult.ticketId}/pdf`
}
download
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-yellow text-primary-dark font-medium rounded-btn hover:bg-primary-yellow/90 transition-colors"
>
<ArrowDownTrayIcon className="w-5 h-5" />
{locale === 'es'
? (bookingResult.ticketCount && bookingResult.ticketCount > 1 ? 'Descargar Tickets' : 'Descargar Ticket')
: (bookingResult.ticketCount && bookingResult.ticketCount > 1 ? 'Download Tickets' : 'Download Ticket')}
</a>
</div>
)}
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link href="/events">
<Button variant="outline">{t('booking.success.browseEvents')}</Button>
</Link>
<Link href="/">
<Button>{t('booking.success.backHome')}</Button>
</Link>
</div>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,40 @@
// Shared types for the booking flow.
export interface AttendeeInfo {
firstName: string;
lastName: string;
}
export type PaymentMethod = 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
export interface BookingFormData {
firstName: string;
lastName: string;
email: string;
phone: string;
preferredLanguage: 'en' | 'es';
paymentMethod: PaymentMethod;
ruc: string;
}
export interface LightningInvoice {
paymentHash: string;
paymentRequest: string; // BOLT11 invoice
amount: number; // Amount in satoshis
fiatAmount?: number; // Original fiat amount
fiatCurrency?: string; // Original fiat currency
expiry?: string;
}
export interface BookingResult {
ticketId: string;
ticketIds?: string[]; // For multi-ticket bookings
bookingId?: string;
qrCode: string;
qrCodes?: string[]; // For multi-ticket bookings
paymentMethod: PaymentMethod;
lightningInvoice?: LightningInvoice;
ticketCount?: number;
}
export type BookingStep = 'form' | 'paying' | 'manual_payment' | 'pending_approval' | 'success';
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,7 @@ import { useParams, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
import { formatPrice, formatDateLong, formatTime, getTpagoLink } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import {
@@ -69,7 +69,7 @@ export default function BookingPaymentPage() {
// Get payment config for the event
if (ticketData.eventId) {
const { paymentOptions } = await paymentOptionsApi.getForEvent(ticketData.eventId);
const { paymentOptions } = await paymentOptionsApi.getForEvent(ticketData.eventId, ticketData.id);
setPaymentConfig(paymentOptions);
}
@@ -305,6 +305,7 @@ export default function BookingPaymentPage() {
if (step === 'manual_payment' && ticket && paymentConfig) {
const isBankTransfer = ticket.payment?.provider === 'bank_transfer';
const isTpago = ticket.payment?.provider === 'tpago';
const tpagoLink = getTpagoLink(paymentConfig, ticket.bookingTicketCount || 1);
return (
<div className="section-padding">
@@ -418,9 +419,9 @@ export default function BookingPaymentPage() {
<h3 className="font-semibold text-gray-900">
{locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'}
</h3>
{paymentConfig.tpagoLink && (
{tpagoLink && (
<a
href={paymentConfig.tpagoLink}
href={tpagoLink}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full px-6 py-4 bg-blue-600 text-white rounded-btn hover:bg-blue-700 transition-colors font-medium"
@@ -0,0 +1,23 @@
import { redirect } from 'next/navigation';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
async function getFeaturedEventSlug(): Promise<string | null> {
try {
const revalidateSeconds =
parseInt(process.env.NEXT_EVENT_REVALIDATE_SECONDS || '3600', 10) || 3600;
const response = await fetch(`${apiUrl}/api/events/next/upcoming`, {
next: { tags: ['next-event'], revalidate: revalidateSeconds },
});
if (!response.ok) return null;
const data = await response.json();
return data.event?.slug || null;
} catch {
return null;
}
}
export default async function FeaturedEventPage() {
const slug = await getFeaturedEventSlug();
redirect(slug ? `/events/${slug}` : '/events');
}
+3 -2
View File
@@ -10,6 +10,7 @@ import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import GoogleSignInButton from '@/components/GoogleSignInButton';
import { authApi } from '@/lib/api';
import { safeInternalPath } from '@/lib/safeRedirect';
import toast from 'react-hot-toast';
function LoginContent() {
@@ -25,8 +26,8 @@ function LoginContent() {
password: '',
});
// Check for redirect after login
const redirectTo = searchParams.get('redirect') || '/dashboard';
// Check for redirect after login (only same-origin relative paths are honoured)
const redirectTo = safeInternalPath(searchParams.get('redirect'), '/dashboard');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
+23
View File
@@ -0,0 +1,23 @@
import { redirect } from 'next/navigation';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
async function getNextEventSlug(): Promise<string | null> {
try {
const revalidateSeconds =
parseInt(process.env.NEXT_EVENT_REVALIDATE_SECONDS || '3600', 10) || 3600;
const response = await fetch(`${apiUrl}/api/events/next`, {
next: { tags: ['next-event'], revalidate: revalidateSeconds },
});
if (!response.ok) return null;
const data = await response.json();
return data.event?.slug || null;
} catch {
return null;
}
}
export default async function NextEventPage() {
const slug = await getNextEventSlug();
redirect(slug ? `/events/${slug}` : '/events');
}
+2
View File
@@ -1047,6 +1047,7 @@ export default function AdminEmailsPage() {
<div className="flex-1 overflow-auto">
<iframe
srcDoc={previewHtml}
sandbox=""
className="w-full h-full min-h-[500px]"
title="Email Preview"
/>
@@ -1100,6 +1101,7 @@ export default function AdminEmailsPage() {
{selectedLog.bodyHtml ? (
<iframe
srcDoc={selectedLog.bodyHtml}
sandbox=""
className="w-full h-full min-h-[400px]"
title="Email Content"
/>
@@ -0,0 +1,19 @@
import clsx from 'clsx';
export function StatusBadge({ status, compact = false }: { status: string; compact?: boolean }) {
const styles: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800',
confirmed: 'bg-green-100 text-green-800',
cancelled: 'bg-red-100 text-red-800',
checked_in: 'bg-blue-100 text-blue-800',
};
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[status] || 'bg-gray-100 text-gray-800'
)}>
{status.replace('_', ' ')}
</span>
);
}
@@ -0,0 +1,37 @@
import { useState, useEffect } from 'react';
import toast from 'react-hot-toast';
import { eventsApi, ticketsApi, emailsApi, Event, Ticket, EmailTemplate } from '@/lib/api';
/**
* Loads the core data for the admin event detail page (event, tickets, active
* email templates) and exposes a reload function used after mutations.
*/
export function useEventDetailData(eventId: string) {
const [loading, setLoading] = useState(true);
const [event, setEvent] = useState<Event | null>(null);
const [tickets, setTickets] = useState<Ticket[]>([]);
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
const loadEventData = async () => {
try {
const [eventRes, ticketsRes, templatesRes] = await Promise.all([
eventsApi.getById(eventId),
ticketsApi.getAll({ eventId }),
emailsApi.getTemplates(),
]);
setEvent(eventRes.event);
setTickets(ticketsRes.tickets);
setTemplates(templatesRes.templates.filter(t => t.isActive));
} catch (error) {
toast.error('Failed to load event data');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadEventData();
}, [eventId]);
return { loading, event, tickets, templates, loadEventData };
}
@@ -0,0 +1,96 @@
import { useState } from 'react';
import toast from 'react-hot-toast';
import { paymentOptionsApi, PaymentOptionsConfig } from '@/lib/api';
/**
* Manages the event-level payment override editor state: loading global +
* override config, computing effective values, editing, saving and resetting.
*/
export function usePaymentOverrides(eventId: string, locale: string) {
const [globalPaymentOptions, setGlobalPaymentOptions] = useState<PaymentOptionsConfig | null>(null);
const [paymentOverrides, setPaymentOverrides] = useState<Partial<PaymentOptionsConfig>>({});
const [hasPaymentOverrides, setHasPaymentOverrides] = useState(false);
const [savingPayments, setSavingPayments] = useState(false);
const [loadingPayments, setLoadingPayments] = useState(false);
const loadPaymentOptions = async () => {
if (globalPaymentOptions) return;
setLoadingPayments(true);
try {
const [globalRes, overridesRes] = await Promise.all([
paymentOptionsApi.getGlobal(),
paymentOptionsApi.getEventOverrides(eventId),
]);
setGlobalPaymentOptions(globalRes.paymentOptions);
if (overridesRes.overrides) {
setPaymentOverrides(overridesRes.overrides);
setHasPaymentOverrides(true);
}
} catch (error) {
toast.error('Failed to load payment options');
} finally {
setLoadingPayments(false);
}
};
const getEffectivePaymentOption = <K extends keyof PaymentOptionsConfig>(key: K): PaymentOptionsConfig[K] => {
if (paymentOverrides[key] !== undefined && paymentOverrides[key] !== null) {
return paymentOverrides[key] as PaymentOptionsConfig[K];
}
return globalPaymentOptions?.[key] as PaymentOptionsConfig[K];
};
const updatePaymentOverride = <K extends keyof PaymentOptionsConfig>(
key: K,
value: PaymentOptionsConfig[K] | null
) => {
setPaymentOverrides((prev) => ({ ...prev, [key]: value }));
setHasPaymentOverrides(true);
};
const handleSavePaymentOptions = async () => {
setSavingPayments(true);
try {
await paymentOptionsApi.updateEventOverrides(eventId, paymentOverrides);
toast.success(locale === 'es' ? 'Opciones de pago guardadas' : 'Payment options saved');
} catch (error: any) {
toast.error(error.message || 'Failed to save payment options');
} finally {
setSavingPayments(false);
}
};
const handleResetToGlobal = async () => {
if (!confirm(locale === 'es'
? '¿Resetear a la configuración global? Se eliminarán todas las personalizaciones de este evento.'
: 'Reset to global settings? This will remove all customizations for this event.')) {
return;
}
setSavingPayments(true);
try {
await paymentOptionsApi.deleteEventOverrides(eventId);
setPaymentOverrides({});
setHasPaymentOverrides(false);
toast.success(locale === 'es' ? 'Restablecido a configuración global' : 'Reset to global settings');
} catch (error: any) {
toast.error(error.message || 'Failed to reset payment options');
} finally {
setSavingPayments(false);
}
};
return {
globalPaymentOptions,
paymentOverrides,
hasPaymentOverrides,
savingPayments,
loadingPayments,
loadPaymentOptions,
getEffectivePaymentOption,
updatePaymentOverride,
handleSavePaymentOptions,
handleResetToGlobal,
};
}
export type PaymentOverridesController = ReturnType<typeof usePaymentOverrides>;
@@ -0,0 +1,521 @@
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 {
CheckCircleIcon,
EnvelopeIcon,
PlusIcon,
StarIcon,
XMarkIcon,
} from '@heroicons/react/24/outline';
import type { AttendeeStatusFilter, AttendeeFormState, AddAtDoorFormState } from '../_types';
interface EventModalsProps {
// counts + filter
ticketsCount: number;
pendingCount: number;
confirmedCount: number;
checkedInCount: number;
cancelledCount: number;
statusFilter: AttendeeStatusFilter;
setStatusFilter: (value: AttendeeStatusFilter) => void;
// mobile filter sheet
mobileFilterOpen: boolean;
setMobileFilterOpen: (value: boolean) => void;
// add ticket sheet
showAddTicketSheet: boolean;
setShowAddTicketSheet: (value: boolean) => void;
// export sheets
showExportSheet: boolean;
setShowExportSheet: (value: boolean) => void;
handleExportAttendees: (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => void;
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
showNoteModal: boolean;
setShowNoteModal: (value: boolean) => void;
selectedTicket: Ticket | null;
setSelectedTicket: (value: Ticket | null) => void;
noteText: string;
setNoteText: (value: string) => void;
handleSaveNote: () => void;
// preview modal
previewHtml: string | null;
setPreviewHtml: (value: string | null) => void;
}
export function EventModals(props: EventModalsProps) {
const {
ticketsCount,
pendingCount,
confirmedCount,
checkedInCount,
cancelledCount,
statusFilter,
setStatusFilter,
mobileFilterOpen,
setMobileFilterOpen,
showAddTicketSheet,
setShowAddTicketSheet,
showExportSheet,
setShowExportSheet,
handleExportAttendees,
showTicketExportSheet,
setShowTicketExportSheet,
handleExportTickets,
showAddAtDoorModal,
setShowAddAtDoorModal,
addAtDoorForm,
setAddAtDoorForm,
handleAddAtDoor,
showManualTicketModal,
setShowManualTicketModal,
manualTicketForm,
setManualTicketForm,
handleManualTicket,
showInviteGuestModal,
setShowInviteGuestModal,
inviteGuestForm,
setInviteGuestForm,
handleInviteGuest,
submitting,
showNoteModal,
setShowNoteModal,
selectedTicket,
setSelectedTicket,
noteText,
setNoteText,
handleSaveNote,
previewHtml,
setPreviewHtml,
} = props;
return (
<>
{/* Mobile filter bottom sheet */}
<BottomSheet
open={mobileFilterOpen}
onClose={() => setMobileFilterOpen(false)}
title="Filter by Status"
>
<div className="space-y-1">
{[
{ value: 'all', label: `All (${ticketsCount})` },
{ value: 'pending', label: `Pending (${pendingCount})` },
{ value: 'confirmed', label: `Confirmed (${confirmedCount})` },
{ value: 'checked_in', label: `Checked In (${checkedInCount})` },
{ value: 'cancelled', label: `Cancelled (${cancelledCount})` },
].map((option) => (
<button
key={option.value}
onClick={() => { setStatusFilter(option.value as AttendeeStatusFilter); setMobileFilterOpen(false); }}
className={clsx(
'w-full text-left px-4 py-3 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
statusFilter === option.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
)}
>
{option.label}
{statusFilter === option.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
</button>
))}
</div>
</BottomSheet>
{/* Mobile FAB bottom sheet */}
<BottomSheet
open={showAddTicketSheet}
onClose={() => setShowAddTicketSheet(false)}
title="Add Ticket"
>
<div className="space-y-1">
<button
onClick={() => { setShowManualTicketModal(true); 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>
</div>
</button>
<button
onClick={() => { setShowAddAtDoorModal(true); 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" />
<div>
<p className="font-medium">Add at Door</p>
<p className="text-xs text-gray-500">Quick add with optional auto check-in</p>
</div>
</button>
<button
onClick={() => { setShowInviteGuestModal(true); 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" />
<div>
<p className="font-medium">Invite Guest</p>
<p className="text-xs text-gray-500">Free ticket, not counted in revenue</p>
</div>
</button>
</div>
</BottomSheet>
{/* Mobile export bottom sheet (attendees) */}
<BottomSheet
open={showExportSheet}
onClose={() => setShowExportSheet(false)}
title="Export Attendees"
>
<div className="space-y-1">
{[
{ status: 'all' as const, label: 'Export All' },
{ status: 'confirmed' as const, label: 'Export Confirmed' },
{ status: 'checked_in' as const, label: 'Export Checked-in' },
{ status: 'confirmed_pending' as const, label: 'Confirmed & Pending' },
].map((opt) => (
<button
key={opt.status}
onClick={() => { handleExportAttendees(opt.status); setShowExportSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px]"
>
{opt.label}
</button>
))}
<p className="text-[10px] text-gray-400 px-4 pt-2">Format: CSV</p>
</div>
</BottomSheet>
{/* Mobile export bottom sheet (tickets) */}
<BottomSheet
open={showTicketExportSheet}
onClose={() => setShowTicketExportSheet(false)}
title="Export Tickets"
>
<div className="space-y-1">
{[
{ status: 'all' as const, label: 'Export All' },
{ status: 'confirmed' as const, label: 'Export Valid' },
{ status: 'checked_in' as const, label: 'Export Checked-in' },
].map((opt) => (
<button
key={opt.status}
onClick={() => { handleExportTickets(opt.status); setShowTicketExportSheet(false); }}
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px]"
>
{opt.label}
</button>
))}
<p className="text-[10px] text-gray-400 px-4 pt-2">Format: CSV</p>
</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">
<Card className="w-full md:max-w-md rounded-t-2xl md:rounded-card">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
<div>
<h2 className="text-base font-bold">Admin Note</h2>
<p className="text-xs text-gray-500">{selectedTicket.attendeeFirstName} {selectedTicket.attendeeLastName || ''}</p>
</div>
<button onClick={() => { setShowNoteModal(false); setSelectedTicket(null); }}
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>
<div className="p-4 space-y-3">
<div>
<label className="block text-xs font-medium mb-1">Note</label>
<textarea value={noteText} onChange={(e) => setNoteText(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={4} placeholder="Add a private note..." maxLength={1000} />
<p className="text-[10px] text-gray-400 mt-1 text-right">{noteText.length}/1000</p>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={() => { setShowNoteModal(false); setSelectedTicket(null); }} className="flex-1 min-h-[44px]">Cancel</Button>
<Button onClick={handleSaveNote} isLoading={submitting} className="flex-1 min-h-[44px]">Save Note</Button>
</div>
</div>
</Card>
</div>
)}
{/* Preview Modal */}
{previewHtml && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
<h2 className="text-base font-bold">Email Preview</h2>
<Button variant="outline" size="sm" onClick={() => setPreviewHtml(null)} className="min-h-[44px] md:min-h-0">Close</Button>
</div>
<div className="flex-1 overflow-auto">
<iframe srcDoc={previewHtml} sandbox="" className="w-full h-full min-h-[500px]" title="Email Preview" />
</div>
</Card>
</div>
)}
</>
);
}
@@ -0,0 +1,355 @@
import { Ticket } from '@/lib/api';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
import clsx from 'clsx';
import {
MagnifyingGlassIcon,
ChevronDownIcon,
ArrowDownTrayIcon,
PlusIcon,
EnvelopeIcon,
StarIcon,
FunnelIcon,
ChatBubbleLeftIcon,
} from '@heroicons/react/24/outline';
import { StatusBadge } from '../_components/StatusBadge';
import type { AttendeeStatusFilter, PrimaryAction } from '../_types';
interface AttendeesTabProps {
locale: string;
tickets: Ticket[];
filteredTickets: Ticket[];
searchQuery: string;
setSearchQuery: (value: string) => void;
statusFilter: AttendeeStatusFilter;
setStatusFilter: (value: AttendeeStatusFilter) => void;
pendingCount: number;
confirmedCount: number;
checkedInCount: number;
cancelledCount: number;
exporting: boolean;
showExportDropdown: boolean;
setShowExportDropdown: (value: boolean) => void;
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;
setMobileFilterOpen: (value: boolean) => void;
setShowExportSheet: (value: boolean) => void;
setShowAddTicketSheet: (value: boolean) => void;
getPrimaryAction: (ticket: Ticket) => PrimaryAction | null;
handleOpenNoteModal: (ticket: Ticket) => void;
}
export function AttendeesTab({
locale,
tickets,
filteredTickets,
searchQuery,
setSearchQuery,
statusFilter,
setStatusFilter,
pendingCount,
confirmedCount,
checkedInCount,
cancelledCount,
exporting,
showExportDropdown,
setShowExportDropdown,
showAddTicketDropdown,
setShowAddTicketDropdown,
handleExportAttendees,
setShowManualTicketModal,
setShowAddAtDoorModal,
setShowInviteGuestModal,
setMobileFilterOpen,
setShowExportSheet,
setShowAddTicketSheet,
getPrimaryAction,
handleOpenNoteModal,
}: AttendeesTabProps) {
return (
<div className="space-y-3">
{/* Desktop toolbar */}
<Card className="p-3 hidden md:block">
<div className="flex items-center gap-3">
{/* Left: Search + Status */}
<div className="relative flex-1 max-w-sm">
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search name, email, phone..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-1.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as AttendeeStatusFilter)}
className="px-3 py-1.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
>
<option value="all">All ({tickets.length})</option>
<option value="pending">Pending ({pendingCount})</option>
<option value="confirmed">Confirmed ({confirmedCount})</option>
<option value="checked_in">Checked In ({checkedInCount})</option>
<option value="cancelled">Cancelled ({cancelledCount})</option>
</select>
<div className="flex-1" />
{/* Right: Export + Add Ticket dropdown */}
<Dropdown
open={showExportDropdown}
onOpenChange={setShowExportDropdown}
trigger={
<Button variant="outline" size="sm" disabled={exporting}>
{exporting ? (
<div className="w-3.5 h-3.5 mr-1.5 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
) : (
<ArrowDownTrayIcon className="w-3.5 h-3.5 mr-1.5" />
)}
Export
<ChevronDownIcon className="w-3 h-3 ml-1" />
</Button>
}
>
<DropdownItem onClick={() => handleExportAttendees('all')}>Export All</DropdownItem>
<DropdownItem onClick={() => handleExportAttendees('confirmed')}>Export Confirmed</DropdownItem>
<DropdownItem onClick={() => handleExportAttendees('checked_in')}>Export Checked-in</DropdownItem>
<DropdownItem onClick={() => handleExportAttendees('confirmed_pending')}>Confirmed &amp; Pending</DropdownItem>
<div className="border-t border-gray-100 mx-2" />
<div className="px-4 py-1.5 text-[10px] text-gray-400">Format: CSV</div>
</Dropdown>
<Dropdown
open={showAddTicketDropdown}
onOpenChange={setShowAddTicketDropdown}
trigger={
<Button size="sm">
<PlusIcon className="w-3.5 h-3.5 mr-1.5" />
Add Ticket
<ChevronDownIcon className="w-3 h-3 ml-1" />
</Button>
}
>
<DropdownItem onClick={() => { setShowManualTicketModal(true); setShowAddTicketDropdown(false); }}>
<EnvelopeIcon className="w-4 h-4 mr-2" /> Manual Ticket
</DropdownItem>
<DropdownItem onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketDropdown(false); }}>
<PlusIcon className="w-4 h-4 mr-2" /> Add at Door
</DropdownItem>
<DropdownItem onClick={() => { setShowInviteGuestModal(true); setShowAddTicketDropdown(false); }}>
<StarIcon className="w-4 h-4 mr-2" /> Invite Guest
</DropdownItem>
</Dropdown>
</div>
{(searchQuery || statusFilter !== 'all') && (
<div className="mt-2 text-xs text-gray-500 flex items-center gap-2">
<span>Showing {filteredTickets.length} of {tickets.length}</span>
<button onClick={() => { setSearchQuery(''); setStatusFilter('all'); }} className="text-primary-yellow hover:underline">
Clear
</button>
</div>
)}
</Card>
{/* Mobile toolbar */}
<div className="md:hidden space-y-2">
<div className="relative">
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search name, email, phone..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setMobileFilterOpen(true)}
className={clsx(
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
statusFilter !== 'all'
? 'border-primary-yellow bg-yellow-50 text-primary-dark'
: 'border-secondary-light-gray text-gray-600'
)}
>
<FunnelIcon className="w-4 h-4" />
{statusFilter === 'all' ? 'Filter' : statusFilter.replace('_', ' ')}
</button>
<button
onClick={() => setShowExportSheet(true)}
disabled={exporting}
className="flex items-center gap-1.5 px-3 py-2 rounded-btn border border-secondary-light-gray text-sm text-gray-600 min-h-[44px]"
>
<ArrowDownTrayIcon className="w-4 h-4" />
Export
</button>
{(searchQuery || statusFilter !== 'all') && (
<button
onClick={() => { setSearchQuery(''); setStatusFilter('all'); }}
className="text-xs text-primary-yellow ml-auto min-h-[44px] flex items-center"
>
Clear
</button>
)}
</div>
{(searchQuery || statusFilter !== 'all') && (
<p className="text-xs text-gray-500">Showing {filteredTickets.length} of {tickets.length}</p>
)}
</div>
{/* Desktop: Dense table */}
<Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Attendee</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Contact</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Booked</th>
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredTickets.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-10 text-center text-gray-500 text-sm">
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
</td>
</tr>
) : (
filteredTickets.map((ticket) => {
const primary = getPrimaryAction(ticket);
return (
<tr key={ticket.id} className="hover:bg-gray-50/50">
<td className="px-4 py-2.5">
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
{ticket.bookingId && (
<span className="text-[10px] text-purple-600" title={`Booking: ${ticket.bookingId}`}>
Group booking
</span>
)}
</td>
<td className="px-4 py-2.5">
<p className="text-sm text-gray-600 truncate max-w-[200px]">{ticket.attendeeEmail}</p>
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
</td>
<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>
)}
</div>
{ticket.checkinAt && (
<p className="text-[10px] text-gray-400 mt-0.5">
{parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}
</p>
)}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500">
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
</td>
<td className="px-4 py-2.5">
<div className="flex items-center justify-end gap-1">
{primary && (
<Button size="sm" variant={primary.variant} onClick={primary.onClick} className="text-xs px-2 py-1">
{primary.icon && <primary.icon className="w-3 h-3 mr-1" />}
{primary.label}
</Button>
)}
<MoreMenu>
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
</DropdownItem>
{ticket.adminNote && (
<div className="px-4 py-1.5 text-[10px] text-gray-400 truncate max-w-[180px]">
Note: {ticket.adminNote}
</div>
)}
<div className="px-4 py-1.5 text-[10px] text-gray-400 font-mono" title={ticket.id}>
ID: {ticket.id.slice(0, 8)}...
</div>
</MoreMenu>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</Card>
{/* Mobile: Card layout */}
<div className="md:hidden space-y-2">
{filteredTickets.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
</div>
) : (
filteredTickets.map((ticket) => {
const primary = getPrimaryAction(ticket);
return (
<Card key={ticket.id} className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
<p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail}</p>
{ticket.attendeePhone && <p className="text-[10px] text-gray-400">{ticket.attendeePhone}</p>}
</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>
)}
</div>
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400">
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
{ticket.checkinAt && ` · Checked in ${parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`}
</p>
<div className="flex items-center gap-1">
{primary && (
<Button size="sm" variant={primary.variant} onClick={primary.onClick} className="text-xs px-2.5 py-1.5 min-h-[36px]">
{primary.icon && <primary.icon className="w-3 h-3 mr-1" />}
{primary.label}
</Button>
)}
<MoreMenu>
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
</DropdownItem>
</MoreMenu>
</div>
</div>
</Card>
);
})
)}
</div>
{/* Mobile FAB */}
<div className="md:hidden fixed bottom-6 right-6 z-40">
<button
onClick={() => setShowAddTicketSheet(true)}
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform"
>
<PlusIcon className="w-6 h-6" />
</button>
</div>
</div>
);
}
@@ -0,0 +1,155 @@
import { EmailTemplate } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import clsx from 'clsx';
import {
EyeIcon,
PaperAirplaneIcon,
CheckCircleIcon,
ClockIcon,
TicketIcon,
XCircleIcon,
} from '@heroicons/react/24/outline';
type RecipientFilter = 'all' | 'confirmed' | 'pending' | 'checked_in';
interface EmailTabProps {
templates: EmailTemplate[];
selectedTemplate: string;
setSelectedTemplate: (value: string) => void;
recipientFilter: RecipientFilter;
setRecipientFilter: (value: RecipientFilter) => void;
customMessage: string;
setCustomMessage: (value: string) => void;
sending: boolean;
handlePreviewEmail: () => void;
handleSendEmail: () => void;
getFilteredRecipientCount: () => number;
ticketsCount: number;
confirmedCount: number;
pendingCount: number;
checkedInCount: number;
cancelledCount: number;
}
export function EmailTab({
templates,
selectedTemplate,
setSelectedTemplate,
recipientFilter,
setRecipientFilter,
customMessage,
setCustomMessage,
sending,
handlePreviewEmail,
handleSendEmail,
getFilteredRecipientCount,
ticketsCount,
confirmedCount,
pendingCount,
checkedInCount,
cancelledCount,
}: EmailTabProps) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="p-5">
<h3 className="font-semibold text-base mb-3">Send Email to Attendees</h3>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">Email Template</label>
<select
value={selectedTemplate}
onChange={(e) => setSelectedTemplate(e.target.value)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow text-sm"
>
<option value="">Select a template...</option>
{templates.map((template) => (
<option key={template.id} value={template.slug}>
{template.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Recipients</label>
<select
value={recipientFilter}
onChange={(e) => setRecipientFilter(e.target.value as RecipientFilter)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow text-sm"
>
<option value="all">All Attendees ({ticketsCount})</option>
<option value="confirmed">Confirmed Only ({confirmedCount})</option>
<option value="pending">Pending Only ({pendingCount})</option>
<option value="checked_in">Checked In Only ({checkedInCount})</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Custom Message (optional)</label>
<textarea
value={customMessage}
onChange={(e) => setCustomMessage(e.target.value)}
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow text-sm"
rows={3}
placeholder="Add a custom message that will be included in the email..."
/>
<p className="text-[10px] text-gray-500 mt-1">
This message will replace the {`{{customMessage}}`} variable in the template.
</p>
</div>
<div className="flex flex-wrap gap-2 pt-1">
<Button
variant="outline"
size="sm"
onClick={handlePreviewEmail}
disabled={!selectedTemplate}
className="min-h-[44px] md:min-h-0"
>
<EyeIcon className="w-4 h-4 mr-1.5" />
Preview
</Button>
<Button
size="sm"
onClick={handleSendEmail}
disabled={!selectedTemplate || getFilteredRecipientCount() === 0}
isLoading={sending}
className="min-h-[44px] md:min-h-0"
>
<PaperAirplaneIcon className="w-4 h-4 mr-1.5" />
Send to {getFilteredRecipientCount()} {getFilteredRecipientCount() === 1 ? 'person' : 'people'}
</Button>
</div>
</div>
</Card>
<Card className="p-5">
<h3 className="font-semibold text-base mb-3">Recipient Summary</h3>
<div className="space-y-2">
{[
{ label: 'Confirmed', count: confirmedCount, icon: CheckCircleIcon, color: 'text-green-500' },
{ label: 'Pending Payment', count: pendingCount, icon: ClockIcon, color: 'text-yellow-500' },
{ label: 'Checked In', count: checkedInCount, icon: TicketIcon, color: 'text-blue-500' },
{ label: 'Cancelled', count: cancelledCount, icon: XCircleIcon, color: 'text-red-500' },
].map((item) => (
<div key={item.label} className="flex items-center justify-between p-2.5 bg-gray-50 rounded-btn">
<div className="flex items-center gap-2">
<item.icon className={clsx('w-4 h-4', item.color)} />
<span className="text-sm">{item.label}</span>
</div>
<span className="font-semibold text-sm">{item.count}</span>
</div>
))}
<div className="border-t pt-2 mt-2">
<div className="flex items-center justify-between font-semibold text-sm">
<span>Total Bookings</span>
<span>{ticketsCount}</span>
</div>
</div>
</div>
</Card>
</div>
);
}
@@ -0,0 +1,83 @@
import { Event } from '@/lib/api';
import Card from '@/components/ui/Card';
import { CalendarIcon, MapPinIcon, CurrencyDollarIcon, UsersIcon } from '@heroicons/react/24/outline';
interface OverviewTabProps {
event: Event;
formatDate: (dateStr: string) => string;
fmtTime: (dateStr: string) => string;
formatCurrency: (amount: number, currency: string) => string;
confirmedCount: number;
checkedInCount: number;
}
export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confirmedCount, checkedInCount }: OverviewTabProps) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="p-5">
<h3 className="font-semibold text-base mb-3">Event Information</h3>
<div className="space-y-3">
<div className="flex items-start gap-3">
<CalendarIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm">Date & Time</p>
<p className="text-sm text-gray-600">{formatDate(event.startDatetime)}</p>
<p className="text-sm text-gray-600">{fmtTime(event.startDatetime)}{event.endDatetime && ` - ${fmtTime(event.endDatetime)}`}</p>
</div>
</div>
<div className="flex items-start gap-3">
<MapPinIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm">Location</p>
<p className="text-sm text-gray-600">{event.location}</p>
{event.locationUrl && (
<a href={event.locationUrl} target="_blank" rel="noopener" className="text-blue-600 text-xs hover:underline">
View on Map
</a>
)}
</div>
</div>
<div className="flex items-start gap-3">
<CurrencyDollarIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-sm">Price</p>
<p className="text-sm text-gray-600">{event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}</p>
</div>
</div>
<div className="flex items-start gap-3">
<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>
</div>
</div>
</div>
</Card>
<Card className="p-5">
<h3 className="font-semibold text-base mb-3">Description</h3>
<div className="prose prose-sm max-w-none">
<p className="text-sm text-gray-600 whitespace-pre-wrap">{event.description}</p>
{event.descriptionEs && (
<>
<p className="font-medium text-sm mt-3">Spanish:</p>
<p className="text-sm text-gray-600 whitespace-pre-wrap">{event.descriptionEs}</p>
</>
)}
</div>
</Card>
{event.bannerUrl && (
<Card className="p-5 lg:col-span-2">
<h3 className="font-semibold text-base mb-3">Event Banner</h3>
<img
src={event.bannerUrl}
alt={event.title}
className="w-full max-h-64 object-cover rounded-lg"
/>
</Card>
)}
</div>
);
}
@@ -0,0 +1,406 @@
import { PaymentOptionsConfig } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import clsx from 'clsx';
import {
CreditCardIcon,
BuildingLibraryIcon,
BoltIcon,
BanknotesIcon,
ArrowPathIcon,
CheckCircleIcon,
XCircleIcon,
} from '@heroicons/react/24/outline';
import type { PaymentOverridesController } from '../_hooks/usePaymentOverrides';
interface PaymentsTabProps {
locale: string;
payments: PaymentOverridesController;
}
export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
const {
loadingPayments,
hasPaymentOverrides,
savingPayments,
globalPaymentOptions,
paymentOverrides,
getEffectivePaymentOption,
updatePaymentOverride,
handleResetToGlobal,
handleSavePaymentOptions,
} = payments;
return (
<div className="space-y-4">
{loadingPayments ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
) : (
<>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
<h3 className="font-semibold text-base">
{locale === 'es' ? 'Métodos de Pago del Evento' : 'Event Payment Methods'}
</h3>
<p className="text-xs text-gray-500">
{hasPaymentOverrides
? (locale === 'es' ? 'Configuración personalizada' : 'Custom settings')
: (locale === 'es' ? 'Usando configuración global' : 'Using global settings')}
</p>
</div>
<div className="flex items-center gap-2">
{hasPaymentOverrides && (
<Button variant="outline" size="sm" onClick={handleResetToGlobal} disabled={savingPayments} className="min-h-[44px] md:min-h-0">
<ArrowPathIcon className="w-4 h-4 mr-1.5" />
{locale === 'es' ? 'Resetear' : 'Reset'}
</Button>
)}
<Button size="sm" onClick={handleSavePaymentOptions} isLoading={savingPayments} className="min-h-[44px] md:min-h-0">
{locale === 'es' ? 'Guardar' : 'Save'}
</Button>
</div>
</div>
{/* TPago */}
<Card>
<div className="p-4 md:p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
<CreditCardIcon className="w-4 h-4 text-blue-600" />
</div>
<div>
<h4 className="font-semibold text-sm">
{locale === 'es' ? 'TPago / Tarjeta' : 'TPago / Card'}
</h4>
<p className="text-[10px] text-gray-500">
{locale === 'es' ? 'Requiere aprobación' : 'Requires approval'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{globalPaymentOptions && !globalPaymentOptions.tpagoEnabled && (
<span className="text-[10px] text-gray-400 hidden sm:inline">
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
</span>
)}
<button
onClick={() => updatePaymentOverride('tpagoEnabled', !getEffectivePaymentOption('tpagoEnabled'))}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
getEffectivePaymentOption('tpagoEnabled') ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
getEffectivePaymentOption('tpagoEnabled') ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
</div>
{getEffectivePaymentOption('tpagoEnabled') && (
<div className="space-y-3 pt-3 border-t">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
{locale === 'es' ? 'Enlaces de Pago TPago (por cantidad de tickets)' : 'TPago Payment Links (per ticket quantity)'}
</label>
<p className="text-[10px] text-gray-500 mb-2">
{locale === 'es'
? 'Cada enlace tiene un monto fijo. Un enlace distinto por cantidad de tickets.'
: 'Each link has a fixed amount. One link per ticket quantity.'}
</p>
<div className="space-y-2">
{([1, 2, 3, 4, 5] as const).map((qty) => {
const key = (qty === 1 ? 'tpagoLink' : `tpagoLink${qty}`) as keyof PaymentOptionsConfig;
return (
<div key={qty} className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-600 w-20 flex-shrink-0">
{qty} {qty === 1 ? 'ticket' : 'tickets'}
</span>
<input
type="url"
value={(paymentOverrides[key] as string | null) ?? ''}
onChange={(e) => updatePaymentOverride(key, (e.target.value || null) as any)}
placeholder={(globalPaymentOptions?.[key] as string | null) || 'https://www.tpago.com.py/links?alias=...'}
className="flex-1 px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
);
})}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Instructions (EN)</label>
<textarea
value={paymentOverrides.tpagoInstructions ?? ''}
onChange={(e) => updatePaymentOverride('tpagoInstructions', e.target.value || null)}
rows={2}
placeholder={globalPaymentOptions?.tpagoInstructions || 'Instructions for users...'}
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Instrucciones (ES)</label>
<textarea
value={paymentOverrides.tpagoInstructionsEs ?? ''}
onChange={(e) => updatePaymentOverride('tpagoInstructionsEs', e.target.value || null)}
rows={2}
placeholder={globalPaymentOptions?.tpagoInstructionsEs || 'Instrucciones para usuarios...'}
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
</div>
<p className="text-[10px] text-gray-400">
{locale === 'es' ? 'Vacío = configuración global' : 'Empty = global settings'}
</p>
</div>
)}
</div>
</Card>
{/* Bank Transfer */}
<Card>
<div className="p-4 md:p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center flex-shrink-0">
<BuildingLibraryIcon className="w-4 h-4 text-green-600" />
</div>
<div>
<h4 className="font-semibold text-sm">
{locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer'}
</h4>
<p className="text-[10px] text-gray-500">
{locale === 'es' ? 'Requiere aprobación' : 'Requires approval'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{globalPaymentOptions && !globalPaymentOptions.bankTransferEnabled && (
<span className="text-[10px] text-gray-400 hidden sm:inline">
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
</span>
)}
<button
onClick={() => updatePaymentOverride('bankTransferEnabled', !getEffectivePaymentOption('bankTransferEnabled'))}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
getEffectivePaymentOption('bankTransferEnabled') ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
getEffectivePaymentOption('bankTransferEnabled') ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
</div>
{getEffectivePaymentOption('bankTransferEnabled') && (
<div className="space-y-3 pt-3 border-t">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{[
{ label: locale === 'es' ? 'Banco' : 'Bank Name', key: 'bankName' as const, placeholder: 'e.g., Banco Itaú' },
{ label: locale === 'es' ? 'Titular' : 'Account Holder', key: 'bankAccountHolder' as const, placeholder: 'e.g., Juan Pérez' },
{ label: locale === 'es' ? 'N° Cuenta' : 'Account Number', key: 'bankAccountNumber' as const, placeholder: 'e.g., 1234567890' },
{ label: 'Alias', key: 'bankAlias' as const, placeholder: 'e.g., spanglish.pagos' },
{ label: locale === 'es' ? 'Teléfono' : 'Phone', key: 'bankPhone' as const, placeholder: '+595 981 123456' },
].map((field) => (
<div key={field.key}>
<label className="block text-xs font-medium text-gray-700 mb-1">{field.label}</label>
<input
type="text"
value={(paymentOverrides as any)[field.key] ?? ''}
onChange={(e) => updatePaymentOverride(field.key, e.target.value || null)}
placeholder={(globalPaymentOptions as any)?.[field.key] || field.placeholder}
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Notes (EN)</label>
<textarea
value={paymentOverrides.bankNotes ?? ''}
onChange={(e) => updatePaymentOverride('bankNotes', e.target.value || null)}
rows={2}
placeholder={globalPaymentOptions?.bankNotes || 'Additional notes...'}
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Notas (ES)</label>
<textarea
value={paymentOverrides.bankNotesEs ?? ''}
onChange={(e) => updatePaymentOverride('bankNotesEs', e.target.value || null)}
rows={2}
placeholder={globalPaymentOptions?.bankNotesEs || 'Notas adicionales...'}
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
</div>
<p className="text-[10px] text-gray-400">
{locale === 'es' ? 'Vacío = configuración global' : 'Empty = global settings'}
</p>
</div>
)}
</div>
</Card>
{/* Bitcoin Lightning */}
<Card>
<div className="p-4 md:p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
<BoltIcon className="w-4 h-4 text-orange-600" />
</div>
<div>
<h4 className="font-semibold text-sm">Bitcoin Lightning</h4>
<p className="text-[10px] text-gray-500">
{locale === 'es' ? 'Confirmación automática' : 'Auto confirmation'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{globalPaymentOptions && !globalPaymentOptions.lightningEnabled && (
<span className="text-[10px] text-gray-400 hidden sm:inline">
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
</span>
)}
<button
onClick={() => updatePaymentOverride('lightningEnabled', !getEffectivePaymentOption('lightningEnabled'))}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
getEffectivePaymentOption('lightningEnabled') ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
getEffectivePaymentOption('lightningEnabled') ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
</div>
{getEffectivePaymentOption('lightningEnabled') && (
<div className="pt-3 border-t mt-3">
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
<p className="text-xs text-orange-800">
{locale === 'es'
? 'Lightning configurado vía LNbits. No personalizable por evento.'
: 'Lightning is configured via LNbits. Cannot be customized per event.'}
</p>
</div>
</div>
)}
</div>
</Card>
{/* Cash at Door */}
<Card>
<div className="p-4 md:p-5">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-yellow-100 rounded-full flex items-center justify-center flex-shrink-0">
<BanknotesIcon className="w-4 h-4 text-yellow-600" />
</div>
<div>
<h4 className="font-semibold text-sm">
{locale === 'es' ? 'Efectivo' : 'Cash at Door'}
</h4>
<p className="text-[10px] text-gray-500">
{locale === 'es' ? 'Requiere aprobación' : 'Requires approval'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{globalPaymentOptions && !globalPaymentOptions.cashEnabled && (
<span className="text-[10px] text-gray-400 hidden sm:inline">
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
</span>
)}
<button
onClick={() => updatePaymentOverride('cashEnabled', !getEffectivePaymentOption('cashEnabled'))}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
getEffectivePaymentOption('cashEnabled') ? 'bg-primary-yellow' : 'bg-gray-300'
}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
getEffectivePaymentOption('cashEnabled') ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
</div>
</div>
{getEffectivePaymentOption('cashEnabled') && (
<div className="space-y-3 pt-3 border-t">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Instructions (EN)</label>
<textarea
value={paymentOverrides.cashInstructions ?? ''}
onChange={(e) => updatePaymentOverride('cashInstructions', e.target.value || null)}
rows={2}
placeholder={globalPaymentOptions?.cashInstructions || 'Cash payment instructions...'}
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Instrucciones (ES)</label>
<textarea
value={paymentOverrides.cashInstructionsEs ?? ''}
onChange={(e) => updatePaymentOverride('cashInstructionsEs', e.target.value || null)}
rows={2}
placeholder={globalPaymentOptions?.cashInstructionsEs || 'Instrucciones de pago en efectivo...'}
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
</div>
<p className="text-[10px] text-gray-400">
{locale === 'es' ? 'Vacío = configuración global' : 'Empty = global settings'}
</p>
</div>
)}
</div>
</Card>
{/* Summary */}
<Card>
<div className="p-4 md:p-5">
<h4 className="font-semibold text-sm mb-3">
{locale === 'es' ? 'Resumen' : 'Active Methods'}
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: 'TPago', enabled: getEffectivePaymentOption('tpagoEnabled') },
{ label: locale === 'es' ? 'Transferencia' : 'Bank Transfer', enabled: getEffectivePaymentOption('bankTransferEnabled') },
{ label: 'Lightning', enabled: getEffectivePaymentOption('lightningEnabled') },
{ label: locale === 'es' ? 'Efectivo' : 'Cash', enabled: getEffectivePaymentOption('cashEnabled') },
].map((method) => (
<div key={method.label} className="flex items-center gap-1.5">
{method.enabled ? (
<CheckCircleIcon className="w-4 h-4 text-green-500" />
) : (
<XCircleIcon className="w-4 h-4 text-gray-300" />
)}
<span className={clsx('text-sm', method.enabled ? 'text-gray-900' : 'text-gray-400')}>
{method.label}
</span>
</div>
))}
</div>
{hasPaymentOverrides && (
<p className="text-[10px] text-gray-500 mt-3 flex items-center gap-1">
<span className="inline-block w-1.5 h-1.5 bg-primary-yellow rounded-full" />
{locale === 'es'
? 'Configuración personalizada activa'
: 'Custom settings override global defaults'}
</p>
)}
</div>
</Card>
</>
)}
</div>
);
}
@@ -0,0 +1,258 @@
import { Ticket } from '@/lib/api';
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
import {
MagnifyingGlassIcon,
ChevronDownIcon,
ArrowDownTrayIcon,
ArrowUturnLeftIcon,
} from '@heroicons/react/24/outline';
import type { TicketStatusFilter } from '../_types';
interface TicketsTabProps {
locale: string;
confirmedTickets: Ticket[];
filteredConfirmedTickets: Ticket[];
ticketSearchQuery: string;
setTicketSearchQuery: (value: string) => void;
ticketStatusFilter: TicketStatusFilter;
setTicketStatusFilter: (value: TicketStatusFilter) => void;
confirmedCount: number;
checkedInCount: number;
exporting: boolean;
showTicketExportDropdown: boolean;
setShowTicketExportDropdown: (value: boolean) => void;
handleExportTickets: (status: 'confirmed' | 'checked_in' | 'all') => void;
handleCheckin: (ticketId: string) => void;
handleRemoveCheckin: (ticketId: string) => void;
setShowTicketExportSheet: (value: boolean) => void;
}
export function TicketsTab({
locale,
confirmedTickets,
filteredConfirmedTickets,
ticketSearchQuery,
setTicketSearchQuery,
ticketStatusFilter,
setTicketStatusFilter,
confirmedCount,
checkedInCount,
exporting,
showTicketExportDropdown,
setShowTicketExportDropdown,
handleExportTickets,
handleCheckin,
handleRemoveCheckin,
setShowTicketExportSheet,
}: TicketsTabProps) {
return (
<div className="space-y-3">
{/* Desktop toolbar */}
<Card className="p-3 hidden md:block">
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search by name or ticket ID..."
value={ticketSearchQuery}
onChange={(e) => setTicketSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-1.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<select
value={ticketStatusFilter}
onChange={(e) => setTicketStatusFilter(e.target.value as TicketStatusFilter)}
className="px-3 py-1.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
>
<option value="all">All ({confirmedTickets.length})</option>
<option value="confirmed">Valid ({confirmedCount})</option>
<option value="checked_in">Checked In ({checkedInCount})</option>
</select>
<div className="flex-1" />
<Dropdown
open={showTicketExportDropdown}
onOpenChange={setShowTicketExportDropdown}
trigger={
<Button variant="outline" size="sm" disabled={exporting}>
<ArrowDownTrayIcon className="w-3.5 h-3.5 mr-1.5" />
Export
<ChevronDownIcon className="w-3 h-3 ml-1" />
</Button>
}
>
<DropdownItem onClick={() => handleExportTickets('all')}>Export All</DropdownItem>
<DropdownItem onClick={() => handleExportTickets('confirmed')}>Export Valid</DropdownItem>
<DropdownItem onClick={() => handleExportTickets('checked_in')}>Export Checked-in</DropdownItem>
<div className="border-t border-gray-100 mx-2" />
<div className="px-4 py-1.5 text-[10px] text-gray-400">Format: CSV</div>
</Dropdown>
</div>
{(ticketSearchQuery || ticketStatusFilter !== 'all') && (
<div className="mt-2 text-xs text-gray-500 flex items-center gap-2">
<span>Showing {filteredConfirmedTickets.length} of {confirmedTickets.length}</span>
<button onClick={() => { setTicketSearchQuery(''); setTicketStatusFilter('all'); }} className="text-primary-yellow hover:underline">Clear</button>
</div>
)}
</Card>
{/* Mobile toolbar */}
<div className="md:hidden space-y-2">
<div className="relative">
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search by name or ticket ID..."
value={ticketSearchQuery}
onChange={(e) => setTicketSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
/>
</div>
<div className="flex items-center gap-2">
<select
value={ticketStatusFilter}
onChange={(e) => setTicketStatusFilter(e.target.value as TicketStatusFilter)}
className="px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow min-h-[44px]"
>
<option value="all">All ({confirmedTickets.length})</option>
<option value="confirmed">Valid ({confirmedCount})</option>
<option value="checked_in">Checked In ({checkedInCount})</option>
</select>
<button
onClick={() => setShowTicketExportSheet(true)}
disabled={exporting}
className="flex items-center gap-1.5 px-3 py-2 rounded-btn border border-secondary-light-gray text-sm text-gray-600 min-h-[44px]"
>
<ArrowDownTrayIcon className="w-4 h-4" />
Export
</button>
</div>
</div>
{/* Desktop: Dense table */}
<Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Attendee</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Check-in</th>
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredConfirmedTickets.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-10 text-center text-gray-500 text-sm">
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
</td>
</tr>
) : (
filteredConfirmedTickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-gray-50/50">
<td className="px-4 py-2.5">
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
{ticket.bookingId && (
<span className="text-[10px] text-purple-600">Group booking</span>
)}
</td>
<td className="px-4 py-2.5">
{ticket.status === 'confirmed' ? (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-green-100 text-green-800 font-medium">Valid</span>
) : (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-blue-100 text-blue-800 font-medium">Checked In</span>
)}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500">
{ticket.checkinAt ? (
parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE,
})
) : (
<span className="text-gray-300"></span>
)}
</td>
<td className="px-4 py-2.5">
<div className="flex items-center justify-end gap-1">
{ticket.status === 'confirmed' && (
<Button size="sm" onClick={() => handleCheckin(ticket.id)} className="text-xs px-2 py-1">
Check In
</Button>
)}
{ticket.status === 'checked_in' && (
<Button size="sm" variant="outline" onClick={() => handleRemoveCheckin(ticket.id)} className="text-xs px-2 py-1">
<ArrowUturnLeftIcon className="w-3 h-3 mr-1" />
Undo
</Button>
)}
<MoreMenu>
<div className="px-4 py-1.5 text-[10px] text-gray-400 font-mono" title={ticket.id}>
ID: {ticket.id.slice(0, 8)}...
</div>
{ticket.bookingId && (
<div className="px-4 py-1.5 text-[10px] text-purple-500 font-mono" title={ticket.bookingId}>
Booking: {ticket.bookingId.slice(0, 8)}...
</div>
)}
</MoreMenu>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
{/* Mobile: Card layout */}
<div className="md:hidden space-y-2">
{filteredConfirmedTickets.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
</div>
) : (
filteredConfirmedTickets.map((ticket) => (
<Card key={ticket.id} className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
{ticket.bookingId && <p className="text-[10px] text-purple-600">Group booking</p>}
</div>
{ticket.status === 'confirmed' ? (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-green-100 text-green-800 font-medium flex-shrink-0">Valid</span>
) : (
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-blue-100 text-blue-800 font-medium flex-shrink-0">Checked In</span>
)}
</div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-[10px] text-gray-400">
{ticket.checkinAt
? `Checked in ${parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`
: 'Not checked in'}
</p>
<div className="flex items-center gap-1">
{ticket.status === 'confirmed' && (
<Button size="sm" onClick={() => handleCheckin(ticket.id)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
Check In
</Button>
)}
{ticket.status === 'checked_in' && (
<Button size="sm" variant="outline" onClick={() => handleRemoveCheckin(ticket.id)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
<ArrowUturnLeftIcon className="w-3 h-3 mr-1" />
Undo
</Button>
)}
</div>
</div>
</Card>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,26 @@
import type { ComponentType } from 'react';
export type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
export type AttendeeStatusFilter = 'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled';
export type TicketStatusFilter = 'all' | 'confirmed' | 'checked_in';
export type RecipientFilter = 'all' | 'confirmed' | 'pending' | 'checked_in';
export interface PrimaryAction {
label: string;
onClick: () => void;
variant: 'outline' | 'primary';
icon?: ComponentType<{ className?: string }>;
}
export interface AttendeeFormState {
firstName: string;
lastName: string;
email: string;
phone: string;
adminNote: string;
}
export interface AddAtDoorFormState extends AttendeeFormState {
autoCheckin: boolean;
}
@@ -0,0 +1,20 @@
// Pure formatting/IO helpers for the admin event detail page.
export function formatCurrency(amount: number, currency: string): string {
if (currency === 'PYG') {
return `${amount.toLocaleString('es-PY')} PYG`;
}
return `$${amount.toFixed(2)} ${currency}`;
}
/** Trigger a browser download for a blob with the given filename. */
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
File diff suppressed because it is too large Load Diff
@@ -26,6 +26,10 @@ export default function PaymentOptionsPage() {
const [options, setOptions] = useState<PaymentOptionsConfig>({
tpagoEnabled: false,
tpagoLink: null,
tpagoLink2: null,
tpagoLink3: null,
tpagoLink4: null,
tpagoLink5: null,
tpagoInstructions: null,
tpagoInstructionsEs: null,
bankTransferEnabled: false,
@@ -140,13 +144,31 @@ export default function PaymentOptionsPage() {
<div className="space-y-4 pt-4 border-t">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{locale === 'es' ? 'Enlace de Pago TPago' : 'TPago Payment Link'}
{locale === 'es' ? 'Enlaces de Pago TPago (por cantidad de tickets)' : 'TPago Payment Links (per ticket quantity)'}
</label>
<Input
value={options.tpagoLink || ''}
onChange={(e) => updateOption('tpagoLink', e.target.value || null)}
placeholder="https://www.tpago.com.py/links?alias=..."
/>
<p className="text-xs text-gray-500 mb-2">
{locale === 'es'
? 'Cada enlace tiene un monto fijo. Usá un enlace distinto para cada cantidad de tickets.'
: 'Each link has a fixed amount. Use a different link for each ticket quantity.'}
</p>
<div className="space-y-2">
{([1, 2, 3, 4, 5] as const).map((qty) => {
const key = (qty === 1 ? 'tpagoLink' : `tpagoLink${qty}`) as keyof PaymentOptionsConfig;
return (
<div key={qty} className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-600 w-24 flex-shrink-0">
{qty} {locale === 'es' ? (qty === 1 ? 'ticket' : 'tickets') : (qty === 1 ? 'ticket' : 'tickets')}
</span>
<Input
value={(options[key] as string | null) || ''}
onChange={(e) => updateOption(key, (e.target.value || null) as any)}
placeholder="https://www.tpago.com.py/links?alias=..."
className="flex-1"
/>
</div>
);
})}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
@@ -0,0 +1,44 @@
// Streaming proxy for the backend LNbits SSE endpoint.
//
// Next.js `rewrites` buffer `text/event-stream` responses, so payment events
// never reach the browser in real time when going through the same-origin proxy.
// This route handler streams the backend response body straight through with
// anti-buffering headers, so EventSource on the booking page works in dev and
// proxyless deployments. A route handler takes precedence over the rewrite.
export const dynamic = 'force-dynamic';
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3001';
export async function GET(
request: Request,
{ params }: { params: { ticketId: string } }
) {
const upstream = await fetch(
`${BACKEND_URL}/api/lnbits/stream/${params.ticketId}`,
{
headers: { Accept: 'text/event-stream' },
signal: request.signal,
}
);
// If the ticket is already confirmed the backend may answer with JSON instead
// of a stream; pass that through unchanged so the client can handle it.
const contentType = upstream.headers.get('content-type') || '';
if (!contentType.includes('text/event-stream')) {
return new Response(upstream.body, {
status: upstream.status,
headers: { 'Content-Type': contentType || 'application/json' },
});
}
return new Response(upstream.body, {
status: upstream.status,
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
}
+15 -2
View File
@@ -1,14 +1,27 @@
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
import { timingSafeEqual } from 'crypto';
// Constant-time string comparison to avoid leaking the secret via response timing.
function secretsMatch(provided: unknown, expected: string): boolean {
if (typeof provided !== 'string' || provided.length === 0) return false;
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { secret, tag } = body;
// Validate the revalidation secret
// Validate the revalidation secret. Reject if it is unset or left at an insecure default.
const revalidateSecret = process.env.REVALIDATE_SECRET;
if (!revalidateSecret || secret !== revalidateSecret) {
if (!revalidateSecret || revalidateSecret === 'change-me' || revalidateSecret.length < 16) {
return NextResponse.json({ error: 'Revalidation is not configured' }, { status: 503 });
}
if (!secretsMatch(secret, revalidateSecret)) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 });
}
+12
View File
@@ -58,6 +58,18 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: 'daily',
priority: 0.9,
},
{
url: `${siteUrl}/next`,
lastModified: now,
changeFrequency: 'daily',
priority: 0.8,
},
{
url: `${siteUrl}/featured`,
lastModified: now,
changeFrequency: 'daily',
priority: 0.8,
},
{
url: `${siteUrl}/community`,
lastModified: now,
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useLanguage } from '@/context/LanguageContext';
import { safeInternalPath } from '@/lib/safeRedirect';
import toast from 'react-hot-toast';
declare global {
@@ -82,10 +83,9 @@ export default function GoogleSignInButton({
toast.success(locale === 'es' ? 'Bienvenido!' : 'Welcome!');
onSuccess?.();
// Use window.location for navigation to ensure clean state
if (redirectTo) {
window.location.href = redirectTo;
}
// Use window.location for navigation to ensure clean state.
// Constrain to a same-origin path to avoid open redirects.
window.location.href = safeInternalPath(redirectTo, '/dashboard');
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Google login failed';
const displayError = locale === 'es' ? 'Error al iniciar sesion con Google' : errorMessage;
@@ -21,6 +21,17 @@ function extractLastUpdated(contentMarkdown: string, updatedAt?: string): string
return match ? match[1].trim() : updatedAt;
}
// Only permit safe link schemes. Anything else (javascript:, data:, etc.) is dropped
// so a malicious markdown link can't execute script when clicked.
function sanitizeHref(href?: string): string | undefined {
if (!href) return undefined;
const trimmed = href.trim();
// Allow relative/anchor/protocol-relative-safe links
if (trimmed.startsWith('/') || trimmed.startsWith('#')) return trimmed;
if (/^(https?:|mailto:|tel:)/i.test(trimmed)) return trimmed;
return undefined;
}
export default function LegalPageLayout({
slug,
initialLocale,
@@ -141,17 +152,20 @@ export default function LegalPageLayout({
{children}
</li>
),
// Style links
a: ({ href, children }) => (
<a
href={href}
className="text-primary-dark underline hover:text-primary-yellow transition-colors"
target={href?.startsWith('http') ? '_blank' : undefined}
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{children}
</a>
),
// Style links (with scheme allowlist to block javascript:/data: URLs)
a: ({ href, children }) => {
const safeHref = sanitizeHref(href);
return (
<a
href={safeHref}
className="text-primary-dark underline hover:text-primary-yellow transition-colors"
target={safeHref?.startsWith('http') ? '_blank' : undefined}
rel={safeHref?.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{children}
</a>
);
},
// Style horizontal rules
hr: () => (
<hr className="my-8 border-gray-200" />
@@ -14,11 +14,18 @@ interface RichTextEditorProps {
editable?: boolean;
}
// Escape HTML-significant characters so any raw HTML embedded in the markdown source
// is neutralised before we layer our own generated tags on top (defense-in-depth;
// the public renderer escapes too, and TipTap sanitizes via its schema).
function escapeRawHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Convert markdown to HTML for TipTap
function markdownToHtml(markdown: string): string {
if (!markdown) return '<p></p>';
let html = markdown;
let html = escapeRawHtml(markdown);
// Convert horizontal rules first (before other processing)
html = html.replace(/^---+$/gm, '<hr>');
+43 -4
View File
@@ -44,6 +44,17 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
const TOKEN_KEY = 'spanglish-token';
const USER_KEY = 'spanglish-user';
const AUTH_COOKIE = 'spanglish-auth';
function setAuthCookie() {
if (typeof document === 'undefined') return;
document.cookie = `${AUTH_COOKIE}=1; path=/; max-age=${60 * 60 * 24}; SameSite=Lax`;
}
function clearAuthCookie() {
if (typeof document === 'undefined') return;
document.cookie = `${AUTH_COOKIE}=; path=/; max-age=0; SameSite=Lax`;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
@@ -66,12 +77,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const data = await res.json();
setUser(data.user);
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
setAuthCookie();
} else if (res.status === 401) {
// Token is invalid, clear auth state
setToken(null);
setUser(null);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
clearAuthCookie();
}
} catch (error) {
// Network error, keep using cached data
@@ -85,10 +98,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const savedUser = localStorage.getItem(USER_KEY);
if (savedToken && savedUser) {
setToken(savedToken);
setUser(JSON.parse(savedUser));
// Refresh user data from server to get latest role/permissions
refreshUser().finally(() => setIsLoading(false));
// Guard against corrupt/tampered localStorage so the whole app doesn't crash.
let parsedUser: User | null = null;
try {
parsedUser = JSON.parse(savedUser);
} catch {
parsedUser = null;
}
if (parsedUser) {
setToken(savedToken);
setUser(parsedUser);
// Refresh user data from server to get latest role/permissions (source of truth)
refreshUser().finally(() => setIsLoading(false));
} else {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
setIsLoading(false);
}
} else {
setIsLoading(false);
}
@@ -99,6 +126,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(data.user);
localStorage.setItem(TOKEN_KEY, data.token);
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
setAuthCookie();
}, []);
const login = async (email: string, password: string) => {
@@ -166,10 +194,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
const logout = useCallback(() => {
// Best-effort server-side invalidation (bumps token version so the JWT can't be reused).
const currentToken = localStorage.getItem(TOKEN_KEY);
if (currentToken) {
fetch(`${API_BASE}/api/auth/logout`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${currentToken}` },
}).catch(() => {
// Ignore network errors; local state is cleared regardless.
});
}
setToken(null);
setUser(null);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
clearAuthCookie();
}, []);
const updateUser = useCallback((updatedUser: User) => {
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
import { fetchApi, fetchBlob } from './client';
import type {
DashboardData,
AnalyticsData,
ExportedTicket,
ExportedPayment,
FinancialSummary,
} from './types';
export const adminApi = {
getDashboard: () => fetchApi<{ dashboard: DashboardData }>('/api/admin/dashboard'),
getAnalytics: () => fetchApi<{ analytics: AnalyticsData }>('/api/admin/analytics'),
exportTickets: (eventId?: string) => {
const query = eventId ? `?eventId=${eventId}` : '';
return fetchApi<{ tickets: ExportedTicket[] }>(`/api/admin/export/tickets${query}`);
},
exportFinancial: (params?: { startDate?: string; endDate?: string; eventId?: string }) => {
const query = new URLSearchParams();
if (params?.startDate) query.set('startDate', params.startDate);
if (params?.endDate) query.set('endDate', params.endDate);
if (params?.eventId) query.set('eventId', params.eventId);
return fetchApi<{ payments: ExportedPayment[]; summary: FinancialSummary }>(`/api/admin/export/financial?${query}`);
},
/** Download attendee export as a file (CSV). Returns a Blob. */
exportAttendees: (eventId: string, params?: { status?: string; format?: string; q?: string }) => {
const query = new URLSearchParams();
if (params?.status) query.set('status', params.status);
if (params?.format) query.set('format', params.format);
if (params?.q) query.set('q', params.q);
return fetchBlob(
`/api/admin/events/${eventId}/attendees/export?${query}`,
`attendees-${new Date().toISOString().split('T')[0]}.csv`
);
},
/** Download tickets export as CSV. Returns a Blob. */
exportTicketsCSV: (eventId: string, params?: { status?: string; q?: string }) => {
const query = new URLSearchParams();
if (params?.status) query.set('status', params.status);
if (params?.q) query.set('q', params.q);
return fetchBlob(
`/api/admin/events/${eventId}/tickets/export?${query}`,
`tickets-${new Date().toISOString().split('T')[0]}.csv`
);
},
};
+63
View File
@@ -0,0 +1,63 @@
import { fetchApi } from './client';
import type { User } from './types';
export const authApi = {
// Magic link
requestMagicLink: (email: string) =>
fetchApi<{ message: string }>('/api/auth/magic-link/request', {
method: 'POST',
body: JSON.stringify({ email }),
}),
verifyMagicLink: (token: string) =>
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/magic-link/verify', {
method: 'POST',
body: JSON.stringify({ token }),
}),
// Password reset
requestPasswordReset: (email: string) =>
fetchApi<{ message: string }>('/api/auth/password-reset/request', {
method: 'POST',
body: JSON.stringify({ email }),
}),
confirmPasswordReset: (token: string, password: string) =>
fetchApi<{ message: string }>('/api/auth/password-reset/confirm', {
method: 'POST',
body: JSON.stringify({ token, password }),
}),
// Account claiming
requestClaimAccount: (email: string) =>
fetchApi<{ message: string }>('/api/auth/claim-account/request', {
method: 'POST',
body: JSON.stringify({ email }),
}),
confirmClaimAccount: (token: string, data: { password?: string; googleId?: string }) =>
fetchApi<{ user: User; token: string; refreshToken: string; message: string }>(
'/api/auth/claim-account/confirm',
{
method: 'POST',
body: JSON.stringify({ token, ...data }),
}
),
// Google OAuth
googleAuth: (credential: string) =>
fetchApi<{ user: User; token: string; refreshToken: string }>('/api/auth/google', {
method: 'POST',
body: JSON.stringify({ credential }),
}),
// Change password
changePassword: (currentPassword: string, newPassword: string) =>
fetchApi<{ message: string }>('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
}),
// Get current user
me: () => fetchApi<{ user: User }>('/api/auth/me'),
};
+66
View File
@@ -0,0 +1,66 @@
export const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
export interface ApiError {
error: string;
}
/** Read the stored auth token (browser only). */
export function getToken(): string | null {
return typeof window !== 'undefined' ? localStorage.getItem('spanglish-token') : null;
}
export async function fetchApi<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const token = getToken();
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
(headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers,
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({ error: 'Request failed' }));
const errorMessage = typeof errorData.error === 'string'
? errorData.error
: (errorData.message || JSON.stringify(errorData) || 'Request failed');
throw new Error(errorMessage);
}
return res.json();
}
/**
* Fetch a file download (CSV/blob) with auth, returning the blob and the
* filename parsed from the Content-Disposition header.
*/
export async function fetchBlob(
endpoint: string,
fallbackFilename: string
): Promise<{ blob: Blob; filename: string }> {
const token = getToken();
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${endpoint}`, { headers });
if (!res.ok) {
const errorData = await res.json().catch(() => ({ error: 'Export failed' }));
throw new Error(errorData.error || 'Export failed');
}
const disposition = res.headers.get('Content-Disposition') || '';
const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
const filename = filenameMatch ? filenameMatch[1] : fallbackFilename;
const blob = await res.blob();
return { blob, filename };
}
+27
View File
@@ -0,0 +1,27 @@
import { fetchApi } from './client';
import type { Contact } from './types';
export const contactsApi = {
submit: (data: { name: string; email: string; message: string }) =>
fetchApi<{ message: string }>('/api/contacts', {
method: 'POST',
body: JSON.stringify(data),
}),
subscribe: (email: string, name?: string) =>
fetchApi<{ message: string }>('/api/contacts/subscribe', {
method: 'POST',
body: JSON.stringify({ email, name }),
}),
getAll: (status?: string) => {
const query = status ? `?status=${status}` : '';
return fetchApi<{ contacts: Contact[] }>(`/api/contacts${query}`);
},
updateStatus: (id: string, status: string) =>
fetchApi<{ contact: Contact }>(`/api/contacts/${id}`, {
method: 'PUT',
body: JSON.stringify({ status }),
}),
};
+65
View File
@@ -0,0 +1,65 @@
import { fetchApi } from './client';
import type {
DashboardSummary,
UserProfile,
UserTicket,
UserPayment,
UserInvoice,
UserSession,
NextEventInfo,
} from './types';
export const dashboardApi = {
// Summary
getSummary: () =>
fetchApi<{ summary: DashboardSummary }>('/api/dashboard/summary'),
// Profile
getProfile: () =>
fetchApi<{ profile: UserProfile }>('/api/dashboard/profile'),
updateProfile: (data: { name?: string; phone?: string; languagePreference?: string; rucNumber?: string }) =>
fetchApi<{ profile: UserProfile; message: string }>('/api/dashboard/profile', {
method: 'PUT',
body: JSON.stringify(data),
}),
// Tickets
getTickets: () =>
fetchApi<{ tickets: UserTicket[] }>('/api/dashboard/tickets'),
getTicket: (id: string) =>
fetchApi<{ ticket: UserTicket }>(`/api/dashboard/tickets/${id}`),
// Next event
getNextEvent: () =>
fetchApi<{ nextEvent: NextEventInfo | null }>('/api/dashboard/next-event'),
// Payments
getPayments: () =>
fetchApi<{ payments: UserPayment[] }>('/api/dashboard/payments'),
// Invoices
getInvoices: () =>
fetchApi<{ invoices: UserInvoice[] }>('/api/dashboard/invoices'),
// Sessions
getSessions: () =>
fetchApi<{ sessions: UserSession[] }>('/api/dashboard/sessions'),
revokeSession: (id: string) =>
fetchApi<{ message: string }>(`/api/dashboard/sessions/${id}`, { method: 'DELETE' }),
revokeAllSessions: () =>
fetchApi<{ message: string }>('/api/dashboard/sessions/revoke-all', { method: 'POST' }),
// Security
setPassword: (password: string) =>
fetchApi<{ message: string }>('/api/dashboard/set-password', {
method: 'POST',
body: JSON.stringify({ password }),
}),
unlinkGoogle: () =>
fetchApi<{ message: string }>('/api/dashboard/unlink-google', { method: 'POST' }),
};
+90
View File
@@ -0,0 +1,90 @@
import { fetchApi } from './client';
import type { EmailTemplate, EmailVariable, EmailLog, EmailStats, Pagination } from './types';
export const emailsApi = {
// Templates
getTemplates: () => fetchApi<{ templates: EmailTemplate[] }>('/api/emails/templates'),
getTemplate: (id: string) => fetchApi<{ template: EmailTemplate }>(`/api/emails/templates/${id}`),
createTemplate: (data: Partial<EmailTemplate>) =>
fetchApi<{ template: EmailTemplate; message: string }>('/api/emails/templates', {
method: 'POST',
body: JSON.stringify(data),
}),
updateTemplate: (id: string, data: Partial<EmailTemplate>) =>
fetchApi<{ template: EmailTemplate; message: string }>(`/api/emails/templates/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
deleteTemplate: (id: string) =>
fetchApi<{ message: string }>(`/api/emails/templates/${id}`, { method: 'DELETE' }),
getTemplateVariables: (slug: string) =>
fetchApi<{ variables: EmailVariable[] }>(`/api/emails/templates/${slug}/variables`),
// Sending
sendToEvent: (eventId: string, data: {
templateSlug: string;
customVariables?: Record<string, any>;
recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in';
}) =>
fetchApi<{ success: boolean; queuedCount: number; error?: string }>(
`/api/emails/send/event/${eventId}`,
{
method: 'POST',
body: JSON.stringify(data),
}
),
sendCustom: (data: {
to: string;
toName?: string;
subject: string;
bodyHtml: string;
bodyText?: string;
eventId?: string;
}) =>
fetchApi<{ success: boolean; logId?: string; error?: string }>('/api/emails/send/custom', {
method: 'POST',
body: JSON.stringify(data),
}),
preview: (data: {
templateSlug: string;
variables?: Record<string, any>;
locale?: string;
}) =>
fetchApi<{ subject: string; bodyHtml: string }>('/api/emails/preview', {
method: 'POST',
body: JSON.stringify(data),
}),
// Logs
getLogs: (params?: { eventId?: string; status?: string; search?: string; limit?: number; offset?: number }) => {
const query = new URLSearchParams();
if (params?.eventId) query.set('eventId', params.eventId);
if (params?.status) query.set('status', params.status);
if (params?.search) query.set('search', params.search);
if (params?.limit) query.set('limit', params.limit.toString());
if (params?.offset) query.set('offset', params.offset.toString());
return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`);
},
getLog: (id: string) => fetchApi<{ log: EmailLog }>(`/api/emails/logs/${id}`),
resendLog: (id: string) =>
fetchApi<{ success: boolean; error?: string }>(`/api/emails/logs/${id}/resend`, {
method: 'POST',
}),
getStats: (eventId?: string) => {
const query = eventId ? `?eventId=${eventId}` : '';
return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`);
},
seedTemplates: () =>
fetchApi<{ message: string }>('/api/emails/seed-templates', { method: 'POST' }),
};
+41
View File
@@ -0,0 +1,41 @@
import { fetchApi } from './client';
import type { Event } from './types';
export const eventsApi = {
getAll: (params?: { status?: string; upcoming?: boolean }) => {
const query = new URLSearchParams();
if (params?.status) query.set('status', params.status);
if (params?.upcoming) query.set('upcoming', 'true');
return fetchApi<{ events: Event[] }>(`/api/events?${query}`);
},
getById: (id: string) => fetchApi<{ event: Event }>(`/api/events/${id}`),
getNext: () => fetchApi<{ event: Event | null }>('/api/events/next'),
getNextUpcoming: () => fetchApi<{ event: Event | null }>('/api/events/next/upcoming'),
create: (data: Partial<Event>) =>
fetchApi<{ event: Event }>('/api/events', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id: string, data: Partial<Event>) =>
fetchApi<{ event: Event }>(`/api/events/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (id: string) =>
fetchApi<{ message: string }>(`/api/events/${id}`, { method: 'DELETE' }),
duplicate: (id: string) =>
fetchApi<{ event: Event; message: string }>(`/api/events/${id}/duplicate`, { method: 'POST' }),
getSlugAliases: (id: string) =>
fetchApi<{ aliases: { slug: string; createdAt: string }[] }>(`/api/events/${id}/slug-aliases`),
deleteSlugAlias: (id: string, slug: string) =>
fetchApi<{ message: string }>(`/api/events/${id}/slug-aliases/${encodeURIComponent(slug)}`, { method: 'DELETE' }),
};
+50
View File
@@ -0,0 +1,50 @@
import { fetchApi } from './client';
import type { FaqItem, FaqItemAdmin } from './types';
export const faqApi = {
// Public
getList: (homepageOnly?: boolean) =>
fetchApi<{ faqs: FaqItem[] }>(`/api/faq${homepageOnly ? '?homepage=true' : ''}`),
// Admin
getAdminList: () =>
fetchApi<{ faqs: FaqItemAdmin[] }>('/api/faq/admin/list'),
getById: (id: string) =>
fetchApi<{ faq: FaqItemAdmin }>(`/api/faq/admin/${id}`),
create: (data: {
question: string;
questionEs?: string;
answer: string;
answerEs?: string;
enabled?: boolean;
showOnHomepage?: boolean;
}) =>
fetchApi<{ faq: FaqItemAdmin }>('/api/faq/admin', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id: string, data: {
question?: string;
questionEs?: string | null;
answer?: string;
answerEs?: string | null;
enabled?: boolean;
showOnHomepage?: boolean;
}) =>
fetchApi<{ faq: FaqItemAdmin }>(`/api/faq/admin/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (id: string) =>
fetchApi<{ message: string }>(`/api/faq/admin/${id}`, { method: 'DELETE' }),
reorder: (ids: string[]) =>
fetchApi<{ faqs: FaqItemAdmin[] }>('/api/faq/admin/reorder', {
method: 'POST',
body: JSON.stringify({ ids }),
}),
};
+19
View File
@@ -0,0 +1,19 @@
// Barrel for the frontend API client. Consumers import from `@/lib/api`.
export type { ApiError } from './client';
export * from './types';
export { eventsApi } from './events';
export { ticketsApi } from './tickets';
export { contactsApi } from './contacts';
export { usersApi } from './users';
export { paymentsApi } from './payments';
export { paymentOptionsApi } from './paymentOptions';
export { mediaApi } from './media';
export { adminApi } from './admin';
export { emailsApi } from './emails';
export { authApi } from './auth';
export { dashboardApi } from './dashboard';
export { siteSettingsApi } from './siteSettings';
export { legalSettingsApi } from './legalSettings';
export { legalPagesApi } from './legalPages';
export { faqApi } from './faq';
+34
View File
@@ -0,0 +1,34 @@
import { fetchApi } from './client';
import type { LegalPage, LegalPagePublic, LegalPageListItem } from './types';
export const legalPagesApi = {
// Public endpoints
getAll: (locale?: string) =>
fetchApi<{ pages: LegalPageListItem[] }>(`/api/legal-pages${locale ? `?locale=${locale}` : ''}`),
getBySlug: (slug: string, locale?: string) =>
fetchApi<{ page: LegalPagePublic }>(`/api/legal-pages/${slug}${locale ? `?locale=${locale}` : ''}`),
// Admin endpoints
getAdminList: () =>
fetchApi<{ pages: LegalPage[] }>('/api/legal-pages/admin/list'),
getAdminPage: (slug: string) =>
fetchApi<{ page: LegalPage }>(`/api/legal-pages/admin/${slug}`),
update: (slug: string, data: {
contentMarkdown?: string;
contentMarkdownEs?: string;
title?: string;
titleEs?: string;
}) =>
fetchApi<{ page: LegalPage; message: string }>(`/api/legal-pages/admin/${slug}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
seed: () =>
fetchApi<{ message: string; seeded: number; pages?: string[] }>('/api/legal-pages/admin/seed', {
method: 'POST',
}),
};
+12
View File
@@ -0,0 +1,12 @@
import { fetchApi } from './client';
import type { LegalSettingsData } from './types';
export const legalSettingsApi = {
get: () => fetchApi<{ settings: LegalSettingsData }>('/api/legal-settings'),
update: (data: Partial<LegalSettingsData>) =>
fetchApi<{ settings: LegalSettingsData; message: string }>('/api/legal-settings', {
method: 'PUT',
body: JSON.stringify(data),
}),
};
+37
View File
@@ -0,0 +1,37 @@
import { fetchApi, API_BASE, getToken } from './client';
import type { Media } from './types';
export const mediaApi = {
getAll: (relatedType?: string, relatedId?: string) => {
const params = new URLSearchParams();
if (relatedType) params.set('relatedType', relatedType);
if (relatedId) params.set('relatedId', relatedId);
const query = params.toString();
return fetchApi<{ media: Media[] }>(`/api/media${query ? `?${query}` : ''}`);
},
upload: async (file: File, relatedId?: string, relatedType?: string) => {
const token = getToken();
const formData = new FormData();
formData.append('file', file);
if (relatedId) formData.append('relatedId', relatedId);
if (relatedType) formData.append('relatedType', relatedType);
const res = await fetch(`${API_BASE}/api/media/upload`, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData,
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({ error: 'Upload failed' }));
throw new Error(errorData.error || 'Upload failed');
}
return res.json() as Promise<{ media: Media; url: string }>;
},
delete: (id: string) =>
fetchApi<{ message: string }>(`/api/media/${id}`, { method: 'DELETE' }),
};
+41
View File
@@ -0,0 +1,41 @@
import { fetchApi } from './client';
import type { PaymentOptionsConfig } from './types';
export const paymentOptionsApi = {
// Global payment options
getGlobal: () =>
fetchApi<{ paymentOptions: PaymentOptionsConfig }>('/api/payment-options'),
updateGlobal: (data: Partial<PaymentOptionsConfig>) =>
fetchApi<{ paymentOptions: PaymentOptionsConfig; message: string }>('/api/payment-options', {
method: 'PUT',
body: JSON.stringify(data),
}),
// Event-specific options (merged with global). Pass ticketId after booking to
// retrieve bank/TPago credentials gated behind the booking capability token.
getForEvent: (eventId: string, ticketId?: string) =>
fetchApi<{ paymentOptions: PaymentOptionsConfig; hasOverrides: boolean }>(
`/api/payment-options/event/${eventId}${ticketId ? `?ticketId=${encodeURIComponent(ticketId)}` : ''}`
),
// Event overrides (admin only)
getEventOverrides: (eventId: string) =>
fetchApi<{ overrides: Partial<PaymentOptionsConfig> | null }>(
`/api/payment-options/event/${eventId}/overrides`
),
updateEventOverrides: (eventId: string, data: Partial<PaymentOptionsConfig>) =>
fetchApi<{ overrides: Partial<PaymentOptionsConfig>; message: string }>(
`/api/payment-options/event/${eventId}/overrides`,
{
method: 'PUT',
body: JSON.stringify(data),
}
),
deleteEventOverrides: (eventId: string) =>
fetchApi<{ message: string }>(`/api/payment-options/event/${eventId}/overrides`, {
method: 'DELETE',
}),
};
+49
View File
@@ -0,0 +1,49 @@
import { fetchApi } from './client';
import type { Payment, PaymentWithDetails } from './types';
export const paymentsApi = {
getAll: (params?: { status?: string; provider?: string; pendingApproval?: boolean; eventId?: string; eventIds?: string[] }) => {
const query = new URLSearchParams();
if (params?.status) query.set('status', params.status);
if (params?.provider) query.set('provider', params.provider);
if (params?.pendingApproval) query.set('pendingApproval', 'true');
if (params?.eventId) query.set('eventId', params.eventId);
if (params?.eventIds && params.eventIds.length > 0) query.set('eventIds', params.eventIds.join(','));
return fetchApi<{ payments: PaymentWithDetails[] }>(`/api/payments?${query}`);
},
getPendingApproval: () =>
fetchApi<{ payments: PaymentWithDetails[] }>('/api/payments/pending-approval'),
update: (id: string, data: { status: string; reference?: string; adminNote?: string }) =>
fetchApi<{ payment: Payment }>(`/api/payments/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
approve: (id: string, adminNote?: string, sendEmail: boolean = true) =>
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/approve`, {
method: 'POST',
body: JSON.stringify({ adminNote, sendEmail }),
}),
reject: (id: string, adminNote?: string, sendEmail: boolean = true) =>
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ adminNote, sendEmail }),
}),
sendReminder: (id: string) =>
fetchApi<{ message: string; reminderSentAt?: string }>(`/api/payments/${id}/send-reminder`, {
method: 'POST',
}),
updateNote: (id: string, adminNote: string) =>
fetchApi<{ payment: Payment; message: string }>(`/api/payments/${id}/note`, {
method: 'POST',
body: JSON.stringify({ adminNote }),
}),
refund: (id: string) =>
fetchApi<{ message: string }>(`/api/payments/${id}/refund`, { method: 'POST' }),
};
+21
View File
@@ -0,0 +1,21 @@
import { fetchApi } from './client';
import type { SiteSettings, TimezoneOption } from './types';
export const siteSettingsApi = {
get: () => fetchApi<{ settings: SiteSettings }>('/api/site-settings'),
update: (data: Partial<SiteSettings>) =>
fetchApi<{ settings: SiteSettings; message: string }>('/api/site-settings', {
method: 'PUT',
body: JSON.stringify(data),
}),
getTimezones: () =>
fetchApi<{ timezones: TimezoneOption[] }>('/api/site-settings/timezones'),
setFeaturedEvent: (eventId: string | null) =>
fetchApi<{ featuredEventId: string | null; message: string }>('/api/site-settings/featured-event', {
method: 'PUT',
body: JSON.stringify({ eventId }),
}),
};
+142
View File
@@ -0,0 +1,142 @@
import { fetchApi, API_BASE } from './client';
import type {
Ticket,
Payment,
BookingData,
TicketValidationResult,
TicketSearchResult,
LiveSearchResult,
} from './types';
export const ticketsApi = {
book: (data: BookingData) =>
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets', {
method: 'POST',
body: JSON.stringify(data),
}),
getById: (id: string) => fetchApi<{ ticket: Ticket }>(`/api/tickets/${id}`),
getAll: (params?: { eventId?: string; status?: string }) => {
const query = new URLSearchParams();
if (params?.eventId) query.set('eventId', params.eventId);
if (params?.status) query.set('status', params.status);
return fetchApi<{ tickets: Ticket[] }>(`/api/tickets?${query}`);
},
// Validate ticket by QR code (for scanner)
validate: (code: string, eventId?: string) =>
fetchApi<TicketValidationResult>('/api/tickets/validate', {
method: 'POST',
body: JSON.stringify({ code, eventId }),
}),
// Search tickets by name/email (for scanner manual search)
search: (query: string, eventId?: string) =>
fetchApi<{ tickets: TicketSearchResult[] }>('/api/tickets/search', {
method: 'POST',
body: JSON.stringify({ query, eventId }),
}),
// Get event check-in stats (for scanner header counter)
getCheckinStats: (eventId: string) =>
fetchApi<{ eventId: string; capacity: number; checkedIn: number; totalActive: number }>(
`/api/tickets/stats/checkin?eventId=${eventId}`
),
// Live search tickets (GET - for scanner live search with debounce)
searchLive: (q: string, eventId?: string) => {
const params = new URLSearchParams();
params.set('q', q);
if (eventId) params.set('eventId', eventId);
return fetchApi<{ tickets: LiveSearchResult[] }>(`/api/tickets/search?${params}`);
},
checkin: (id: string) =>
fetchApi<{ ticket: Ticket & { attendeeName?: string }; event?: { id: string; title: string }; message: string }>(`/api/tickets/${id}/checkin`, {
method: 'POST',
}),
removeCheckin: (id: string) =>
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/remove-checkin`, {
method: 'POST',
}),
cancel: (id: string) =>
fetchApi<{ message: string }>(`/api/tickets/${id}/cancel`, { method: 'POST' }),
updateStatus: (id: string, status: string) =>
fetchApi<{ ticket: Ticket }>(`/api/tickets/${id}`, {
method: 'PUT',
body: JSON.stringify({ status }),
}),
updateNote: (id: string, note: string) =>
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/note`, {
method: 'POST',
body: JSON.stringify({ note }),
}),
markPaid: (id: string) =>
fetchApi<{ ticket: Ticket; message: string }>(`/api/tickets/${id}/mark-paid`, {
method: 'POST',
}),
// For manual payment methods (bank_transfer, tpago) - user marks payment as sent
markPaymentSent: (id: string, payerName?: string) =>
fetchApi<{ payment: Payment; message: string }>(`/api/tickets/${id}/mark-payment-sent`, {
method: 'POST',
body: JSON.stringify({ payerName }),
}),
adminCreate: (data: {
eventId: string;
firstName: string;
lastName?: string;
email?: string;
phone?: string;
preferredLanguage?: 'en' | 'es';
autoCheckin?: boolean;
adminNote?: string;
}) =>
fetchApi<{ ticket: Ticket; payment: Payment; message: string }>('/api/tickets/admin/create', {
method: 'POST',
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: {
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/guest', {
method: 'POST',
body: JSON.stringify(data),
}),
checkPaymentStatus: (ticketId: string) =>
fetchApi<{ ticketStatus: string; paymentStatus: string; lnbitsStatus?: string; isPaid: boolean }>(
`/api/lnbits/status/${ticketId}`
),
// Get PDF download URL (returns the URL, not the PDF itself)
getPdfUrl: (id: string) => `${API_BASE}/api/tickets/${id}/pdf`,
};
+546
View File
@@ -0,0 +1,546 @@
export interface Event {
id: string;
slug: string;
title: string;
titleEs?: string;
description: string;
descriptionEs?: string;
shortDescription?: string;
shortDescriptionEs?: string;
startDatetime: string;
endDatetime?: string;
location: string;
locationUrl?: string;
price: number;
currency: string;
capacity: number;
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
bannerUrl?: string;
externalBookingEnabled?: boolean;
externalBookingUrl?: string;
bookedCount?: number;
availableSeats?: number;
isFeatured?: boolean;
createdAt: string;
updatedAt: string;
}
export interface Ticket {
id: string;
bookingId?: string; // Groups multiple tickets from same booking
bookingTicketCount?: number; // Total tickets in the booking (for per-quantity payment links)
userId: string;
eventId: string;
attendeeFirstName: string;
attendeeLastName?: string;
attendeeEmail?: string;
attendeePhone?: string;
attendeeRuc?: string;
preferredLanguage?: string;
status: 'pending' | 'confirmed' | 'cancelled' | 'checked_in';
checkinAt?: string;
checkedInByAdminId?: string;
qrCode: string;
adminNote?: string;
isGuest?: boolean;
createdAt: string;
event?: Event;
payment?: Payment;
user?: User;
}
export interface TicketValidationResult {
valid: boolean;
status: 'valid' | 'already_checked_in' | 'pending_payment' | 'cancelled' | 'invalid' | 'wrong_event';
canCheckIn: boolean;
ticket?: {
id: string;
qrCode: string;
attendeeName: string;
attendeeEmail?: string;
attendeePhone?: string;
status: string;
checkinAt?: string;
checkedInBy?: string;
};
event?: {
id: string;
title: string;
startDatetime: string;
location: string;
};
error?: string;
}
export interface TicketSearchResult {
id: string;
qrCode: string;
attendeeName: string;
attendeeEmail?: string;
attendeePhone?: string;
status: string;
checkinAt?: string;
event?: {
id: string;
title: string;
startDatetime: string;
location: string;
} | null;
}
export interface LiveSearchResult {
ticket_id: string;
name: string;
email?: string;
status: string;
checked_in: boolean;
checkinAt?: string;
event_id: string;
qrCode: string;
event?: {
id: string;
title: string;
startDatetime: string;
location: string;
} | null;
}
export interface Payment {
id: string;
ticketId: string;
provider: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
amount: number;
currency: string;
status: 'pending' | 'pending_approval' | 'paid' | 'refunded' | 'failed';
reference?: string;
userMarkedPaidAt?: string;
payerName?: string; // Name of payer if different from attendee
paidAt?: string;
paidByAdminId?: string;
adminNote?: string;
reminderSentAt?: string; // When payment reminder email was sent
createdAt: string;
updatedAt: string;
}
export interface PaymentWithDetails extends Payment {
ticket: {
id: string;
bookingId?: string;
attendeeFirstName: string;
attendeeLastName?: string;
attendeeEmail?: string;
attendeePhone?: string;
status: string;
} | null;
event: {
id: string;
title: string;
startDatetime: string;
} | null;
}
export interface PaymentOptionsConfig {
tpagoEnabled: boolean;
tpagoLink?: string | null;
tpagoLink2?: string | null;
tpagoLink3?: string | null;
tpagoLink4?: string | null;
tpagoLink5?: string | null;
tpagoInstructions?: string | null;
tpagoInstructionsEs?: string | null;
bankTransferEnabled: boolean;
bankName?: string | null;
bankAccountHolder?: string | null;
bankAccountNumber?: string | null;
bankAlias?: string | null;
bankPhone?: string | null;
bankNotes?: string | null;
bankNotesEs?: string | null;
lightningEnabled: boolean;
cashEnabled: boolean;
cashInstructions?: string | null;
cashInstructionsEs?: string | null;
// Booking settings
allowDuplicateBookings?: boolean;
}
export interface User {
id: string;
email: string;
name: string;
phone?: string;
role: 'admin' | 'organizer' | 'staff' | 'marketing' | 'user';
languagePreference?: string;
isClaimed?: boolean;
rucNumber?: string;
accountStatus?: string;
createdAt: string;
}
export interface Contact {
id: string;
name: string;
email: string;
message: string;
status: 'new' | 'read' | 'replied';
createdAt: string;
}
export interface AttendeeData {
firstName: string;
lastName?: string;
}
export interface BookingData {
eventId: string;
firstName: string;
lastName: string;
email: string;
phone: string;
preferredLanguage?: 'en' | 'es';
paymentMethod: 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
ruc?: string;
// For multi-ticket bookings
attendees?: AttendeeData[];
}
export interface DashboardData {
stats: {
totalUsers: number;
totalEvents: number;
totalTickets: number;
confirmedTickets: number;
pendingPayments: number;
totalRevenue: number;
newContacts: number;
totalSubscribers: number;
};
upcomingEvents: Event[];
recentTickets: Ticket[];
}
export interface AnalyticsData {
events: {
id: string;
title: string;
date: string;
capacity: number;
totalBookings: number;
confirmedBookings: number;
checkedIn: number;
revenue: number;
}[];
}
export interface Media {
id: string;
fileUrl: string;
type: 'image' | 'video' | 'document';
relatedId?: string;
relatedType?: string;
createdAt: string;
}
export interface ExportedTicket {
ticketId: string;
ticketStatus: string;
qrCode: string;
checkinAt?: string;
userName: string;
userEmail: string;
userPhone?: string;
eventTitle: string;
eventDate: string;
paymentStatus: string;
paymentAmount: number;
createdAt: string;
}
export interface EmailTemplate {
id: string;
name: string;
slug: string;
subject: string;
subjectEs?: string;
bodyHtml: string;
bodyHtmlEs?: string;
bodyText?: string;
bodyTextEs?: string;
description?: string;
variables: EmailVariable[];
isSystem: boolean;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
export interface EmailVariable {
name: string;
description: string;
example: string;
}
export interface EmailLog {
id: string;
templateId?: string;
eventId?: string;
recipientEmail: string;
recipientName?: string;
subject: string;
bodyHtml?: string;
status: 'pending' | 'sent' | 'failed' | 'bounced';
errorMessage?: string;
sentAt?: string;
sentBy?: string;
createdAt: string;
resendAttempts?: number;
lastResentAt?: string;
}
export interface EmailStats {
total: number;
sent: number;
failed: number;
pending: number;
}
export interface Pagination {
total: number;
limit: number;
offset: number;
hasMore: boolean;
}
export interface ExportedPayment {
paymentId: string;
amount: number;
currency: string;
provider: string;
status: string;
reference?: string;
paidAt?: string;
createdAt: string;
ticketId: string;
attendeeFirstName: string;
attendeeLastName?: string;
attendeeEmail?: string;
eventId: string;
eventTitle: string;
eventDate: string;
}
export interface FinancialSummary {
totalPayments: number;
totalPaid: number;
totalPending: number;
totalRefunded: number;
byProvider: {
bancard: number;
lightning: number;
cash: number;
bank_transfer: number;
tpago: number;
};
paidCount: number;
pendingCount: number;
pendingApprovalCount: number;
refundedCount: number;
failedCount: number;
}
// ==================== User Dashboard Types ====================
export interface UserProfile {
id: string;
email: string;
name: string;
phone?: string;
languagePreference?: string;
rucNumber?: string;
isClaimed: boolean;
accountStatus: string;
hasPassword: boolean;
hasGoogleLinked: boolean;
memberSince: string;
membershipDays: number;
createdAt: string;
}
export interface UserTicket extends Ticket {
invoice?: {
id: string;
invoiceNumber: string;
pdfUrl?: string;
createdAt: string;
} | null;
}
export interface UserPayment extends Payment {
ticket: {
id: string;
attendeeFirstName: string;
attendeeLastName?: string;
status: string;
} | null;
event: {
id: string;
title: string;
titleEs?: string;
startDatetime: string;
} | null;
invoice?: {
id: string;
invoiceNumber: string;
pdfUrl?: string;
} | null;
}
export interface UserInvoice {
id: string;
paymentId: string;
invoiceNumber: string;
rucNumber?: string;
legalName?: string;
amount: number;
currency: string;
pdfUrl?: string;
status: string;
createdAt: string;
event?: {
id: string;
title: string;
titleEs?: string;
startDatetime: string;
} | null;
}
export interface UserSession {
id: string;
userAgent?: string;
ipAddress?: string;
lastActiveAt: string;
createdAt: string;
}
export interface DashboardSummary {
user: {
name: string;
email: string;
accountStatus: string;
memberSince: string;
membershipDays: number;
};
stats: {
totalTickets: number;
confirmedTickets: number;
upcomingEvents: number;
pendingPayments: number;
};
}
export interface NextEventInfo {
event: Event;
ticket: Ticket;
payment: Payment | null;
}
// ==================== Site Settings Types ====================
export interface SiteSettings {
id?: string;
timezone: string;
siteName: string;
siteDescription?: string | null;
siteDescriptionEs?: string | null;
contactEmail?: string | null;
contactPhone?: string | null;
facebookUrl?: string | null;
instagramUrl?: string | null;
twitterUrl?: string | null;
linkedinUrl?: string | null;
featuredEventId?: string | null;
maintenanceMode: boolean;
maintenanceMessage?: string | null;
maintenanceMessageEs?: string | null;
updatedAt?: string;
updatedBy?: string;
}
export interface TimezoneOption {
value: string;
label: string;
}
// ==================== Legal Settings Types ====================
export interface LegalSettingsData {
id?: string;
companyName?: string | null;
legalEntityName?: string | null;
rucNumber?: string | null;
companyAddress?: string | null;
companyCity?: string | null;
companyCountry?: string | null;
supportEmail?: string | null;
legalEmail?: string | null;
governingLaw?: string | null;
jurisdictionCity?: string | null;
updatedAt?: string;
updatedBy?: string;
}
// ==================== Legal Pages Types ====================
export interface LegalPage {
id: string;
slug: string;
title: string;
titleEs?: string | null;
contentText: string;
contentTextEs?: string | null;
contentMarkdown: string;
contentMarkdownEs?: string | null;
updatedAt: string;
updatedBy?: string | null;
createdAt: string;
source?: 'database' | 'filesystem';
hasEnglish?: boolean;
hasSpanish?: boolean;
}
export interface LegalPagePublic {
id?: string;
slug: string;
title: string;
contentMarkdown: string;
updatedAt?: string;
source?: 'database' | 'filesystem';
}
export interface LegalPageListItem {
id: string;
slug: string;
title: string;
updatedAt: string;
hasEnglish?: boolean;
hasSpanish?: boolean;
}
// ==================== FAQ Types ====================
export interface FaqItem {
id: string;
question: string;
questionEs?: string | null;
answer: string;
answerEs?: string | null;
rank?: number;
}
export interface FaqItemAdmin extends FaqItem {
enabled: boolean;
showOnHomepage: boolean;
createdAt: string;
updatedAt: string;
}
+20
View File
@@ -0,0 +1,20 @@
import { fetchApi } from './client';
import type { User } from './types';
export const usersApi = {
getAll: (role?: string) => {
const query = role ? `?role=${role}` : '';
return fetchApi<{ users: User[] }>(`/api/users${query}`);
},
getById: (id: string) => fetchApi<{ user: User }>(`/api/users/${id}`),
update: (id: string, data: Partial<User>) =>
fetchApi<{ user: User }>(`/api/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (id: string) =>
fetchApi<{ message: string }>(`/api/users/${id}`, { method: 'DELETE' }),
};

Some files were not shown because too many files have changed in this diff Show More