From 613bd7be1de70ac62b643b37d11605aeab3ba677 Mon Sep 17 00:00:00 2001 From: Michilis Date: Thu, 25 Jun 2026 07:12:59 +0000 Subject: [PATCH] Refactor monolithic modules and harden booking, email, and auth infrastructure. Split oversized frontend API client, email service, and admin/booking pages into focused modules while preserving import surfaces, and add Redis-backed queues, stale booking cleanup, stronger auth, and scale deployment configs. Co-authored-by: Cursor --- README.md | 83 +- backend/.env.example | 41 + backend/package.json | 2 + backend/src/db/index.ts | 3 + backend/src/db/migrate.ts | 24 + backend/src/db/schema.ts | 25 + backend/src/index.ts | 37 +- backend/src/lib/auth.ts | 35 +- backend/src/lib/backends.ts | 36 + backend/src/lib/bookingCleanup.ts | 104 + backend/src/lib/email.ts | 1477 +------------- backend/src/lib/email/bookingEmails.ts | 96 + backend/src/lib/email/bulkEmails.ts | 101 + backend/src/lib/email/formatting.ts | 69 + backend/src/lib/email/paymentEmails.ts | 474 +++++ backend/src/lib/email/templateService.ts | 307 +++ backend/src/lib/email/transport.ts | 308 +++ backend/src/lib/emailQueue.ts | 211 +- backend/src/lib/rateLimit.ts | 45 +- backend/src/lib/redis.ts | 93 + backend/src/lib/storage.ts | 136 ++ backend/src/lib/stores/cache.ts | 105 + backend/src/lib/stores/lock.ts | 111 ++ backend/src/lib/stores/pubsub.ts | 121 ++ backend/src/lib/stores/rateLimiter.ts | 98 + backend/src/routes/admin.ts | 29 +- backend/src/routes/auth.ts | 15 + backend/src/routes/contacts.ts | 15 +- backend/src/routes/emails.ts | 7 +- backend/src/routes/legal-pages.ts | 18 - backend/src/routes/lnbits.ts | 79 +- backend/src/routes/media.ts | 31 +- deploy/docker-compose.scale.yml | 81 + deploy/nginx.scale.conf | 29 + frontend/package.json | 3 +- .../[eventId]/_hooks/useLightningWatcher.ts | 83 + .../(public)/book/[eventId]/_logic/booking.ts | 131 ++ .../book/[eventId]/_steps/BookingFormStep.tsx | 447 +++++ .../[eventId]/_steps/ManualPaymentStep.tsx | 249 +++ .../book/[eventId]/_steps/PayingStep.tsx | 81 + .../[eventId]/_steps/PendingApprovalStep.tsx | 76 + .../book/[eventId]/_steps/SuccessStep.tsx | 144 ++ .../src/app/(public)/book/[eventId]/_types.ts | 40 + .../src/app/(public)/book/[eventId]/page.tsx | 1167 +---------- .../events/[id]/_components/StatusBadge.tsx | 19 + .../events/[id]/_hooks/useEventDetailData.ts | 37 + .../events/[id]/_hooks/usePaymentOverrides.ts | 96 + .../admin/events/[id]/_modals/EventModals.tsx | 521 +++++ .../admin/events/[id]/_tabs/AttendeesTab.tsx | 355 ++++ .../app/admin/events/[id]/_tabs/EmailTab.tsx | 155 ++ .../admin/events/[id]/_tabs/OverviewTab.tsx | 83 + .../admin/events/[id]/_tabs/PaymentsTab.tsx | 406 ++++ .../admin/events/[id]/_tabs/TicketsTab.tsx | 258 +++ frontend/src/app/admin/events/[id]/_types.ts | 26 + .../app/admin/events/[id]/_utils/format.ts | 20 + frontend/src/app/admin/events/[id]/page.tsx | 1762 ++--------------- frontend/src/lib/api.ts | 1311 ------------ frontend/src/lib/api/admin.ts | 45 + frontend/src/lib/api/auth.ts | 63 + frontend/src/lib/api/client.ts | 66 + frontend/src/lib/api/contacts.ts | 27 + frontend/src/lib/api/dashboard.ts | 65 + frontend/src/lib/api/emails.ts | 90 + frontend/src/lib/api/events.ts | 39 + frontend/src/lib/api/faq.ts | 50 + frontend/src/lib/api/index.ts | 19 + frontend/src/lib/api/legalPages.ts | 34 + frontend/src/lib/api/legalSettings.ts | 12 + frontend/src/lib/api/media.ts | 37 + frontend/src/lib/api/paymentOptions.ts | 41 + frontend/src/lib/api/payments.ts | 49 + frontend/src/lib/api/siteSettings.ts | 21 + frontend/src/lib/api/tickets.ts | 142 ++ frontend/src/lib/api/types.ts | 546 +++++ frontend/src/lib/api/users.ts | 20 + 75 files changed, 7702 insertions(+), 5580 deletions(-) create mode 100644 backend/src/lib/backends.ts create mode 100644 backend/src/lib/bookingCleanup.ts create mode 100644 backend/src/lib/email/bookingEmails.ts create mode 100644 backend/src/lib/email/bulkEmails.ts create mode 100644 backend/src/lib/email/formatting.ts create mode 100644 backend/src/lib/email/paymentEmails.ts create mode 100644 backend/src/lib/email/templateService.ts create mode 100644 backend/src/lib/email/transport.ts create mode 100644 backend/src/lib/redis.ts create mode 100644 backend/src/lib/storage.ts create mode 100644 backend/src/lib/stores/cache.ts create mode 100644 backend/src/lib/stores/lock.ts create mode 100644 backend/src/lib/stores/pubsub.ts create mode 100644 backend/src/lib/stores/rateLimiter.ts create mode 100644 deploy/docker-compose.scale.yml create mode 100644 deploy/nginx.scale.conf create mode 100644 frontend/src/app/(public)/book/[eventId]/_hooks/useLightningWatcher.ts create mode 100644 frontend/src/app/(public)/book/[eventId]/_logic/booking.ts create mode 100644 frontend/src/app/(public)/book/[eventId]/_steps/BookingFormStep.tsx create mode 100644 frontend/src/app/(public)/book/[eventId]/_steps/ManualPaymentStep.tsx create mode 100644 frontend/src/app/(public)/book/[eventId]/_steps/PayingStep.tsx create mode 100644 frontend/src/app/(public)/book/[eventId]/_steps/PendingApprovalStep.tsx create mode 100644 frontend/src/app/(public)/book/[eventId]/_steps/SuccessStep.tsx create mode 100644 frontend/src/app/(public)/book/[eventId]/_types.ts create mode 100644 frontend/src/app/admin/events/[id]/_components/StatusBadge.tsx create mode 100644 frontend/src/app/admin/events/[id]/_hooks/useEventDetailData.ts create mode 100644 frontend/src/app/admin/events/[id]/_hooks/usePaymentOverrides.ts create mode 100644 frontend/src/app/admin/events/[id]/_modals/EventModals.tsx create mode 100644 frontend/src/app/admin/events/[id]/_tabs/AttendeesTab.tsx create mode 100644 frontend/src/app/admin/events/[id]/_tabs/EmailTab.tsx create mode 100644 frontend/src/app/admin/events/[id]/_tabs/OverviewTab.tsx create mode 100644 frontend/src/app/admin/events/[id]/_tabs/PaymentsTab.tsx create mode 100644 frontend/src/app/admin/events/[id]/_tabs/TicketsTab.tsx create mode 100644 frontend/src/app/admin/events/[id]/_types.ts create mode 100644 frontend/src/app/admin/events/[id]/_utils/format.ts delete mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/api/admin.ts create mode 100644 frontend/src/lib/api/auth.ts create mode 100644 frontend/src/lib/api/client.ts create mode 100644 frontend/src/lib/api/contacts.ts create mode 100644 frontend/src/lib/api/dashboard.ts create mode 100644 frontend/src/lib/api/emails.ts create mode 100644 frontend/src/lib/api/events.ts create mode 100644 frontend/src/lib/api/faq.ts create mode 100644 frontend/src/lib/api/index.ts create mode 100644 frontend/src/lib/api/legalPages.ts create mode 100644 frontend/src/lib/api/legalSettings.ts create mode 100644 frontend/src/lib/api/media.ts create mode 100644 frontend/src/lib/api/paymentOptions.ts create mode 100644 frontend/src/lib/api/payments.ts create mode 100644 frontend/src/lib/api/siteSettings.ts create mode 100644 frontend/src/lib/api/tickets.ts create mode 100644 frontend/src/lib/api/types.ts create mode 100644 frontend/src/lib/api/users.ts 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 70c388d..03c7ca6 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -432,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 ( @@ -899,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 ( diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 1efc7ca..addab36 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -273,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(), @@ -608,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(), @@ -695,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 82d876d..fe47c13 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(); @@ -1870,9 +1873,16 @@ app.use('/uploads/*', async (c, next) => { }); 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 @@ -1909,15 +1919,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 1c732a6..61c965a 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -192,11 +192,44 @@ 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 }; } 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 efb9ecc..8d35060 100644 --- a/backend/src/lib/email.ts +++ b/backend/src/lib/email.ts @@ -1,1429 +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 - */ -// 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}`; -} - -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); - } -} - -// ==================== 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, 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 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: 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 = { - ...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/rateLimit.ts b/backend/src/lib/rateLimit.ts index bcec78c..de2f9a9 100644 --- a/backend/src/lib/rateLimit.ts +++ b/backend/src/lib/rateLimit.ts @@ -1,30 +1,15 @@ import { Context } from 'hono'; +import { getRateLimiter } from './stores/rateLimiter.js'; /** - * Simple in-memory rate limiter. + * Rate limiting helpers. * - * Suitable for a single backend instance (the current deployment model). If the - * backend is ever scaled horizontally, replace the in-memory Map with a shared - * store (e.g. Redis) so limits are enforced across instances. + * The actual counting is delegated to a pluggable rate limiter (in-memory by + * default, Redis-backed when REDIS_URL is set) so limits are enforced either + * per instance (single-instance deployments) or across all instances + * (horizontal scaling). See lib/stores/rateLimiter.ts. */ -interface Bucket { - count: number; - resetAt: number; -} - -const buckets = new Map(); - -// Periodically drop expired buckets so the Map does not grow unbounded. -const cleanup = setInterval(() => { - const now = Date.now(); - for (const [key, bucket] of buckets) { - if (now > bucket.resetAt) buckets.delete(key); - } -}, 60_000); -// Don't keep the process alive just for cleanup. -(cleanup as any).unref?.(); - /** Best-effort client IP extraction (honours common reverse-proxy headers). */ export function getClientIp(c: Context): string { const forwarded = c.req.header('x-forwarded-for'); @@ -40,20 +25,8 @@ export function consumeRateLimit( key: string, max: number, windowMs: number -): { allowed: boolean; retryAfter?: number } { - const now = Date.now(); - const bucket = buckets.get(key); - - if (!bucket || now > bucket.resetAt) { - 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 }; +): Promise<{ allowed: boolean; retryAfter?: number }> { + return getRateLimiter().consume(key, max, windowMs); } /** @@ -63,7 +36,7 @@ export function consumeRateLimit( export function rateLimitMiddleware(opts: { max: number; windowMs: number; prefix: string }) { return async (c: Context, next: () => Promise) => { const ip = getClientIp(c); - const result = consumeRateLimit(`${opts.prefix}:${ip}`, opts.max, opts.windowMs); + 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 }, 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 34cdab9..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(); @@ -291,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', @@ -380,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 74e1a47..8e65ee0 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -229,6 +229,21 @@ auth.post('/login', authRateLimit, 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, user.tokenVersion ?? 0); const refreshToken = await createRefreshToken(user.id); diff --git a/backend/src/routes/contacts.ts b/backend/src/routes/contacts.ts index 9e26143..b982a59 100644 --- a/backend/src/routes/contacts.ts +++ b/backend/src/routes/contacts.ts @@ -4,7 +4,7 @@ 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'; @@ -16,19 +16,6 @@ const publicFormLimit = rateLimitMiddleware({ max: 5, windowMs: 10 * 60 * 1000, // ==================== Sanitization Helpers ==================== -/** - * 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, '''); -} - /** * Sanitize email header values to prevent email header injection * Strips newlines and carriage returns that could be used to inject headers diff --git a/backend/src/routes/emails.ts b/backend/src/routes/emails.ts index 68dd6c5..d94a177 100644 --- a/backend/src/routes/emails.ts +++ b/backend/src/routes/emails.ts @@ -163,9 +163,8 @@ emailsRouter.put('/templates/:id', requireAuth(['admin']), zValidator('json', up 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'); @@ -486,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/legal-pages.ts b/backend/src/routes/legal-pages.ts index 6132094..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 ''; diff --git a/backend/src/routes/lnbits.ts b/backend/src/routes/lnbits.ts index 9511c1a..1493071 100644 --- a/backend/src/routes/lnbits.ts +++ b/backend/src/routes/lnbits.ts @@ -5,15 +5,26 @@ 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) +// 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,9 +43,21 @@ 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. */ 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) { await Promise.all( @@ -49,17 +72,42 @@ async function notifyClients(ticketId: string, data: any) { } } +// 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`); @@ -73,6 +121,7 @@ function startBackgroundChecker(ticketId: string, paymentHash: string, expirySec console.log(`Invoice expired for ticket ${ticketId}`); clearInterval(checkInterval); activeCheckers.delete(ticketId); + releaseCheckerLock(ticketId); await notifyClients(ticketId, { type: 'expired', ticketId }); return; } @@ -84,6 +133,7 @@ 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); await notifyClients(ticketId, { type: 'paid', ticketId, paymentHash }); @@ -104,6 +154,7 @@ function stopBackgroundChecker(ticketId: string) { if (interval) { clearInterval(interval); activeCheckers.delete(ticketId); + releaseCheckerLock(ticketId); } } @@ -273,7 +324,7 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => { // Start background checker if not already running (only while still pending) if (ticket.status !== 'confirmed' && payment?.reference && !activeCheckers.has(ticketId)) { - startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry + await startBackgroundChecker(ticketId, payment.reference, 900); // 15 min expiry } // Prevent proxies/CDNs from buffering the event stream so events flush immediately. @@ -291,9 +342,15 @@ lnbitsRouter.get('/stream/:ticketId', async (c) => { return; } - // Register this connection + // 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); @@ -317,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 a2af83e..c2b4365 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -3,13 +3,10 @@ import { db, dbGet, dbAll, media } from '../db/index.js'; 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 } from 'path'; +import { getStorage, keyFromUrl } from '../lib/storage.js'; const mediaRouter = new Hono(); -const UPLOAD_DIR = './uploads'; const MAX_FILE_SIZE = (Number(process.env.MEDIA_MAX_UPLOAD_MB || '10') || 10) * 1024 * 1024; // default 10MB @@ -51,13 +48,6 @@ function detectImageType(buf: Buffer): { mime: string; ext: string } | null { return null; } -// Ensure upload directory exists -async function ensureUploadDir() { - if (!existsSync(UPLOAD_DIR)) { - await mkdir(UPLOAD_DIR, { recursive: true }); - } -} - // Upload image mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => { try { @@ -83,15 +73,13 @@ mediaRouter.post('/upload', requireAuth(['admin', 'organizer']), async (c) => { return c.json({ error: 'Invalid file. Allowed: JPEG, PNG, GIF, WebP, AVIF' }, 400); } - await ensureUploadDir(); - // Generate unique filename using the *detected* extension (ignore client filename) const id = generateId(); const filename = `${id}${detected.ext}`; - const filepath = join(UPLOAD_DIR, filename); - - // Write file - await writeFile(filepath, buffer); + + // 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; @@ -101,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, @@ -147,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); } 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/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/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 ed20683..8296779 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -2,73 +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 { formatDateLong, formatTime } from '@/lib/utils'; import { isSafeExternalUrl } from '@/lib/safeRedirect'; -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 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(); @@ -79,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: '', @@ -115,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 }); @@ -186,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) { @@ -212,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, @@ -245,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, ''); @@ -271,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); @@ -300,111 +245,29 @@ export default function BookingPage() { }; // 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. - useEffect(() => { - if (step !== 'paying' || !bookingResult?.ticketId) return; - - const ticketId = bookingResult.ticketId; - 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, bookingResult?.ticketId, locale]); - - // 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)}`; - }; + 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'); @@ -428,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, @@ -441,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 = { @@ -513,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 ( @@ -572,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/admin/events/[id]/_components/StatusBadge.tsx b/frontend/src/app/admin/events/[id]/_components/StatusBadge.tsx new file mode 100644 index 0000000..0d0b33b --- /dev/null +++ b/frontend/src/app/admin/events/[id]/_components/StatusBadge.tsx @@ -0,0 +1,19 @@ +import clsx from 'clsx'; + +export function StatusBadge({ status, compact = false }: { status: string; compact?: boolean }) { + const styles: Record = { + pending: 'bg-yellow-100 text-yellow-800', + confirmed: 'bg-green-100 text-green-800', + cancelled: 'bg-red-100 text-red-800', + checked_in: 'bg-blue-100 text-blue-800', + }; + return ( + + {status.replace('_', ' ')} + + ); +} diff --git a/frontend/src/app/admin/events/[id]/_hooks/useEventDetailData.ts b/frontend/src/app/admin/events/[id]/_hooks/useEventDetailData.ts new file mode 100644 index 0000000..2f9ca9c --- /dev/null +++ b/frontend/src/app/admin/events/[id]/_hooks/useEventDetailData.ts @@ -0,0 +1,37 @@ +import { useState, useEffect } from 'react'; +import toast from 'react-hot-toast'; +import { eventsApi, ticketsApi, emailsApi, Event, Ticket, EmailTemplate } from '@/lib/api'; + +/** + * Loads the core data for the admin event detail page (event, tickets, active + * email templates) and exposes a reload function used after mutations. + */ +export function useEventDetailData(eventId: string) { + const [loading, setLoading] = useState(true); + const [event, setEvent] = useState(null); + const [tickets, setTickets] = useState([]); + const [templates, setTemplates] = useState([]); + + const loadEventData = async () => { + try { + const [eventRes, ticketsRes, templatesRes] = await Promise.all([ + eventsApi.getById(eventId), + ticketsApi.getAll({ eventId }), + emailsApi.getTemplates(), + ]); + setEvent(eventRes.event); + setTickets(ticketsRes.tickets); + setTemplates(templatesRes.templates.filter(t => t.isActive)); + } catch (error) { + toast.error('Failed to load event data'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadEventData(); + }, [eventId]); + + return { loading, event, tickets, templates, loadEventData }; +} diff --git a/frontend/src/app/admin/events/[id]/_hooks/usePaymentOverrides.ts b/frontend/src/app/admin/events/[id]/_hooks/usePaymentOverrides.ts new file mode 100644 index 0000000..579f6e8 --- /dev/null +++ b/frontend/src/app/admin/events/[id]/_hooks/usePaymentOverrides.ts @@ -0,0 +1,96 @@ +import { useState } from 'react'; +import toast from 'react-hot-toast'; +import { paymentOptionsApi, PaymentOptionsConfig } from '@/lib/api'; + +/** + * Manages the event-level payment override editor state: loading global + + * override config, computing effective values, editing, saving and resetting. + */ +export function usePaymentOverrides(eventId: string, locale: string) { + const [globalPaymentOptions, setGlobalPaymentOptions] = useState(null); + const [paymentOverrides, setPaymentOverrides] = useState>({}); + const [hasPaymentOverrides, setHasPaymentOverrides] = useState(false); + const [savingPayments, setSavingPayments] = useState(false); + const [loadingPayments, setLoadingPayments] = useState(false); + + const loadPaymentOptions = async () => { + if (globalPaymentOptions) return; + setLoadingPayments(true); + try { + const [globalRes, overridesRes] = await Promise.all([ + paymentOptionsApi.getGlobal(), + paymentOptionsApi.getEventOverrides(eventId), + ]); + setGlobalPaymentOptions(globalRes.paymentOptions); + if (overridesRes.overrides) { + setPaymentOverrides(overridesRes.overrides); + setHasPaymentOverrides(true); + } + } catch (error) { + toast.error('Failed to load payment options'); + } finally { + setLoadingPayments(false); + } + }; + + const getEffectivePaymentOption = (key: K): PaymentOptionsConfig[K] => { + if (paymentOverrides[key] !== undefined && paymentOverrides[key] !== null) { + return paymentOverrides[key] as PaymentOptionsConfig[K]; + } + return globalPaymentOptions?.[key] as PaymentOptionsConfig[K]; + }; + + const updatePaymentOverride = ( + key: K, + value: PaymentOptionsConfig[K] | null + ) => { + setPaymentOverrides((prev) => ({ ...prev, [key]: value })); + setHasPaymentOverrides(true); + }; + + const handleSavePaymentOptions = async () => { + setSavingPayments(true); + try { + await paymentOptionsApi.updateEventOverrides(eventId, paymentOverrides); + toast.success(locale === 'es' ? 'Opciones de pago guardadas' : 'Payment options saved'); + } catch (error: any) { + toast.error(error.message || 'Failed to save payment options'); + } finally { + setSavingPayments(false); + } + }; + + const handleResetToGlobal = async () => { + if (!confirm(locale === 'es' + ? '¿Resetear a la configuración global? Se eliminarán todas las personalizaciones de este evento.' + : 'Reset to global settings? This will remove all customizations for this event.')) { + return; + } + setSavingPayments(true); + try { + await paymentOptionsApi.deleteEventOverrides(eventId); + setPaymentOverrides({}); + setHasPaymentOverrides(false); + toast.success(locale === 'es' ? 'Restablecido a configuración global' : 'Reset to global settings'); + } catch (error: any) { + toast.error(error.message || 'Failed to reset payment options'); + } finally { + setSavingPayments(false); + } + }; + + return { + globalPaymentOptions, + paymentOverrides, + hasPaymentOverrides, + savingPayments, + loadingPayments, + loadPaymentOptions, + getEffectivePaymentOption, + updatePaymentOverride, + handleSavePaymentOptions, + handleResetToGlobal, + }; +} + +export type PaymentOverridesController = ReturnType; diff --git a/frontend/src/app/admin/events/[id]/_modals/EventModals.tsx b/frontend/src/app/admin/events/[id]/_modals/EventModals.tsx new file mode 100644 index 0000000..3f85445 --- /dev/null +++ b/frontend/src/app/admin/events/[id]/_modals/EventModals.tsx @@ -0,0 +1,521 @@ +import type { Dispatch, FormEvent, SetStateAction } from 'react'; +import { Ticket } from '@/lib/api'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { BottomSheet } from '@/components/admin/MobileComponents'; +import clsx from 'clsx'; +import { + CheckCircleIcon, + EnvelopeIcon, + PlusIcon, + StarIcon, + XMarkIcon, +} from '@heroicons/react/24/outline'; +import type { AttendeeStatusFilter, AttendeeFormState, AddAtDoorFormState } from '../_types'; + +interface EventModalsProps { + // counts + filter + ticketsCount: number; + pendingCount: number; + confirmedCount: number; + checkedInCount: number; + cancelledCount: number; + statusFilter: AttendeeStatusFilter; + setStatusFilter: (value: AttendeeStatusFilter) => void; + // mobile filter sheet + mobileFilterOpen: boolean; + setMobileFilterOpen: (value: boolean) => void; + // add ticket sheet + showAddTicketSheet: boolean; + setShowAddTicketSheet: (value: boolean) => void; + // export sheets + showExportSheet: boolean; + setShowExportSheet: (value: boolean) => void; + handleExportAttendees: (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => void; + showTicketExportSheet: boolean; + setShowTicketExportSheet: (value: boolean) => void; + handleExportTickets: (status: 'confirmed' | 'checked_in' | 'all') => void; + // add at door + showAddAtDoorModal: boolean; + setShowAddAtDoorModal: (value: boolean) => void; + addAtDoorForm: AddAtDoorFormState; + setAddAtDoorForm: Dispatch>; + handleAddAtDoor: (e: FormEvent) => void; + // manual ticket + showManualTicketModal: boolean; + setShowManualTicketModal: (value: boolean) => void; + manualTicketForm: AttendeeFormState; + setManualTicketForm: Dispatch>; + handleManualTicket: (e: FormEvent) => void; + // invite guest + showInviteGuestModal: boolean; + setShowInviteGuestModal: (value: boolean) => void; + inviteGuestForm: AttendeeFormState; + setInviteGuestForm: Dispatch>; + handleInviteGuest: (e: FormEvent) => void; + // shared submit flag + submitting: boolean; + // note modal + showNoteModal: boolean; + setShowNoteModal: (value: boolean) => void; + selectedTicket: Ticket | null; + setSelectedTicket: (value: Ticket | null) => void; + noteText: string; + setNoteText: (value: string) => void; + handleSaveNote: () => void; + // preview modal + previewHtml: string | null; + setPreviewHtml: (value: string | null) => void; +} + +export function EventModals(props: EventModalsProps) { + const { + ticketsCount, + pendingCount, + confirmedCount, + checkedInCount, + cancelledCount, + statusFilter, + setStatusFilter, + mobileFilterOpen, + setMobileFilterOpen, + showAddTicketSheet, + setShowAddTicketSheet, + showExportSheet, + setShowExportSheet, + handleExportAttendees, + showTicketExportSheet, + setShowTicketExportSheet, + handleExportTickets, + showAddAtDoorModal, + setShowAddAtDoorModal, + addAtDoorForm, + setAddAtDoorForm, + handleAddAtDoor, + showManualTicketModal, + setShowManualTicketModal, + manualTicketForm, + setManualTicketForm, + handleManualTicket, + showInviteGuestModal, + setShowInviteGuestModal, + inviteGuestForm, + setInviteGuestForm, + handleInviteGuest, + submitting, + showNoteModal, + setShowNoteModal, + selectedTicket, + setSelectedTicket, + noteText, + setNoteText, + handleSaveNote, + previewHtml, + setPreviewHtml, + } = props; + + return ( + <> + {/* Mobile filter bottom sheet */} + setMobileFilterOpen(false)} + title="Filter by Status" + > +
+ {[ + { value: 'all', label: `All (${ticketsCount})` }, + { value: 'pending', label: `Pending (${pendingCount})` }, + { value: 'confirmed', label: `Confirmed (${confirmedCount})` }, + { value: 'checked_in', label: `Checked In (${checkedInCount})` }, + { value: 'cancelled', label: `Cancelled (${cancelledCount})` }, + ].map((option) => ( + + ))} +
+
+ + {/* Mobile FAB bottom sheet */} + setShowAddTicketSheet(false)} + title="Add Ticket" + > +
+ + + +
+
+ + {/* Mobile export bottom sheet (attendees) */} + setShowExportSheet(false)} + title="Export Attendees" + > +
+ {[ + { status: 'all' as const, label: 'Export All' }, + { status: 'confirmed' as const, label: 'Export Confirmed' }, + { status: 'checked_in' as const, label: 'Export Checked-in' }, + { status: 'confirmed_pending' as const, label: 'Confirmed & Pending' }, + ].map((opt) => ( + + ))} +

Format: CSV

+
+
+ + {/* Mobile export bottom sheet (tickets) */} + setShowTicketExportSheet(false)} + title="Export Tickets" + > +
+ {[ + { status: 'all' as const, label: 'Export All' }, + { status: 'confirmed' as const, label: 'Export Valid' }, + { status: 'checked_in' as const, label: 'Export Checked-in' }, + ].map((opt) => ( + + ))} +

Format: CSV

+
+
+ + {/* Add at Door Modal */} + {showAddAtDoorModal && ( +
setShowAddAtDoorModal(false)} + role="presentation" + > + e.stopPropagation()} + > +
+

Add Attendee at Door

+ +
+
+
+
+ + setAddAtDoorForm({ ...addAtDoorForm, firstName: e.target.value })} + className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" + placeholder="First name" /> +
+
+ + setAddAtDoorForm({ ...addAtDoorForm, lastName: e.target.value })} + className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" + placeholder="Last name" /> +
+
+
+ + setAddAtDoorForm({ ...addAtDoorForm, email: e.target.value })} + className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" + placeholder="email@example.com" /> +
+
+ + setAddAtDoorForm({ ...addAtDoorForm, phone: e.target.value })} + className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow" + placeholder="+595 981 123456" /> +
+
+ +