diff --git a/README.md b/README.md index fb7b874..f768805 100644 --- a/README.md +++ b/README.md @@ -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/` diff --git a/backend/.env.example b/backend/.env.example index eec048d..71c348b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 + diff --git a/backend/package.json b/backend/package.json index f90b769..f2a5bed 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index ca31dd6..2d54f51 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -12,8 +12,11 @@ const dbType = process.env.DB_TYPE || 'sqlite'; let db: ReturnType | ReturnType; 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 { diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 5c4c187..03c7ca6 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -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` @@ -429,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 ( @@ -541,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` @@ -893,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 ( @@ -974,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 }>( diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index c8e834a..addab36 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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(), }); @@ -271,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(), @@ -359,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(), }); @@ -604,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(), @@ -691,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; diff --git a/backend/src/index.ts b/backend/src/index.ts index 495ef96..9532ae7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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, diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index 26a42da..61c965a 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -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 { .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 { - const token = await new jose.SignJWT({ sub: userId, email, role }) +export async function createToken(userId: string, email: string, role: string, tokenVersion: number = 0): Promise { + 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 { + 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 { const token = await new jose.SignJWT({ sub: userId, type: 'refresh' }) .setProtectedHeader({ alg: 'HS256' }) @@ -223,10 +289,44 @@ export async function getAuthUser(c: Context): Promise { 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( - (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 { ); return !result || result.length === 0; } + +/** Fetch only the password hash column (never expose via getAuthUser). */ +export async function getUserPasswordHash(userId: string): Promise { + const row = await dbGet( + (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; +} diff --git a/backend/src/lib/backends.ts b/backend/src/lib/backends.ts new file mode 100644 index 0000000..63196a9 --- /dev/null +++ b/backend/src/lib/backends.ts @@ -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}`); +} diff --git a/backend/src/lib/bookingCleanup.ts b/backend/src/lib/bookingCleanup.ts new file mode 100644 index 0000000..5d54a49 --- /dev/null +++ b/backend/src/lib/bookingCleanup.ts @@ -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 { + 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 | 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; + } +} diff --git a/backend/src/lib/email.ts b/backend/src/lib/email.ts index 5ff1449..8d35060 100644 --- a/backend/src/lib/email.ts +++ b/backend/src/lib/email.ts @@ -1,1412 +1,64 @@ // Email service for Spanglish platform // Supports multiple email providers: Resend, SMTP (Nodemailer) +// +// This module is a thin facade. The implementation is split across ./email/*: +// - transport: provider config, SMTP, low-level sendEmail, diagnostics +// - formatting: common variables, timezone, date/time/currency helpers +// - templateService: template DB access, seeding, template/custom send + logging +// - bookingEmails: booking confirmation +// - paymentEmails: receipt, instructions, rejection, reminder, payment config +// - bulkEmails: event-wide queued sends -import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets, payments, users, paymentOptions, eventPaymentOverrides, siteSettings } from '../db/index.js'; -import { eq, and } from 'drizzle-orm'; -import { getNow, generateId } from './utils.js'; -import { - replaceTemplateVariables, - wrapInBaseTemplate, - defaultTemplates, - type DefaultTemplate -} from './emailTemplates.js'; -import { enqueueBulkEmails, type TemplateEmailJobParams } from './emailQueue.js'; -import nodemailer from 'nodemailer'; -import type { Transporter } from 'nodemailer'; - -// ==================== Types ==================== - -interface SendEmailOptions { - to: string | string[]; - subject: string; - html: string; - text?: string; - replyTo?: string; -} - -interface SendEmailResult { - success: boolean; - messageId?: string; - error?: string; -} - -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'; -} - -// ==================== 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 { - 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 { - 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 { - 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()}` - }; -} - -/** - * Main send function that routes to the appropriate provider - */ -async function sendEmail(options: SendEmailOptions): Promise { - const provider = getEmailProvider(); - - console.log(`[Email] Sending email via ${provider} to ${Array.isArray(options.to) ? options.to.join(', ') : options.to}`); - - switch (provider) { - case 'resend': - return sendWithResend(options); - case 'smtp': - return sendWithSMTP(options); - case 'console': - default: - return sendWithConsole(options); - } -} - -// ==================== Email Service ==================== +import { sendEmail, getProviderInfo, testConnection } from './email/transport.js'; +import { + getCommonVariables, + getSiteTimezone, + formatDate, + formatTime, + formatCurrency, +} from './email/formatting.js'; +import { + getTemplate, + seedDefaultTemplates, + sendTemplateEmail, + sendCustomEmail, + resendFromLog, +} from './email/templateService.js'; +import { sendBookingConfirmation } from './email/bookingEmails.js'; +import { + sendPaymentReceipt, + getPaymentConfig, + sendPaymentInstructions, + sendPaymentRejectionEmail, + sendPaymentReminder, +} from './email/paymentEmails.js'; +import { queueEventEmails } from './email/bulkEmails.js'; export const emailService = { - /** - * Get current email provider info - */ - 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 }; - }, - - /** - * Test email configuration by sending a test email - */ - async testConnection(to: string): Promise { - const { provider, configured } = this.getProviderInfo(); - - if (!configured) { - return { success: false, error: `Email provider "${provider}" is not configured` }; - } - - return sendEmail({ - to, - subject: 'Spanglish - Email Test', - html: ` -

Email Configuration Test

-

This is a test email from your Spanglish platform.

-

Provider: ${provider}

-

Timestamp: ${new Date().toISOString()}

-

If you received this email, your email configuration is working correctly!

- `, - text: `Email Configuration Test\n\nProvider: ${provider}\nTimestamp: ${new Date().toISOString()}\n\nIf you received this email, your email configuration is working correctly!`, - }); - }, - - /** - * Get common variables for all emails - */ - getCommonVariables(): Record { - 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) - */ - async getSiteTimezone(): Promise { - const settings = await dbGet( - (db as any).select().from(siteSettings).limit(1) - ); - return settings?.timezone || 'America/Asuncion'; - }, - - /** - * Format date for emails using site timezone - */ - 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 - */ - 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 - */ - formatCurrency(amount: number, currency: string = 'PYG'): string { - if (currency === 'PYG') { - return `${amount.toLocaleString('es-PY')} PYG`; - } - return `$${amount.toFixed(2)} ${currency}`; - }, - - /** - * Get a template by slug - */ - async getTemplate(slug: string): Promise { - 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 - */ - async seedDefaultTemplates(): Promise { - console.log('[Email] Checking for default templates...'); - - for (const template of defaultTemplates) { - const existing = await this.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 - */ - async sendTemplateEmail(params: { - templateSlug: string; - to: string; - toName?: string; - variables: Record; - 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 this.getTemplate(templateSlug); - if (!template) { - return { success: false, error: `Template "${templateSlug}" not found` }; - } - - // Build variables - const allVariables = { - ...this.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); - 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 booking confirmation email - * Supports multi-ticket bookings - includes all tickets in the booking - */ - async sendBookingConfirmation(ticketId: string): Promise<{ success: boolean; error?: string }> { - // Get ticket with event info - const ticket = await dbGet( - (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( - (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 this.getSiteTimezone(); - - return this.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: this.formatDate(event.startDatetime, locale, timezone), - eventTime: this.formatTime(event.startDatetime, locale, timezone), - eventLocation: event.location, - eventLocationUrl: event.locationUrl || '', - eventPrice: this.formatCurrency(event.price, event.currency), - // Multi-ticket specific variables - ticketCount: ticketCount.toString(), - totalPrice: this.formatCurrency(totalPrice, event.currency), - attendeeNames, - isMultiTicket: ticketCount > 1 ? 'true' : 'false', - }, - }); - }, - - /** - * Send payment receipt email - */ - async sendPaymentReceipt(paymentId: string): Promise<{ success: boolean; error?: string }> { - // Get payment with ticket and event info - const payment = await dbGet( - (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( - (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( - (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( - (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((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> = { - 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 - ? `${this.formatCurrency(totalAmount, payment.currency)} (${ticketCount} tickets)` - : this.formatCurrency(totalAmount, payment.currency); - - // Get site timezone for proper date/time formatting - const timezone = await this.getSiteTimezone(); - - return this.sendTemplateEmail({ - templateSlug: 'payment-receipt', - to: ticket.attendeeEmail, - toName: receiptFullName, - locale, - eventId: event.id, - variables: { - attendeeName: receiptFullName, - ticketId: ticket.bookingId || ticket.id, - eventTitle, - eventDate: this.formatDate(event.startDatetime, locale, timezone), - paymentAmount: amountDisplay, - paymentMethod: paymentMethodNames[locale]?.[payment.provider] || payment.provider, - paymentReference: payment.reference || payment.id, - paymentDate: this.formatDate(payment.paidAt || payment.createdAt, locale, timezone), - }, - }); - }, - - /** - * Get merged payment configuration for an event (global + overrides) - */ - async getPaymentConfig(eventId: string): Promise> { - // Get global options - const globalOptions = await dbGet( - (db as any) - .select() - .from(paymentOptions) - ); - - // Get event overrides - const overrides = await dbGet( - (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" - */ - async sendPaymentInstructions(ticketId: string): Promise<{ success: boolean; error?: string }> { - // Get ticket - const ticket = await dbGet( - (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( - (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( - (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 this.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( - (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 - ? `${this.formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)` - : this.formatCurrency(totalPrice, event.currency); - - // Get site timezone for proper date/time formatting - const timezone = await this.getSiteTimezone(); - - // Build variables based on payment method - const variables: Record = { - attendeeName: attendeeFullName, - attendeeEmail: ticket.attendeeEmail, - ticketId: ticket.bookingId || ticket.id, - eventTitle, - eventDate: this.formatDate(event.startDatetime, locale, timezone), - eventTime: this.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 this.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 - */ - async sendPaymentRejectionEmail(paymentId: string): Promise<{ success: boolean; error?: string }> { - // Get payment - const payment = await dbGet( - (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( - (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( - (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 this.getSiteTimezone(); - - console.log(`[Email] Sending payment rejection email to ${ticket.attendeeEmail}`); - - return this.sendTemplateEmail({ - templateSlug: 'payment-rejected', - to: ticket.attendeeEmail, - toName: attendeeFullName, - locale, - eventId: event.id, - variables: { - attendeeName: attendeeFullName, - attendeeEmail: ticket.attendeeEmail, - ticketId: ticket.id, - eventTitle, - eventDate: this.formatDate(event.startDatetime, locale, timezone), - eventTime: this.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 - */ - async sendPaymentReminder(paymentId: string): Promise<{ success: boolean; error?: string }> { - // Get payment - const payment = await dbGet( - (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( - (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( - (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( - (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 - ? `${this.formatCurrency(totalPrice, event.currency)} (${ticketCount} tickets)` - : this.formatCurrency(totalPrice, event.currency); - - // Get site timezone for proper date/time formatting - const timezone = await this.getSiteTimezone(); - - console.log(`[Email] Sending payment reminder email to ${ticket.attendeeEmail}`); - - return this.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: this.formatDate(event.startDatetime, locale, timezone), - eventTime: this.formatTime(event.startDatetime, locale, timezone), - eventLocation: event.location, - eventLocationUrl: event.locationUrl || '', - paymentAmount: amountDisplay, - bookingUrl, - }, - }); - }, - - /** - * Send custom email to event attendees - */ - async sendToEventAttendees(params: { - eventId: string; - templateSlug: string; - customVariables?: Record; - recipientFilter?: 'all' | 'confirmed' | 'pending' | 'checked_in'; - sentBy: string; - }): Promise<{ success: boolean; sentCount: number; failedCount: number; errors: string[] }> { - const { eventId, templateSlug, customVariables = {}, recipientFilter = 'confirmed', sentBy } = params; - - // Get event - const event = await dbGet( - (db as any) - .select() - .from(events) - .where(eq((events as any).id, eventId)) - ); - - if (!event) { - return { success: false, sentCount: 0, failedCount: 0, errors: ['Event 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(ticketQuery); - - if (eventTickets.length === 0) { - return { success: true, sentCount: 0, failedCount: 0, errors: ['No recipients found'] }; - } - - // Get site timezone for proper date/time formatting - const timezone = await this.getSiteTimezone(); - - let sentCount = 0; - let failedCount = 0; - const errors: string[] = []; - - // Send to each attendee - for (const ticket of eventTickets) { - const locale = ticket.preferredLanguage || 'en'; - const eventTitle = locale === 'es' && event.titleEs ? event.titleEs : event.title; - - const bulkFullName = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.trim(); - const result = await this.sendTemplateEmail({ - templateSlug, - to: ticket.attendeeEmail, - toName: bulkFullName, - locale, - eventId: event.id, - sentBy, - variables: { - attendeeName: bulkFullName, - attendeeEmail: ticket.attendeeEmail, - ticketId: ticket.id, - eventTitle, - eventDate: this.formatDate(event.startDatetime, locale, timezone), - eventTime: this.formatTime(event.startDatetime, locale, timezone), - eventLocation: event.location, - eventLocationUrl: event.locationUrl || '', - ...customVariables, - }, - }); - - if (result.success) { - sentCount++; - } else { - failedCount++; - errors.push(`Failed to send to ${ticket.attendeeEmail}: ${result.error}`); - } - } - - return { - success: failedCount === 0, - sentCount, - failedCount, - errors, - }; - }, - - /** - * 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. - */ - async queueEventEmails(params: { - eventId: string; - templateSlug: string; - customVariables?: Record; - 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( - (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 this.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(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 this.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: this.formatDate(event.startDatetime, locale, timezone), - eventTime: this.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, - }; - }, - - /** - * Send a custom email (not from template) - */ - async 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, toName, subject, bodyHtml, bodyText, replyTo, eventId, sentBy = null } = params; - - const allVariables = { - ...this.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 - */ - async resendFromLog(logId: string): Promise<{ success: boolean; error?: string }> { - const log = await dbGet( - (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, - }; - }, + // Diagnostics + getProviderInfo, + testConnection, + // Formatting / variables + getCommonVariables, + getSiteTimezone, + formatDate, + formatTime, + formatCurrency, + // Templates + core sending + getTemplate, + seedDefaultTemplates, + sendTemplateEmail, + sendCustomEmail, + resendFromLog, + // Domain senders + sendBookingConfirmation, + sendPaymentReceipt, + getPaymentConfig, + sendPaymentInstructions, + sendPaymentRejectionEmail, + sendPaymentReminder, + // Bulk + queueEventEmails, }; // Export the main sendEmail function for direct use diff --git a/backend/src/lib/email/bookingEmails.ts b/backend/src/lib/email/bookingEmails.ts new file mode 100644 index 0000000..baf8a3f --- /dev/null +++ b/backend/src/lib/email/bookingEmails.ts @@ -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( + (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( + (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', + }, + }); +} diff --git a/backend/src/lib/email/bulkEmails.ts b/backend/src/lib/email/bulkEmails.ts new file mode 100644 index 0000000..60b67ed --- /dev/null +++ b/backend/src/lib/email/bulkEmails.ts @@ -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; + 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( + (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(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, + }; +} diff --git a/backend/src/lib/email/formatting.ts b/backend/src/lib/email/formatting.ts new file mode 100644 index 0000000..413fd87 --- /dev/null +++ b/backend/src/lib/email/formatting.ts @@ -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 { + 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 { + const cached = await getCache().get('site:timezone'); + if (cached) return cached; + + const settings = await dbGet( + (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}`; +} diff --git a/backend/src/lib/email/paymentEmails.ts b/backend/src/lib/email/paymentEmails.ts new file mode 100644 index 0000000..3654431 --- /dev/null +++ b/backend/src/lib/email/paymentEmails.ts @@ -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( + (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( + (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( + (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( + (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((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> = { + 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> { + // Get global options + const globalOptions = await dbGet( + (db as any) + .select() + .from(paymentOptions) + ); + + // Get event overrides + const overrides = await dbGet( + (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( + (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( + (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( + (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( + (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 = { + 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( + (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( + (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( + (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( + (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( + (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( + (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( + (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, + }, + }); +} diff --git a/backend/src/lib/email/templateService.ts b/backend/src/lib/email/templateService.ts new file mode 100644 index 0000000..4139b97 --- /dev/null +++ b/backend/src/lib/email/templateService.ts @@ -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 { + 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 { + 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; + 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( + (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, + }; +} diff --git a/backend/src/lib/email/transport.ts b/backend/src/lib/email/transport.ts new file mode 100644 index 0000000..d0604f3 --- /dev/null +++ b/backend/src/lib/email/transport.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + const { provider, configured } = getProviderInfo(); + + if (!configured) { + return { success: false, error: `Email provider "${provider}" is not configured` }; + } + + return sendEmail({ + to, + subject: 'Spanglish - Email Test', + html: ` +

Email Configuration Test

+

This is a test email from your Spanglish platform.

+

Provider: ${provider}

+

Timestamp: ${new Date().toISOString()}

+

If you received this email, your email configuration is working correctly!

+ `, + text: `Email Configuration Test\n\nProvider: ${provider}\nTimestamp: ${new Date().toISOString()}\n\nIf you received this email, your email configuration is working correctly!`, + }); +} diff --git a/backend/src/lib/emailQueue.ts b/backend/src/lib/emailQueue.ts index 2621338..26f8975 100644 --- a/backend/src/lib/emailQueue.ts +++ b/backend/src/lib/emailQueue.ts @@ -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 | 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 { + 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 { + 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 { cleanOldTimestamps(); + const row = await dbGet( + (db as any) + .select({ count: sql`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 { + for (let attempt = 0; attempt < 5; attempt++) { + const row = await dbGet( + (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 { + 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 { + await (db as any) + .update(emailQueue) + .set({ status: 'pending' }) + .where(eq((emailQueue as any).id, id)); +} + async function processNext(): Promise { - 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 { } /** - * 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.'); } diff --git a/backend/src/lib/emailTemplates.ts b/backend/src/lib/emailTemplates.ts index 6340aba..876ffc5 100644 --- a/backend/src/lib/emailTemplates.ts +++ b/backend/src/lib/emailTemplates.ts @@ -1216,8 +1216,26 @@ Spanglish`, }, ]; -// Helper function to replace template variables -export function replaceTemplateVariables(template: string, variables: Record): string { +// Escape HTML-significant characters so substituted variable values can't inject markup. +function escapeHtmlValue(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// 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, + escapeHtml: boolean = false +): string { let result = template; // Handle conditional blocks {{#if variable}}...{{/if}} @@ -1229,16 +1247,20 @@ export function replaceTemplateVariables(template: string, variables: Record { - 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 { - 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 diff --git a/backend/src/lib/lnbits.ts b/backend/src/lib/lnbits.ts index 5bdb13c..66cc131 100644 --- a/backend/src/lib/lnbits.ts +++ b/backend/src/lib/lnbits.ts @@ -80,11 +80,8 @@ export async function createInvoice(params: CreateInvoiceParams): Promise { + 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) => { + 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(); + }; +} diff --git a/backend/src/lib/redis.ts b/backend/src/lib/redis.ts new file mode 100644 index 0000000..4c53bca --- /dev/null +++ b/backend/src/lib/redis.ts @@ -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 { + const tasks: Promise[] = []; + 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; +} diff --git a/backend/src/lib/storage.ts b/backend/src/lib/storage.ts new file mode 100644 index 0000000..37bd27c --- /dev/null +++ b/backend/src/lib/storage.ts @@ -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; + delete(key: string): Promise; + // 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 { + if (!existsSync(UPLOAD_DIR)) { + await mkdir(UPLOAD_DIR, { recursive: true }); + } + } + + async put(key: string, buffer: Buffer): Promise { + await this.ensureDir(); + await writeFile(join(UPLOAD_DIR, key), buffer); + } + + async delete(key: string): Promise { + 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 { + 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 { + 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 { + 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; +} diff --git a/backend/src/lib/stores/cache.ts b/backend/src/lib/stores/cache.ts new file mode 100644 index 0000000..e776ac3 --- /dev/null +++ b/backend/src/lib/stores/cache.ts @@ -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(key: string): Promise; + set(key: string, value: T, ttlSeconds: number): Promise; + del(key: string): Promise; +} + +// ==================== Memory implementation ==================== + +interface Entry { + value: unknown; + expiresAt: number; +} + +class MemoryCache implements Cache { + readonly backend = 'memory' as const; + private store = new Map(); + + 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(key: string): Promise { + 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(key: string, value: T, ttlSeconds: number): Promise { + this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 }); + } + + async del(key: string): Promise { + this.store.delete(key); + } +} + +// ==================== Redis implementation ==================== + +class RedisCache implements Cache { + readonly backend = 'redis' as const; + + async get(key: string): Promise { + 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(key: string, value: T, ttlSeconds: number): Promise { + 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 { + 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; +} diff --git a/backend/src/lib/stores/lock.ts b/backend/src/lib/stores/lock.ts new file mode 100644 index 0000000..10950ae --- /dev/null +++ b/backend/src/lib/stores/lock.ts @@ -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; + release(key: string, token: string): Promise; + // Runs fn while holding the lock; returns fn's result, or null if not acquired. + withLock(key: string, ttlMs: number, fn: () => Promise): Promise; +} + +// ==================== Memory implementation ==================== + +class MemoryLock implements Lock { + readonly backend = 'memory' as const; + private held = new Map(); + + async acquire(key: string, ttlMs: number): Promise { + 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 { + const existing = this.held.get(key); + if (existing && existing.token === token) { + this.held.delete(key); + } + } + + async withLock(key: string, ttlMs: number, fn: () => Promise): Promise { + 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 { + 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 { + 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(key: string, ttlMs: number, fn: () => Promise): Promise { + 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; +} diff --git a/backend/src/lib/stores/pubsub.ts b/backend/src/lib/stores/pubsub.ts new file mode 100644 index 0000000..7a5d9a2 --- /dev/null +++ b/backend/src/lib/stores/pubsub.ts @@ -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; + // 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 { + 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>(); + 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 { + 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; +} diff --git a/backend/src/lib/stores/rateLimiter.ts b/backend/src/lib/stores/rateLimiter.ts new file mode 100644 index 0000000..6cf9f34 --- /dev/null +++ b/backend/src/lib/stores/rateLimiter.ts @@ -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; +} + +// ==================== Memory implementation ==================== + +interface Bucket { + count: number; + resetAt: number; +} + +class MemoryRateLimiter implements RateLimiter { + readonly backend = 'memory' as const; + private buckets = new Map(); + + 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 { + 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 { + 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; +} diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 70feaeb..c9327a8 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -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( + const revenueRow = await dbGet( (db as any) - .select() + .select({ total: sql`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( (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((db as any).select().from(events)); - - const eventStats = await Promise.all( - allEvents.map(async (event: any) => { - const ticketCount = await dbGet( - (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where(eq((tickets as any).eventId, event.id)) - ); - - const confirmedCount = await dbGet( - (db as any) - .select({ count: sql`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( - (db as any) - .select({ count: sql`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( + (db as any) + .select({ eventId: (tickets as any).eventId, count: sql`count(*)` }) + .from(tickets) + .groupBy((tickets as any).eventId) ); + const confirmedRows = await dbAll( + (db as any) + .select({ eventId: (tickets as any).eventId, count: sql`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( + (db as any) + .select({ eventId: (tickets as any).eventId, count: sql`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(); + 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(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((db as any).select().from(users).where(inArray((users as any).id, userIds))) : Promise.resolve([]), + eventIds.length ? dbAll((db as any).select().from(events).where(inArray((events as any).id, eventIds))) : Promise.resolve([]), + ticketIds.length ? dbAll((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( - (db as any) - .select() - .from(users) - .where(eq((users as any).id, ticket.userId)) - ); - - const event = await dbGet( - (db as any) - .select() - .from(events) - .where(eq((events as any).id, ticket.eventId)) - ); - - const payment = await dbGet( - (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) => ({ diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index ad34c63..8e65ee0 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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( @@ -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( @@ -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( @@ -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),

An account was created for you during booking. Click below to set up your login credentials.

Claim Account

Or copy this link: ${claimLink}

-

This link expires in 24 hours.

+

This link expires in 1 hour.

`, }); } 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 = { 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( + (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' }); }); diff --git a/backend/src/routes/contacts.ts b/backend/src/routes/contacts.ts index a86093c..b982a59 100644 --- a/backend/src/routes/contacts.ts +++ b/backend/src/routes/contacts.ts @@ -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, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} +// ==================== 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( (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 }); }); diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index 9895c5c..4e2ec47 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -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((db as any).select().from(events).where(inArray((events as any).id, eventIds))) + : []; + const paymentRows = ticketIds.length + ? await dbAll((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((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( - (db as any) - .select() - .from(events) - .where(eq((events as any).id, ticket.eventId)) - ); - - const payment = await dbGet( - (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( - (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( + (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); } diff --git a/backend/src/routes/emails.ts b/backend/src/routes/emails.ts index 281e900..d94a177 100644 --- a/backend/src/routes/emails.ts +++ b/backend/src/routes/emails.ts @@ -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( (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( (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)[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 }); }); diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index c7ed4ad..07fe638 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -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( - (db as any) - .select({ count: sql`count(*)` }) - .from(tickets) - .where( - and( - eq((tickets as any).eventId, event.id), - sql`${(tickets as any).status} IN ('confirmed', 'checked_in')` - ) - ) - ); - - const normalized = normalizeEvent(event); - const bookedCount = ticketCount?.count || 0; - return { - ...normalized, - bookedCount, - availableSeats: calculateAvailableSeats(normalized.capacity, bookedCount), - }; - }) + const result = await dbAll(query.orderBy(desc((events as any).startDatetime))); + + // Single grouped query for booked counts across all events (avoids N+1: previously + // this ran one COUNT query per event). + const countRows = await dbAll( + (db as any) + .select({ eventId: (tickets as any).eventId, count: sql`count(*)` }) + .from(tickets) + .where(sql`${(tickets as any).status} IN ('confirmed', 'checked_in')`) + .groupBy((tickets as any).eventId) ); + const countByEvent = new Map(); + 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 { return ticketCount?.count || 0; } +// Get the earliest upcoming published event with ticket counts (ignores featured promotion) +async function getNextChronologicalUpcoming(): Promise { + const now = getNow(); + const event = await dbGet( + (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( - (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) diff --git a/backend/src/routes/faq.ts b/backend/src/routes/faq.ts index 86f5b2f..67b7370 100644 --- a/backend/src/routes/faq.ts +++ b/backend/src/routes/faq.ts @@ -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 = { 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( + (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) diff --git a/backend/src/routes/legal-pages.ts b/backend/src/routes/legal-pages.ts index 321071f..917c378 100644 --- a/backend/src/routes/legal-pages.ts +++ b/backend/src/routes/legal-pages.ts @@ -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( @@ -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) diff --git a/backend/src/routes/lnbits.ts b/backend/src/routes/lnbits.ts index b433b33..1493071 100644 --- a/backend/src/routes/lnbits.ts +++ b/backend/src/routes/lnbits.ts @@ -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 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 Promise>>(); + +// Pub/sub unsubscribe handles per ticket (one local subscription per ticket). +const channelUnsubs = new Map void>(); // Store for active background checkers (ticketId -> intervalId) const activeCheckers = new Map(); +/** 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(); + +/** 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( + (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( (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); + } } } }); diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index b0b75b6..c2b4365 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -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 }); }); diff --git a/backend/src/routes/payment-options.ts b/backend/src/routes/payment-options.ts index 33fd275..27b4496 100644 --- a/backend/src/routes/payment-options.ts +++ b/backend/src/routes/payment-options.ts @@ -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(); @@ -40,6 +40,23 @@ const updatePaymentOptionsSchema = z.object({ allowDuplicateBookings: booleanOrNumber.optional(), }); +/** Strip bank account numbers from payment options for anonymous callers. */ +function publicPaymentOptions(merged: Record) { + 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(), @@ -151,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( @@ -229,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( + (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, }); }); diff --git a/backend/src/routes/payments.ts b/backend/src/routes/payments.ts index cd60ebe..f480479 100644 --- a/backend/src/routes/payments.ts +++ b/backend/src/routes/payments.ts @@ -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((db as any).select({ count: sql`count(*)` }).from(payments)), + dbGet((db as any).select({ count: sql`count(*)` }).from(payments).where(eq((payments as any).status, 'pending'))), + dbGet((db as any).select({ count: sql`count(*)` }).from(payments).where(eq((payments as any).status, 'paid'))), + dbGet((db as any).select({ count: sql`count(*)` }).from(payments).where(eq((payments as any).status, 'refunded'))), + dbGet((db as any).select({ count: sql`count(*)` }).from(payments).where(eq((payments as any).status, 'failed'))), + dbGet((db as any).select({ total: sql`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( + (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( + (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( + (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( + (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((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; diff --git a/backend/src/routes/site-settings.ts b/backend/src/routes/site-settings.ts index 5ff0592..e8a1389 100644 --- a/backend/src/routes/site-settings.ts +++ b/backend/src/routes/site-settings.ts @@ -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('')), diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 611dcc3..007e6b4 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -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): 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( @@ -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( + (db as any).select().from(paymentOptions) + ); + const eventOverrides = await dbGet( + (db as any).select().from(eventPaymentOverrides).where(eq((eventPaymentOverrides as any).eventId, data.eventId)) + ); + const mergedPaymentOptions: Record = { + 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( (db as any) .select({ count: sql`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( - (db as any) - .select() - .from(paymentOptions) - ); - - const allowDuplicateBookings = globalOptions?.allowDuplicateBookings ?? false; + const allowDuplicateBookings = globalPaymentOptions?.allowDuplicateBookings ?? false; if (!allowDuplicateBookings) { const existingTicket = await dbGet( @@ -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`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( + tx + .select({ count: sql`count(*)` }) + .from(tickets) + .where( + and( + eq((tickets as any).eventId, data.eventId), + sql`${(tickets as any).status} IN ('pending', 'confirmed', 'checked_in')` + ) + ) + ); + const 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'); @@ -639,15 +801,22 @@ ticketsRouter.get('/:id', async (c) => { ); 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: { - ...ticket, - event, - payment, - bookingTicketCount, - }, - }); + return c.json({ ticket: ticketPayload }); }); // Update ticket status (admin/organizer) @@ -985,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; @@ -1039,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( + (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() diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index bc558b1..3773812 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -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( + (db as any) + .select({ count: sql`count(*)` }) + .from(users) + ); + + const adminCount = await dbGet( + (db as any) + .select({ count: sql`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( - (db as any) - .select({ count: sql`count(*)` }) - .from(users) - ); - - const adminCount = await dbGet( - (db as any) - .select({ count: sql`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; diff --git a/deploy/docker-compose.scale.yml b/deploy/docker-compose.scale.yml new file mode 100644 index 0000000..ed19405 --- /dev/null +++ b/deploy/docker-compose.scale.yml @@ -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: diff --git a/deploy/front-end_nginx.conf b/deploy/front-end_nginx.conf index 7b557d0..30b7c88 100644 --- a/deploy/front-end_nginx.conf +++ b/deploy/front-end_nginx.conf @@ -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; diff --git a/deploy/nginx.scale.conf b/deploy/nginx.scale.conf new file mode 100644 index 0000000..8f8494e --- /dev/null +++ b/deploy/nginx.scale.conf @@ -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; + } +} diff --git a/frontend/next.config.js b/frontend/next.config.js index 264227e..f34c070 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -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*`, }, ]; }, diff --git a/frontend/package.json b/frontend/package.json index 16fb5ed..54c9c83 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/app/(public)/book/[eventId]/_hooks/useLightningWatcher.ts b/frontend/src/app/(public)/book/[eventId]/_hooks/useLightningWatcher.ts new file mode 100644 index 0000000..c36f5a0 --- /dev/null +++ b/frontend/src/app/(public)/book/[eventId]/_hooks/useLightningWatcher.ts @@ -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 | 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]); +} diff --git a/frontend/src/app/(public)/book/[eventId]/_logic/booking.ts b/frontend/src/app/(public)/book/[eventId]/_logic/booking.ts new file mode 100644 index 0000000..fc506a4 --- /dev/null +++ b/frontend/src/app/(public)/book/[eventId]/_logic/booking.ts @@ -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', + }; +} diff --git a/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx b/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx new file mode 100644 index 0000000..46cb494 --- /dev/null +++ b/frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx @@ -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>; + errors: Partial>; + attendees: AttendeeInfo[]; + setAttendees: React.Dispatch>; + attendeeErrors: { [key: number]: string }; + setAttendeeErrors: React.Dispatch>; + handleRucChange: (e: React.ChangeEvent) => 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 ( +
+
+ + + {t('common.back')} + + + {/* Event Summary - Always Visible */} + +
+

+ {locale === 'es' && event.titleEs ? event.titleEs : event.title} +

+
+
+
+ + {formatDate(event.startDatetime)} • {fmtTime(event.startDatetime)} +
+
+ + {event.location} +
+ {!event.externalBookingEnabled && ( +
+ + {spotsLeft} / {event.capacity} {t('events.details.spotsLeft')} +
+ )} +
+ + + {event.price === 0 + ? t('events.details.free') + : formatPrice(event.price, event.currency)} + + {event.price > 0 && ( + + {locale === 'es' ? 'por persona' : 'per person'} + + )} +
+ {/* Ticket quantity and total */} + {ticketQuantity > 1 && ( +
+
+ + {locale === 'es' ? 'Tickets' : 'Tickets'}: {ticketQuantity} + + + {locale === 'es' ? 'Total' : 'Total'}: {formatPrice(event.price * ticketQuantity, event.currency)} + +
+
+ )} +
+
+ + {isSoldOut ? ( + + +

{t('events.details.soldOut')}

+

{t('booking.form.soldOutMessage')}

+
+ ) : ( +
+ {/* User Information Section */} + +

+ {attendees.length > 0 && ( + + 1 + + )} + {t('booking.form.personalInfo')} + {attendees.length > 0 && ( + + ({locale === 'es' ? 'Asistente principal' : 'Primary attendee'}) + + )} +

+ +
+
+ setFormData({ ...formData, firstName: e.target.value })} + placeholder={t('booking.form.firstNamePlaceholder')} + error={errors.firstName} + required + /> +
+
+ + + ({locale === 'es' ? 'Opcional' : 'Optional'}) + +
+ setFormData({ ...formData, lastName: e.target.value })} + placeholder={t('booking.form.lastNamePlaceholder')} + error={errors.lastName} + /> +
+
+ +
+ setFormData({ ...formData, email: e.target.value })} + placeholder={t('booking.form.emailPlaceholder')} + error={errors.email} + required + /> +
+ +
+
+ + + ({locale === 'es' ? 'Opcional' : 'Optional'}) + +
+ setFormData({ ...formData, phone: e.target.value })} + placeholder={t('booking.form.phonePlaceholder')} + error={errors.phone} + /> +
+ +
+
+ + + {t('booking.form.rucOptional')} + +
+ +
+ +
+ + +
+
+
+ + {/* Additional Attendees Section (for multi-ticket bookings) */} + {attendees.length > 0 && ( + +

+ + {locale === 'es' ? 'Información de los Otros Asistentes' : 'Other Attendees Information'} +

+

+ {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.'} +

+ +
+ {attendees.map((attendee, index) => ( +
+
+ + {index + 2} + + + {locale === 'es' ? `Asistente ${index + 2}` : `Attendee ${index + 2}`} + +
+
+ { + 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 + /> +
+
+ + + ({locale === 'es' ? 'Opcional' : 'Optional'}) + +
+ { + const newAttendees = [...attendees]; + newAttendees[index].lastName = e.target.value; + setAttendees(newAttendees); + }} + placeholder={t('booking.form.lastNamePlaceholder')} + /> +
+
+
+ ))} +
+
+ )} + + {/* Payment Selection Section */} + +

+ {t('booking.form.paymentMethod')} +

+ +
+ {paymentMethods.length === 0 ? ( +
+ {locale === 'es' + ? 'No hay métodos de pago disponibles para este evento.' + : 'No payment methods available for this event.'} +
+ ) : ( + <> + {paymentMethods.map((method) => ( + + ))} + + + )} +
+
+ + {/* Terms & Privacy agreement */} + +
+ 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" + /> + +
+ {termsError && ( +

+ {termsError} +

+ )} +
+ + {/* Submit Button */} + +
+ )} +
+
+ ); +} diff --git a/frontend/src/app/(public)/book/[eventId]/_steps/ManualPaymentStep.tsx b/frontend/src/app/(public)/book/[eventId]/_steps/ManualPaymentStep.tsx new file mode 100644 index 0000000..65e55ad --- /dev/null +++ b/frontend/src/app/(public)/book/[eventId]/_steps/ManualPaymentStep.tsx @@ -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 ( +
+
+ +
+
+ {isBankTransfer ? ( + + ) : ( + + )} +
+

+ {locale === 'es' ? 'Completa tu Pago' : 'Complete Your Payment'} +

+

+ {locale === 'es' + ? 'Sigue las instrucciones para completar tu pago' + : 'Follow the instructions to complete your payment'} +

+
+ + {/* Amount to pay */} +
+

+ {locale === 'es' ? 'Monto a pagar' : 'Amount to pay'} +

+

+ {event?.price !== undefined ? formatPrice(totalAmount, event.currency) : ''} +

+ {ticketCount > 1 && ( +

+ {ticketCount} tickets × {formatPrice(event?.price || 0, event?.currency || 'PYG')} +

+ )} +
+ + {/* Bank Transfer Details */} + {isBankTransfer && ( +
+

+ {locale === 'es' ? 'Datos Bancarios' : 'Bank Details'} +

+
+ {paymentConfig.bankName && ( +
+ {locale === 'es' ? 'Banco' : 'Bank'}: + {paymentConfig.bankName} +
+ )} + {paymentConfig.bankAccountHolder && ( +
+ {locale === 'es' ? 'Titular' : 'Account Holder'}: + {paymentConfig.bankAccountHolder} +
+ )} + {paymentConfig.bankAccountNumber && ( +
+ {locale === 'es' ? 'Nro. Cuenta' : 'Account Number'}: + {paymentConfig.bankAccountNumber} +
+ )} + {paymentConfig.bankAlias && ( +
+ Alias: + {paymentConfig.bankAlias} +
+ )} + {paymentConfig.bankPhone && ( +
+ {locale === 'es' ? 'Teléfono' : 'Phone'}: + {paymentConfig.bankPhone} +
+ )} +
+ {(locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes) && ( +

+ {locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes} +

+ )} +
+ )} + + {/* TPago Link */} + {isTpago && ( +
+

+ {locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'} +

+ {tpagoLink && ( + + + {locale === 'es' ? 'Abrir TPago para Pagar' : 'Open TPago to Pay'} + + )} + {(locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions) && ( +

+ {locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions} +

+ )} +
+ )} + + {/* Reference */} +
+

+ {locale === 'es' ? 'Referencia de tu reserva' : 'Your booking reference'} +

+

{bookingResult.qrCode}

+
+ + {/* Manual verification notice */} +
+
+
+ + + +
+
+

+ {locale === 'es' ? 'Verificación manual' : 'Manual verification'} +

+

+ {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.'} +

+
+
+
+ + {/* Paid under different name option */} +
+ + + {paidUnderDifferentName && ( +
+ setPayerName(e.target.value)} + placeholder={locale === 'es' ? 'Nombre completo del titular de la cuenta' : 'Full name of account holder'} + required + /> +
+ )} +
+ + {/* Warning before I Have Paid button */} +

+ {locale === 'es' + ? 'Solo haz clic aquí después de haber completado el pago.' + : 'Only click this after you have actually completed the payment.'} +

+ + {/* I Have Paid Button */} + + +

+ {locale === 'es' + ? 'Tu reserva será confirmada una vez que verifiquemos el pago' + : 'Your booking will be confirmed once we verify the payment'} +

+
+
+
+ ); +} diff --git a/frontend/src/app/(public)/book/[eventId]/_steps/PayingStep.tsx b/frontend/src/app/(public)/book/[eventId]/_steps/PayingStep.tsx new file mode 100644 index 0000000..1da730d --- /dev/null +++ b/frontend/src/app/(public)/book/[eventId]/_steps/PayingStep.tsx @@ -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 ( +
+
+ + {/* Amount - prominent at top */} +
+ {invoice.fiatAmount && invoice.fiatCurrency && ( +

+ {invoice.fiatAmount.toLocaleString()} {invoice.fiatCurrency} +

+ )} +

+ ≈ {invoice.amount.toLocaleString()} sats +

+
+ + {/* QR Code - clickable to copy */} +
copyInvoiceToClipboard(invoice.paymentRequest, locale)} + title={locale === 'es' ? 'Clic para copiar' : 'Click to copy'} + > + +
+ + {/* Invoice string - truncated, clickable */} +
copyInvoiceToClipboard(invoice.paymentRequest, locale)} + > +

+ + {truncateInvoice(invoice.paymentRequest, 16)} +

+

+ {locale === 'es' ? 'Toca para copiar' : 'Tap to copy'} +

+
+ + {/* Open in Wallet - primary action */} + + + {locale === 'es' ? 'Abrir en Billetera' : 'Open in Wallet'} + + + {/* Status indicator */} +
+
+ {locale === 'es' ? 'Esperando pago...' : 'Waiting for payment...'} +
+ + {/* Ticket reference - small */} +

+ {locale === 'es' ? 'Ref' : 'Ref'}: {qrCode} +

+ +
+
+ ); +} diff --git a/frontend/src/app/(public)/book/[eventId]/_steps/PendingApprovalStep.tsx b/frontend/src/app/(public)/book/[eventId]/_steps/PendingApprovalStep.tsx new file mode 100644 index 0000000..badff33 --- /dev/null +++ b/frontend/src/app/(public)/book/[eventId]/_steps/PendingApprovalStep.tsx @@ -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 ( +
+
+ +
+ +
+ +

+ {locale === 'es' ? '¡Pago en Verificación!' : 'Payment Being Verified!'} +

+

+ {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.'} +

+ +
+
+ + {bookingResult.qrCode} +
+ +
+

{t('booking.success.event')}: {event?.title}

+

{t('booking.success.date')}: {event && formatDate(event.startDatetime)}

+

{t('booking.success.time')}: {event && fmtTime(event.startDatetime)}

+

{t('booking.success.location')}: {event?.location}

+
+
+ +
+

+ {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.'} +

+
+ +
+ + + + + + +
+
+
+
+ ); +} diff --git a/frontend/src/app/(public)/book/[eventId]/_steps/SuccessStep.tsx b/frontend/src/app/(public)/book/[eventId]/_steps/SuccessStep.tsx new file mode 100644 index 0000000..d17b186 --- /dev/null +++ b/frontend/src/app/(public)/book/[eventId]/_steps/SuccessStep.tsx @@ -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 ( +
+
+ +
+ +
+ +

+ {successContent.title} +

+

+ {successContent.description} +

+ +
+ {/* Multi-ticket indicator */} + {bookingResult.ticketCount && bookingResult.ticketCount > 1 && ( +
+

+ {locale === 'es' + ? `${bookingResult.ticketCount} tickets reservados` + : `${bookingResult.ticketCount} tickets booked`} +

+

+ {locale === 'es' + ? 'Cada asistente recibirá su propio código QR' + : 'Each attendee will receive their own QR code'} +

+
+ )} + +
+ + {bookingResult.qrCode} + {bookingResult.ticketCount && bookingResult.ticketCount > 1 && ( + + +{bookingResult.ticketCount - 1} {locale === 'es' ? 'más' : 'more'} + + )} +
+ +
+

{t('booking.success.event')}: {event.title}

+

{t('booking.success.date')}: {formatDate(event.startDatetime)}

+

{t('booking.success.time')}: {fmtTime(event.startDatetime)}

+

{t('booking.success.location')}: {event.location}

+
+
+ + {bookingResult.paymentMethod === 'cash' && ( +
+

+ {t('booking.success.cashNote')}: {t('booking.success.cashDescription')} +

+
+ )} + + {bookingResult.paymentMethod === 'bancard' && ( +
+

+ {t('booking.success.cardNote')} +

+
+ )} + + {bookingResult.paymentMethod === 'lightning' && ( +
+

+ + {locale === 'es' + ? '¡Pago con Bitcoin Lightning recibido exitosamente!' + : 'Bitcoin Lightning payment received successfully!'} +

+
+ )} + +

+ {t('booking.success.emailSent')} +

+ + {/* Download Ticket Button - only for instant confirmation (Lightning) */} + {bookingResult.paymentMethod === 'lightning' && ( + + )} + +
+ + + + + + +
+
+
+
+ ); +} diff --git a/frontend/src/app/(public)/book/[eventId]/_types.ts b/frontend/src/app/(public)/book/[eventId]/_types.ts new file mode 100644 index 0000000..9a4ec81 --- /dev/null +++ b/frontend/src/app/(public)/book/[eventId]/_types.ts @@ -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'; diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index 8eeaf84..8296779 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -2,72 +2,26 @@ import { useState, useEffect } from 'react'; import { useParams, useRouter, useSearchParams } from 'next/navigation'; -import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; import { useAuth } from '@/context/AuthContext'; import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api'; -import { formatPrice, formatDateLong, formatTime, getTpagoLink } from '@/lib/utils'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; -import { QRCodeSVG } from 'qrcode.react'; -import { - CalendarIcon, - MapPinIcon, - UserGroupIcon, - CurrencyDollarIcon, - ArrowLeftIcon, - CheckCircleIcon, - CreditCardIcon, - BanknotesIcon, - BoltIcon, - TicketIcon, - ClipboardDocumentIcon, - BuildingLibraryIcon, - ClockIcon, - ArrowTopRightOnSquareIcon, - UserIcon, - ArrowDownTrayIcon, -} from '@heroicons/react/24/outline'; +import { formatDateLong, formatTime } from '@/lib/utils'; +import { isSafeExternalUrl } from '@/lib/safeRedirect'; import toast from 'react-hot-toast'; - -// Attendee info for each ticket -interface AttendeeInfo { - firstName: string; - lastName: string; -} - -type PaymentMethod = 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago'; - -interface BookingFormData { - firstName: string; - lastName: string; - email: string; - phone: string; - preferredLanguage: 'en' | 'es'; - paymentMethod: PaymentMethod; - ruc: string; -} - -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; -} - -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; -} +import type { + AttendeeInfo, + BookingFormData, + BookingResult, + BookingStep, + PaymentMethod, +} from './_types'; +import { buildPaymentMethods, formatRuc, rucPattern } from './_logic/booking'; +import { useLightningWatcher } from './_hooks/useLightningWatcher'; +import { PayingStep } from './_steps/PayingStep'; +import { ManualPaymentStep } from './_steps/ManualPaymentStep'; +import { PendingApprovalStep } from './_steps/PendingApprovalStep'; +import { SuccessStep } from './_steps/SuccessStep'; +import { BookingFormStep } from './_steps/BookingFormStep'; export default function BookingPage() { const params = useParams(); @@ -78,26 +32,26 @@ export default function BookingPage() { const [event, setEvent] = useState(null); const [paymentConfig, setPaymentConfig] = useState(null); const [loading, setLoading] = useState(true); - const [step, setStep] = useState<'form' | 'paying' | 'manual_payment' | 'pending_approval' | 'success'>('form'); + const [step, setStep] = useState('form'); const [submitting, setSubmitting] = useState(false); const [bookingResult, setBookingResult] = useState(null); - const [paymentPending, setPaymentPending] = useState(false); + const [, setPaymentPending] = useState(false); const [markingPaid, setMarkingPaid] = useState(false); - + // State for payer name (when paid under different name) const [paidUnderDifferentName, setPaidUnderDifferentName] = useState(false); const [payerName, setPayerName] = useState(''); - + // Quantity from URL param (default 1) const initialQuantity = Math.max(1, parseInt(searchParams.get('qty') || '1', 10)); const [ticketQuantity, setTicketQuantity] = useState(initialQuantity); - + // Attendees for multi-ticket bookings (ticket 1 uses main formData) - const [attendees, setAttendees] = useState(() => + const [attendees, setAttendees] = useState(() => Array(Math.max(0, initialQuantity - 1)).fill(null).map(() => ({ firstName: '', lastName: '' })) ); const [attendeeErrors, setAttendeeErrors] = useState<{ [key: number]: string }>({}); - + const [formData, setFormData] = useState({ firstName: '', lastName: '', @@ -114,19 +68,11 @@ export default function BookingPage() { const [agreedToTerms, setAgreedToTerms] = useState(false); const [termsError, setTermsError] = useState(null); - const rucPattern = /^\d{6,10}$/; - - // Format RUC input: digits only, max 10 - const formatRuc = (value: string): string => { - const digits = value.replace(/\D/g, '').slice(0, 10); - return digits; - }; - // Handle RUC input change const handleRucChange = (e: React.ChangeEvent) => { const formatted = formatRuc(e.target.value); setFormData({ ...formData, ruc: formatted }); - + // Clear error on change if (errors.ruc) { setErrors({ ...errors, ruc: undefined }); @@ -155,8 +101,12 @@ export default function BookingPage() { return; } - // Redirect to external booking if enabled - if (eventRes.event.externalBookingEnabled && eventRes.event.externalBookingUrl) { + // Redirect to external booking if enabled (only https:// targets are allowed) + if ( + eventRes.event.externalBookingEnabled && + eventRes.event.externalBookingUrl && + isSafeExternalUrl(eventRes.event.externalBookingUrl) + ) { window.location.href = eventRes.event.externalBookingUrl; return; } @@ -181,7 +131,7 @@ export default function BookingPage() { return Array(need).fill(null).map((_, i) => prev[i] ?? { firstName: '', lastName: '' }); }); setPaymentConfig(paymentRes.paymentOptions); - + // Set default payment method based on what's enabled const config = paymentRes.paymentOptions; if (config.lightningEnabled) { @@ -207,7 +157,7 @@ export default function BookingPage() { const nameParts = (user.name || '').trim().split(' '); const firstName = nameParts[0] || ''; const lastName = nameParts.slice(1).join(' ') || ''; - + return { ...prev, firstName: prev.firstName || firstName, @@ -240,25 +190,25 @@ export default function BookingPage() { const validateForm = (): boolean => { const newErrors: Partial> = {}; const newAttendeeErrors: { [key: number]: string } = {}; - + if (!formData.firstName.trim() || formData.firstName.length < 2) { newErrors.firstName = t('booking.form.errors.firstNameRequired'); } - + // lastName is optional - only validate if provided if (formData.lastName.trim() && formData.lastName.length < 2) { newErrors.lastName = t('booking.form.errors.lastNameTooShort'); } - + if (!formData.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { newErrors.email = t('booking.form.errors.emailInvalid'); } - + // phone is optional - only validate if provided if (formData.phone.trim() && formData.phone.length < 6) { newErrors.phone = t('booking.form.errors.phoneTooShort'); } - + // RUC validation (optional field - 6–10 digits if filled) if (formData.ruc.trim()) { const digits = formData.ruc.replace(/\D/g, ''); @@ -266,16 +216,16 @@ export default function BookingPage() { newErrors.ruc = t('booking.form.errors.rucInvalidFormat'); } } - + // Validate additional attendees (if multi-ticket) attendees.forEach((attendee, index) => { if (!attendee.firstName.trim() || attendee.firstName.length < 2) { - newAttendeeErrors[index] = locale === 'es' + newAttendeeErrors[index] = locale === 'es' ? 'Ingresa el nombre del asistente' : 'Enter attendee name'; } }); - + setErrors(newErrors); setAttendeeErrors(newAttendeeErrors); @@ -294,108 +244,30 @@ export default function BookingPage() { ); }; - // Connect to SSE for real-time payment updates - const connectPaymentStream = (ticketId: string) => { - const apiUrl = process.env.NEXT_PUBLIC_API_URL || ''; - const eventSource = new EventSource(`${apiUrl}/api/lnbits/stream/${ticketId}`); - - eventSource.addEventListener('payment', (event) => { - try { - const data = JSON.parse(event.data); - console.log('Payment event:', data); - - if (data.type === 'paid') { - toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!'); - setPaymentPending(false); - setStep('success'); - eventSource.close(); - } else if (data.type === 'expired') { - toast.error(locale === 'es' ? 'La factura ha expirado' : 'Invoice has expired'); - setPaymentPending(false); - eventSource.close(); - } else if (data.type === 'already_paid') { - setPaymentPending(false); - setStep('success'); - eventSource.close(); - } - } catch (e) { - console.error('Error parsing payment event:', e); - } - }); - - eventSource.onerror = (error) => { - console.error('SSE error:', error); - // Fallback to polling if SSE fails - eventSource.close(); - fallbackPoll(ticketId); - }; - - return eventSource; - }; - - // Fallback polling if SSE is not available - const fallbackPoll = async (ticketId: string) => { - const maxAttempts = 60; - let attempts = 0; - - const poll = async () => { - attempts++; - try { - const status = await ticketsApi.checkPaymentStatus(ticketId); - if (status.isPaid) { - toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!'); - setPaymentPending(false); - setStep('success'); - return; - } - } catch (error) { - console.error('Error checking payment status:', error); - } - - if (attempts < maxAttempts && paymentPending) { - setTimeout(poll, 5000); - } - }; - - poll(); - }; - - // Copy invoice to clipboard - const copyInvoiceToClipboard = (invoice: string) => { - navigator.clipboard.writeText(invoice).then(() => { - toast.success(locale === 'es' ? '¡Copiado!' : 'Copied!'); - }).catch(() => { - toast.error(locale === 'es' ? 'Error al copiar' : 'Failed to copy'); - }); - }; - - // Truncate invoice for display - const truncateInvoice = (invoice: string, chars: number = 20) => { - if (invoice.length <= chars * 2) return invoice; - return `${invoice.slice(0, chars)}...${invoice.slice(-chars)}`; - }; + // Watch for Lightning payment confirmation while on the paying step. + useLightningWatcher(step, bookingResult?.ticketId, locale, setPaymentPending, setStep); // Handle "I Have Paid" button click const handleMarkPaymentSent = async () => { if (!bookingResult) return; - + // Validate payer name if paid under different name if (paidUnderDifferentName && !payerName.trim()) { - toast.error(locale === 'es' - ? 'Por favor ingresa el nombre del pagador' + toast.error(locale === 'es' + ? 'Por favor ingresa el nombre del pagador' : 'Please enter the payer name'); return; } - + setMarkingPaid(true); try { await ticketsApi.markPaymentSent( - bookingResult.ticketId, + bookingResult.ticketId, paidUnderDifferentName ? payerName.trim() : undefined ); setStep('pending_approval'); - toast.success(locale === 'es' - ? 'Pago marcado como enviado. Esperando aprobación.' + toast.success(locale === 'es' + ? 'Pago marcado como enviado. Esperando aprobación.' : 'Payment marked as sent. Waiting for approval.'); } catch (error: any) { toast.error(error.message || 'Failed to mark payment as sent'); @@ -419,7 +291,7 @@ export default function BookingPage() { { firstName: formData.firstName, lastName: formData.lastName }, ...attendees ]; - + const response = await ticketsApi.book({ eventId: event.id, firstName: formData.firstName, @@ -432,11 +304,11 @@ export default function BookingPage() { // Include attendees array for multi-ticket bookings ...(allAttendees.length > 1 && { attendees: allAttendees }), }); - + const { ticket, tickets: ticketsList, bookingId, lightningInvoice } = response as any; const ticketCount = ticketsList?.length || 1; const primaryTicket = ticket || ticketsList?.[0]; - + // If Lightning payment with invoice, go to paying step if (formData.paymentMethod === 'lightning' && lightningInvoice?.paymentRequest) { const result: BookingResult = { @@ -459,9 +331,7 @@ export default function BookingPage() { setBookingResult(result); setStep('paying'); setPaymentPending(true); - - // Connect to SSE for real-time payment updates - connectPaymentStream(primaryTicket.id); + // Payment confirmation is handled by the paying-step watcher effect. } else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') { // Manual payment methods - show payment details setBookingResult({ @@ -473,6 +343,16 @@ export default function BookingPage() { paymentMethod: formData.paymentMethod, ticketCount, }); + // Fetch full payment credentials now that we hold a ticket capability token + try { + const { paymentOptions } = await paymentOptionsApi.getForEvent( + params.eventId as string, + primaryTicket.id + ); + setPaymentConfig(paymentOptions); + } catch { + // Keep the flags-only config from initial load if the gated fetch fails + } setStep('manual_payment'); } else { // Cash payment - go straight to success @@ -496,47 +376,7 @@ export default function BookingPage() { }; // Build payment methods list based on configuration - const paymentMethods: { id: PaymentMethod; icon: typeof CreditCardIcon; label: string; description: string; badge?: string }[] = []; - - 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', - }); - } + const paymentMethods = buildPaymentMethods(paymentConfig, locale); if (loading) { return ( @@ -555,872 +395,88 @@ export default function BookingPage() { const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0)); const isSoldOut = (event.bookedCount ?? 0) >= event.capacity; - // Get title and description based on payment method - const getSuccessContent = () => { - 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', - }; - }; - // Paying step - waiting for Lightning payment (compact design) if (step === 'paying' && bookingResult && bookingResult.lightningInvoice) { - const invoice = bookingResult.lightningInvoice; - return ( -
-
- - {/* Amount - prominent at top */} -
- {invoice.fiatAmount && invoice.fiatCurrency && ( -

- {invoice.fiatAmount.toLocaleString()} {invoice.fiatCurrency} -

- )} -

- ≈ {invoice.amount.toLocaleString()} sats -

-
- - {/* QR Code - clickable to copy */} -
copyInvoiceToClipboard(invoice.paymentRequest)} - title={locale === 'es' ? 'Clic para copiar' : 'Click to copy'} - > - -
- - {/* Invoice string - truncated, clickable */} -
copyInvoiceToClipboard(invoice.paymentRequest)} - > -

- - {truncateInvoice(invoice.paymentRequest, 16)} -

-

- {locale === 'es' ? 'Toca para copiar' : 'Tap to copy'} -

-
- - {/* Open in Wallet - primary action */} - - - {locale === 'es' ? 'Abrir en Billetera' : 'Open in Wallet'} - - - {/* Status indicator */} -
-
- {locale === 'es' ? 'Esperando pago...' : 'Waiting for payment...'} -
- - {/* Ticket reference - small */} -

- {locale === 'es' ? 'Ref' : 'Ref'}: {bookingResult.qrCode} -

- -
-
+ ); } // Manual payment step - showing bank transfer details or TPago link if (step === 'manual_payment' && bookingResult && paymentConfig) { - 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 ( -
-
- -
-
- {isBankTransfer ? ( - - ) : ( - - )} -
-

- {locale === 'es' ? 'Completa tu Pago' : 'Complete Your Payment'} -

-

- {locale === 'es' - ? 'Sigue las instrucciones para completar tu pago' - : 'Follow the instructions to complete your payment'} -

-
- - {/* Amount to pay */} -
-

- {locale === 'es' ? 'Monto a pagar' : 'Amount to pay'} -

-

- {event?.price !== undefined ? formatPrice(totalAmount, event.currency) : ''} -

- {ticketCount > 1 && ( -

- {ticketCount} tickets × {formatPrice(event?.price || 0, event?.currency || 'PYG')} -

- )} -
- - {/* Bank Transfer Details */} - {isBankTransfer && ( -
-

- {locale === 'es' ? 'Datos Bancarios' : 'Bank Details'} -

-
- {paymentConfig.bankName && ( -
- {locale === 'es' ? 'Banco' : 'Bank'}: - {paymentConfig.bankName} -
- )} - {paymentConfig.bankAccountHolder && ( -
- {locale === 'es' ? 'Titular' : 'Account Holder'}: - {paymentConfig.bankAccountHolder} -
- )} - {paymentConfig.bankAccountNumber && ( -
- {locale === 'es' ? 'Nro. Cuenta' : 'Account Number'}: - {paymentConfig.bankAccountNumber} -
- )} - {paymentConfig.bankAlias && ( -
- Alias: - {paymentConfig.bankAlias} -
- )} - {paymentConfig.bankPhone && ( -
- {locale === 'es' ? 'Teléfono' : 'Phone'}: - {paymentConfig.bankPhone} -
- )} -
- {(locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes) && ( -

- {locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes} -

- )} -
- )} - - {/* TPago Link */} - {isTpago && ( -
-

- {locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'} -

- {tpagoLink && ( - - - {locale === 'es' ? 'Abrir TPago para Pagar' : 'Open TPago to Pay'} - - )} - {(locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions) && ( -

- {locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions} -

- )} -
- )} - - {/* Reference */} -
-

- {locale === 'es' ? 'Referencia de tu reserva' : 'Your booking reference'} -

-

{bookingResult.qrCode}

-
- - {/* Manual verification notice */} -
-
-
- - - -
-
-

- {locale === 'es' ? 'Verificación manual' : 'Manual verification'} -

-

- {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.'} -

-
-
-
- - {/* Paid under different name option */} -
- - - {paidUnderDifferentName && ( -
- setPayerName(e.target.value)} - placeholder={locale === 'es' ? 'Nombre completo del titular de la cuenta' : 'Full name of account holder'} - required - /> -
- )} -
- - {/* Warning before I Have Paid button */} -

- {locale === 'es' - ? 'Solo haz clic aquí después de haber completado el pago.' - : 'Only click this after you have actually completed the payment.'} -

- - {/* I Have Paid Button */} - - -

- {locale === 'es' - ? 'Tu reserva será confirmada una vez que verifiquemos el pago' - : 'Your booking will be confirmed once we verify the payment'} -

-
-
-
+ ); } // Pending approval step - user has marked payment as sent if (step === 'pending_approval' && bookingResult) { return ( -
-
- -
- -
- -

- {locale === 'es' ? '¡Pago en Verificación!' : 'Payment Being Verified!'} -

-

- {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.'} -

- -
-
- - {bookingResult.qrCode} -
- -
-

{t('booking.success.event')}: {event?.title}

-

{t('booking.success.date')}: {event && formatDate(event.startDatetime)}

-

{t('booking.success.time')}: {event && fmtTime(event.startDatetime)}

-

{t('booking.success.location')}: {event?.location}

-
-
- -
-

- {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.'} -

-
- -
- - - - - - -
-
-
-
+ ); } // Success step if (step === 'success' && bookingResult) { - const successContent = getSuccessContent(); - return ( -
-
- -
- -
- -

- {successContent.title} -

-

- {successContent.description} -

- -
- {/* Multi-ticket indicator */} - {bookingResult.ticketCount && bookingResult.ticketCount > 1 && ( -
-

- {locale === 'es' - ? `${bookingResult.ticketCount} tickets reservados` - : `${bookingResult.ticketCount} tickets booked`} -

-

- {locale === 'es' - ? 'Cada asistente recibirá su propio código QR' - : 'Each attendee will receive their own QR code'} -

-
- )} - -
- - {bookingResult.qrCode} - {bookingResult.ticketCount && bookingResult.ticketCount > 1 && ( - - +{bookingResult.ticketCount - 1} {locale === 'es' ? 'más' : 'more'} - - )} -
- -
-

{t('booking.success.event')}: {event.title}

-

{t('booking.success.date')}: {formatDate(event.startDatetime)}

-

{t('booking.success.time')}: {fmtTime(event.startDatetime)}

-

{t('booking.success.location')}: {event.location}

-
-
- - {bookingResult.paymentMethod === 'cash' && ( -
-

- {t('booking.success.cashNote')}: {t('booking.success.cashDescription')} -

-
- )} - - {bookingResult.paymentMethod === 'bancard' && ( -
-

- {t('booking.success.cardNote')} -

-
- )} - - {bookingResult.paymentMethod === 'lightning' && ( -
-

- - {locale === 'es' - ? '¡Pago con Bitcoin Lightning recibido exitosamente!' - : 'Bitcoin Lightning payment received successfully!'} -

-
- )} - -

- {t('booking.success.emailSent')} -

- - {/* Download Ticket Button - only for instant confirmation (Lightning) */} - {bookingResult.paymentMethod === 'lightning' && ( - - )} - -
- - - - - - -
-
-
-
+ ); } return ( -
-
- - - {t('common.back')} - - - {/* Event Summary - Always Visible */} - -
-

- {locale === 'es' && event.titleEs ? event.titleEs : event.title} -

-
-
-
- - {formatDate(event.startDatetime)} • {fmtTime(event.startDatetime)} -
-
- - {event.location} -
- {!event.externalBookingEnabled && ( -
- - {spotsLeft} / {event.capacity} {t('events.details.spotsLeft')} -
- )} -
- - - {event.price === 0 - ? t('events.details.free') - : formatPrice(event.price, event.currency)} - - {event.price > 0 && ( - - {locale === 'es' ? 'por persona' : 'per person'} - - )} -
- {/* Ticket quantity and total */} - {ticketQuantity > 1 && ( -
-
- - {locale === 'es' ? 'Tickets' : 'Tickets'}: {ticketQuantity} - - - {locale === 'es' ? 'Total' : 'Total'}: {formatPrice(event.price * ticketQuantity, event.currency)} - -
-
- )} -
-
- - {isSoldOut ? ( - - -

{t('events.details.soldOut')}

-

{t('booking.form.soldOutMessage')}

-
- ) : ( -
- {/* User Information Section */} - -

- {attendees.length > 0 && ( - - 1 - - )} - {t('booking.form.personalInfo')} - {attendees.length > 0 && ( - - ({locale === 'es' ? 'Asistente principal' : 'Primary attendee'}) - - )} -

- -
-
- setFormData({ ...formData, firstName: e.target.value })} - placeholder={t('booking.form.firstNamePlaceholder')} - error={errors.firstName} - required - /> -
-
- - - ({locale === 'es' ? 'Opcional' : 'Optional'}) - -
- setFormData({ ...formData, lastName: e.target.value })} - placeholder={t('booking.form.lastNamePlaceholder')} - error={errors.lastName} - /> -
-
- -
- setFormData({ ...formData, email: e.target.value })} - placeholder={t('booking.form.emailPlaceholder')} - error={errors.email} - required - /> -
- -
-
- - - ({locale === 'es' ? 'Opcional' : 'Optional'}) - -
- setFormData({ ...formData, phone: e.target.value })} - placeholder={t('booking.form.phonePlaceholder')} - error={errors.phone} - /> -
- -
-
- - - {t('booking.form.rucOptional')} - -
- -
- -
- - -
-
-
- - {/* Additional Attendees Section (for multi-ticket bookings) */} - {attendees.length > 0 && ( - -

- - {locale === 'es' ? 'Información de los Otros Asistentes' : 'Other Attendees Information'} -

-

- {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.'} -

- -
- {attendees.map((attendee, index) => ( -
-
- - {index + 2} - - - {locale === 'es' ? `Asistente ${index + 2}` : `Attendee ${index + 2}`} - -
-
- { - 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 - /> -
-
- - - ({locale === 'es' ? 'Opcional' : 'Optional'}) - -
- { - const newAttendees = [...attendees]; - newAttendees[index].lastName = e.target.value; - setAttendees(newAttendees); - }} - placeholder={t('booking.form.lastNamePlaceholder')} - /> -
-
-
- ))} -
-
- )} - - {/* Payment Selection Section */} - -

- {t('booking.form.paymentMethod')} -

- -
- {paymentMethods.length === 0 ? ( -
- {locale === 'es' - ? 'No hay métodos de pago disponibles para este evento.' - : 'No payment methods available for this event.'} -
- ) : ( - <> - {paymentMethods.map((method) => ( - - ))} - - - )} -
-
- - {/* Terms & Privacy agreement */} - -
- 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" - /> - -
- {termsError && ( -

- {termsError} -

- )} -
- - {/* Submit Button */} - -
- )} -
-
+ ); } diff --git a/frontend/src/app/(public)/booking/[ticketId]/page.tsx b/frontend/src/app/(public)/booking/[ticketId]/page.tsx index 91f6116..5a371ed 100644 --- a/frontend/src/app/(public)/booking/[ticketId]/page.tsx +++ b/frontend/src/app/(public)/booking/[ticketId]/page.tsx @@ -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); } diff --git a/frontend/src/app/(public)/featured/page.tsx b/frontend/src/app/(public)/featured/page.tsx new file mode 100644 index 0000000..b5a90df --- /dev/null +++ b/frontend/src/app/(public)/featured/page.tsx @@ -0,0 +1,23 @@ +import { redirect } from 'next/navigation'; + +const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +async function getFeaturedEventSlug(): Promise { + 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'); +} diff --git a/frontend/src/app/(public)/login/page.tsx b/frontend/src/app/(public)/login/page.tsx index 9ae5864..809c09d 100644 --- a/frontend/src/app/(public)/login/page.tsx +++ b/frontend/src/app/(public)/login/page.tsx @@ -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(); diff --git a/frontend/src/app/(public)/next/page.tsx b/frontend/src/app/(public)/next/page.tsx new file mode 100644 index 0000000..65ec2e8 --- /dev/null +++ b/frontend/src/app/(public)/next/page.tsx @@ -0,0 +1,23 @@ +import { redirect } from 'next/navigation'; + +const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +async function getNextEventSlug(): Promise { + 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'); +} diff --git a/frontend/src/app/admin/emails/page.tsx b/frontend/src/app/admin/emails/page.tsx index 2e2b754..66c97ed 100644 --- a/frontend/src/app/admin/emails/page.tsx +++ b/frontend/src/app/admin/emails/page.tsx @@ -1047,6 +1047,7 @@ export default function AdminEmailsPage() {