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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-26 04:58:52 +00:00

2008 lines
63 KiB
TypeScript

import 'dotenv/config';
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { swaggerUI } from '@hono/swagger-ui';
import { serveStatic } from '@hono/node-server/serve-static';
import authRoutes from './routes/auth.js';
import eventsRoutes from './routes/events.js';
import ticketsRoutes from './routes/tickets.js';
import usersRoutes from './routes/users.js';
import contactsRoutes from './routes/contacts.js';
import paymentsRoutes from './routes/payments.js';
import adminRoutes from './routes/admin.js';
import mediaRoutes from './routes/media.js';
import lnbitsRoutes from './routes/lnbits.js';
import emailsRoutes from './routes/emails.js';
import paymentOptionsRoutes from './routes/payment-options.js';
import dashboardRoutes from './routes/dashboard.js';
import siteSettingsRoutes from './routes/site-settings.js';
import legalPagesRoutes from './routes/legal-pages.js';
import legalSettingsRoutes from './routes/legal-settings.js';
import faqRoutes from './routes/faq.js';
import emailService from './lib/email.js';
import { initEmailQueue, stopQueue } from './lib/emailQueue.js';
import { startBookingCleanup, stopBookingCleanup } from './lib/bookingCleanup.js';
import { startHoldSweep, stopHoldSweep } from './lib/holdSweep.js';
import { startEventEndSweep, stopEventEndSweep } from './lib/eventEndSweep.js';
import { closeRedis } from './lib/redis.js';
import { getLock } from './lib/stores/lock.js';
import { describeBackends, describeRedis, logSelectedBackends } from './lib/backends.js';
const app = new Hono();
// Middleware
app.use('*', logger());
// CORS
// - In production we *typically* rely on nginx to set CORS, but enabling it here
// is a safe fallback (especially for local/proxyless deployments).
// - `FRONTEND_URL` should be set to e.g. https://spanglishcommunity.com in prod.
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3002';
const allowedOrigins = new Set<string>([
frontendUrl,
// Common alias (www) for the same site.
frontendUrl.replace('://www.', '://'),
frontendUrl.includes('://') ? frontendUrl.replace('://', '://www.') : frontendUrl,
]);
app.use(
'*',
cors({
origin: (origin) => {
// Non-browser / same-origin requests may omit Origin.
if (!origin) return frontendUrl;
return allowedOrigins.has(origin) ? origin : null;
},
// We use bearer tokens, but keeping credentials=true matches nginx config.
credentials: true,
})
);
// Baseline security headers on every response.
const isProduction = process.env.NODE_ENV === 'production';
app.use('*', async (c, next) => {
await next();
c.header('X-Content-Type-Options', 'nosniff');
c.header('X-Frame-Options', 'DENY');
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
c.header('X-XSS-Protection', '0');
if (isProduction) {
c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
});
// OpenAPI specification
const openApiSpec = {
openapi: '3.0.0',
info: {
title: 'Spanglish API',
version: '2.0.0',
description: 'API for Spanglish Language Exchange Event Platform - includes authentication, user dashboard, event management, tickets, payments, and more.',
contact: {
name: 'Spanglish',
url: 'https://spanglish.com',
},
},
servers: [
{
url: process.env.API_URL || 'http://localhost:3001',
description: 'API Server',
},
],
tags: [
{ name: 'Auth', description: 'Authentication and account management' },
{ name: 'User Dashboard', description: 'User dashboard and profile endpoints' },
{ name: 'Events', description: 'Event management' },
{ name: 'Tickets', description: 'Ticket booking and management' },
{ name: 'Payments', description: 'Payment management' },
{ name: 'Payment Options', description: 'Payment configuration' },
{ name: 'Users', description: 'User management (admin)' },
{ name: 'Contacts', description: 'Contact and subscription management' },
{ name: 'Emails', description: 'Email templates and sending' },
{ name: 'Media', description: 'File uploads and media management' },
{ name: 'Lightning', description: 'Lightning/Bitcoin payments via LNBits' },
{ name: 'Admin', description: 'Admin dashboard and analytics' },
{ name: 'FAQ', description: 'FAQ questions (public and admin)' },
],
paths: {
// ==================== Auth Endpoints ====================
'/api/auth/register': {
post: {
tags: ['Auth'],
summary: 'Register a new user',
description: 'Create a new user account. First registered user becomes admin. Password must be at least 10 characters.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['email', 'password', 'name'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string', minLength: 10, description: 'Minimum 10 characters' },
name: { type: 'string', minLength: 2 },
phone: { type: 'string' },
languagePreference: { type: 'string', enum: ['en', 'es'] },
},
},
},
},
},
responses: {
201: { description: 'User created successfully' },
400: { description: 'Email already registered or validation error' },
},
},
},
'/api/auth/login': {
post: {
tags: ['Auth'],
summary: 'Login with email and password',
description: 'Authenticate user with email and password. Rate limited to 5 attempts per 15 minutes.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string', format: 'email' },
password: { type: 'string' },
},
},
},
},
},
responses: {
200: { description: 'Login successful, returns JWT token' },
401: { description: 'Invalid credentials' },
429: { description: 'Too many login attempts' },
},
},
},
'/api/auth/google': {
post: {
tags: ['Auth'],
summary: 'Login or register with Google',
description: 'Authenticate using Google OAuth. Creates account if user does not exist.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['credential'],
properties: {
credential: { type: 'string', description: 'Google ID token' },
},
},
},
},
},
responses: {
200: { description: 'Login successful' },
400: { description: 'Invalid Google token' },
},
},
},
'/api/auth/magic-link/request': {
post: {
tags: ['Auth'],
summary: 'Request magic link login',
description: 'Send a one-time login link to email. Link expires in 10 minutes.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['email'],
properties: {
email: { type: 'string', format: 'email' },
},
},
},
},
},
responses: {
200: { description: 'Magic link sent (if account exists)' },
},
},
},
'/api/auth/magic-link/verify': {
post: {
tags: ['Auth'],
summary: 'Verify magic link token',
description: 'Verify the magic link token and login user.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['token'],
properties: {
token: { type: 'string' },
},
},
},
},
},
responses: {
200: { description: 'Login successful' },
400: { description: 'Invalid or expired token' },
},
},
},
'/api/auth/password-reset/request': {
post: {
tags: ['Auth'],
summary: 'Request password reset',
description: 'Send a password reset link to email. Link expires in 30 minutes.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['email'],
properties: {
email: { type: 'string', format: 'email' },
},
},
},
},
},
responses: {
200: { description: 'Reset link sent (if account exists)' },
},
},
},
'/api/auth/password-reset/confirm': {
post: {
tags: ['Auth'],
summary: 'Confirm password reset',
description: 'Reset password using the token from email.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['token', 'password'],
properties: {
token: { type: 'string' },
password: { type: 'string', minLength: 10 },
},
},
},
},
},
responses: {
200: { description: 'Password reset successful' },
400: { description: 'Invalid or expired token' },
},
},
},
'/api/auth/claim-account/request': {
post: {
tags: ['Auth'],
summary: 'Request account claim link',
description: 'For unclaimed accounts created during booking. Link expires in 24 hours.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['email'],
properties: {
email: { type: 'string', format: 'email' },
},
},
},
},
},
responses: {
200: { description: 'Claim link sent (if unclaimed account exists)' },
},
},
},
'/api/auth/claim-account/confirm': {
post: {
tags: ['Auth'],
summary: 'Confirm account claim',
description: 'Claim an unclaimed account by setting password or linking Google.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['token'],
properties: {
token: { type: 'string' },
password: { type: 'string', minLength: 10, description: 'Required if not linking Google' },
googleId: { type: 'string', description: 'Google ID for OAuth linking' },
},
},
},
},
},
responses: {
200: { description: 'Account claimed successfully' },
400: { description: 'Invalid token or missing credentials' },
},
},
},
'/api/auth/change-password': {
post: {
tags: ['Auth'],
summary: 'Change password',
description: 'Change password for authenticated user.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['currentPassword', 'newPassword'],
properties: {
currentPassword: { type: 'string' },
newPassword: { type: 'string', minLength: 10 },
},
},
},
},
},
responses: {
200: { description: 'Password changed' },
400: { description: 'Current password incorrect' },
401: { description: 'Unauthorized' },
},
},
},
'/api/auth/me': {
get: {
tags: ['Auth'],
summary: 'Get current user',
description: 'Get the currently authenticated user profile.',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'Current user data' },
401: { description: 'Unauthorized' },
},
},
},
'/api/auth/logout': {
post: {
tags: ['Auth'],
summary: 'Logout',
description: 'Logout current user (client-side token removal).',
responses: {
200: { description: 'Logged out' },
},
},
},
// ==================== User Dashboard Endpoints ====================
'/api/dashboard/summary': {
get: {
tags: ['User Dashboard'],
summary: 'Get dashboard summary',
description: 'Get user stats including ticket counts, membership duration, etc.',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'Dashboard summary data' },
401: { description: 'Unauthorized' },
},
},
},
'/api/dashboard/profile': {
get: {
tags: ['User Dashboard'],
summary: 'Get user profile',
description: 'Get detailed user profile information.',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'User profile' },
401: { description: 'Unauthorized' },
},
},
put: {
tags: ['User Dashboard'],
summary: 'Update user profile',
description: 'Update user profile fields like name, phone, language preference, RUC number.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
name: { type: 'string', minLength: 2 },
phone: { type: 'string' },
languagePreference: { type: 'string', enum: ['en', 'es'] },
rucNumber: { type: 'string', maxLength: 15 },
},
},
},
},
},
responses: {
200: { description: 'Profile updated' },
401: { description: 'Unauthorized' },
},
},
},
'/api/dashboard/tickets': {
get: {
tags: ['User Dashboard'],
summary: 'Get user tickets',
description: 'Get all tickets for the authenticated user with event and payment details.',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'List of user tickets' },
401: { description: 'Unauthorized' },
},
},
},
'/api/dashboard/tickets/{id}': {
get: {
tags: ['User Dashboard'],
summary: 'Get ticket detail',
description: 'Get detailed information about a specific ticket.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Ticket details' },
404: { description: 'Ticket not found' },
401: { description: 'Unauthorized' },
},
},
},
'/api/dashboard/next-event': {
get: {
tags: ['User Dashboard'],
summary: 'Get next upcoming event',
description: 'Get the next upcoming event the user has a ticket for.',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'Next event info or null' },
401: { description: 'Unauthorized' },
},
},
},
'/api/dashboard/payments': {
get: {
tags: ['User Dashboard'],
summary: 'Get payment history',
description: 'Get all payments made by the user.',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'List of payments' },
401: { description: 'Unauthorized' },
},
},
},
'/api/dashboard/invoices': {
get: {
tags: ['User Dashboard'],
summary: 'Get invoices',
description: 'Get all invoices for the user.',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'List of invoices' },
401: { description: 'Unauthorized' },
},
},
},
'/api/dashboard/sessions': {
get: {
tags: ['User Dashboard'],
summary: 'Get active sessions',
description: 'Get all active login sessions for the user.',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'List of sessions' },
401: { description: 'Unauthorized' },
},
},
},
'/api/dashboard/sessions/{id}': {
delete: {
tags: ['User Dashboard'],
summary: 'Revoke session',
description: 'Revoke a specific session.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Session revoked' },
401: { description: 'Unauthorized' },
},
},
},
'/api/dashboard/sessions/revoke-all': {
post: {
tags: ['User Dashboard'],
summary: 'Revoke all sessions',
description: 'Logout from all devices.',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'All sessions revoked' },
401: { description: 'Unauthorized' },
},
},
},
'/api/dashboard/set-password': {
post: {
tags: ['User Dashboard'],
summary: 'Set password',
description: 'Set a password for users who signed up via Google only.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['password'],
properties: {
password: { type: 'string', minLength: 10 },
},
},
},
},
},
responses: {
200: { description: 'Password set' },
400: { description: 'Password already set' },
401: { description: 'Unauthorized' },
},
},
},
'/api/dashboard/unlink-google': {
post: {
tags: ['User Dashboard'],
summary: 'Unlink Google account',
description: 'Unlink Google account. Requires password to be set first.',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'Google unlinked' },
400: { description: 'Cannot unlink without password' },
401: { description: 'Unauthorized' },
},
},
},
// ==================== Events Endpoints ====================
'/api/events': {
get: {
tags: ['Events'],
summary: 'Get all events',
description: 'Get list of events with optional filters.',
parameters: [
{ name: 'status', in: 'query', schema: { type: 'string', enum: ['draft', 'published', 'cancelled', 'completed', 'archived'] } },
{ name: 'upcoming', in: 'query', schema: { type: 'boolean' }, description: 'Filter to only future events' },
],
responses: {
200: { description: 'List of events' },
},
},
post: {
tags: ['Events'],
summary: 'Create event',
description: 'Create a new event (admin/organizer only).',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['title', 'description', 'startDatetime', 'location'],
properties: {
title: { type: 'string' },
titleEs: { type: 'string' },
description: { type: 'string' },
descriptionEs: { type: 'string' },
startDatetime: { type: 'string', format: 'date-time' },
endDatetime: { type: 'string', format: 'date-time' },
location: { type: 'string' },
locationUrl: { type: 'string', format: 'uri' },
price: { type: 'number' },
currency: { type: 'string', default: 'PYG' },
capacity: { type: 'integer', default: 50 },
status: { type: 'string', enum: ['draft', 'published', 'cancelled', 'completed', 'archived'] },
bannerUrl: { type: 'string', format: 'uri' },
},
},
},
},
},
responses: {
201: { description: 'Event created' },
401: { description: 'Unauthorized' },
403: { description: 'Forbidden' },
},
},
},
'/api/events/{id}': {
get: {
tags: ['Events'],
summary: 'Get event by ID',
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Event details' },
404: { description: 'Event not found' },
},
},
put: {
tags: ['Events'],
summary: 'Update event',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Event updated' },
404: { description: 'Event not found' },
},
},
delete: {
tags: ['Events'],
summary: 'Delete event',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Event deleted' },
404: { description: 'Event not found' },
},
},
},
'/api/events/next': {
get: {
tags: ['Events'],
summary: 'Get next upcoming event (chronological)',
description: 'Get the single earliest upcoming published event, ignoring featured promotion.',
responses: {
200: { description: 'Next event or null' },
},
},
},
'/api/events/next/upcoming': {
get: {
tags: ['Events'],
summary: 'Get next upcoming event',
description: 'Get the featured event if valid, otherwise the single next upcoming published event.',
responses: {
200: { description: 'Next event or null' },
},
},
},
'/api/events/{id}/duplicate': {
post: {
tags: ['Events'],
summary: 'Duplicate event',
description: 'Create a copy of an existing event.',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
201: { description: 'Event duplicated' },
404: { description: 'Event not found' },
},
},
},
// ==================== Tickets Endpoints ====================
'/api/tickets': {
get: {
tags: ['Tickets'],
summary: 'Get all tickets (admin)',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'query', schema: { type: 'string' } },
{ name: 'status', in: 'query', schema: { type: 'string', enum: ['pending', 'confirmed', 'cancelled', 'checked_in'] } },
],
responses: {
200: { description: 'List of tickets' },
},
},
post: {
tags: ['Tickets'],
summary: 'Book a ticket',
description: 'Create a booking for an event. Creates user account if needed.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['eventId', 'firstName', 'email', 'paymentMethod'],
properties: {
eventId: { type: 'string' },
firstName: { type: 'string' },
lastName: { type: 'string' },
email: { type: 'string', format: 'email' },
phone: { type: 'string' },
preferredLanguage: { type: 'string', enum: ['en', 'es'] },
paymentMethod: { type: 'string', enum: ['lightning', 'cash', 'bank_transfer', 'tpago'] },
ruc: { type: 'string', description: 'Paraguayan RUC for invoice' },
},
},
},
},
},
responses: {
201: { description: 'Ticket booked' },
400: { description: 'Booking error' },
},
},
},
'/api/tickets/{id}': {
get: {
tags: ['Tickets'],
summary: 'Get ticket by ID',
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Ticket details' },
404: { description: 'Ticket not found' },
},
},
put: {
tags: ['Tickets'],
summary: 'Update ticket',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Ticket updated' },
},
},
},
'/api/tickets/{id}/checkin': {
post: {
tags: ['Tickets'],
summary: 'Check in ticket',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Check-in successful' },
400: { description: 'Check-in error' },
},
},
},
'/api/tickets/{id}/remove-checkin': {
post: {
tags: ['Tickets'],
summary: 'Remove check-in',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Check-in removed' },
},
},
},
'/api/tickets/{id}/cancel': {
post: {
tags: ['Tickets'],
summary: 'Cancel ticket',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Ticket cancelled' },
},
},
},
'/api/tickets/{id}/mark-paid': {
post: {
tags: ['Tickets'],
summary: 'Mark ticket as paid (admin)',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Marked as paid' },
},
},
},
'/api/tickets/{id}/mark-payment-sent': {
post: {
tags: ['Tickets'],
summary: 'Mark payment sent',
description: 'User marks their bank transfer or TPago payment as sent.',
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Payment marked as pending approval' },
},
},
},
'/api/tickets/{id}/note': {
post: {
tags: ['Tickets'],
summary: 'Update ticket note',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
note: { type: 'string' },
},
},
},
},
},
responses: {
200: { description: 'Note updated' },
},
},
},
'/api/tickets/admin/create': {
post: {
tags: ['Tickets'],
summary: 'Admin create ticket',
description: 'Create ticket directly without payment (admin only).',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['eventId', 'firstName'],
properties: {
eventId: { type: 'string' },
firstName: { type: 'string' },
lastName: { type: 'string' },
email: { type: 'string', format: 'email' },
phone: { type: 'string' },
preferredLanguage: { type: 'string', enum: ['en', 'es'] },
autoCheckin: { type: 'boolean' },
adminNote: { type: 'string' },
},
},
},
},
},
responses: {
201: { description: 'Ticket created' },
},
},
},
// ==================== Payments Endpoints ====================
'/api/payments': {
get: {
tags: ['Payments'],
summary: 'Get all payments',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'status', in: 'query', schema: { type: 'string' } },
{ name: 'provider', in: 'query', schema: { type: 'string' } },
{ name: 'pendingApproval', in: 'query', schema: { type: 'boolean' } },
],
responses: {
200: { description: 'List of payments' },
},
},
},
'/api/payments/pending-approval': {
get: {
tags: ['Payments'],
summary: 'Get pending approval payments',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'Payments awaiting approval' },
},
},
},
'/api/payments/{id}': {
get: {
tags: ['Payments'],
summary: 'Get payment by ID',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Payment details' },
},
},
put: {
tags: ['Payments'],
summary: 'Update payment',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Payment updated' },
},
},
},
'/api/payments/{id}/approve': {
post: {
tags: ['Payments'],
summary: 'Approve payment',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
adminNote: { type: 'string' },
},
},
},
},
},
responses: {
200: { description: 'Payment approved' },
},
},
},
'/api/payments/{id}/reject': {
post: {
tags: ['Payments'],
summary: 'Reject payment',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
adminNote: { type: 'string' },
},
},
},
},
},
responses: {
200: { description: 'Payment rejected' },
},
},
},
'/api/payments/{id}/refund': {
post: {
tags: ['Payments'],
summary: 'Refund payment',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Refund processed' },
},
},
},
'/api/payments/{id}/note': {
post: {
tags: ['Payments'],
summary: 'Update payment note',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
adminNote: { type: 'string' },
},
},
},
},
},
responses: {
200: { description: 'Note updated' },
},
},
},
// ==================== Payment Options Endpoints ====================
'/api/payment-options': {
get: {
tags: ['Payment Options'],
summary: 'Get global payment options',
responses: {
200: { description: 'Payment options configuration' },
},
},
put: {
tags: ['Payment Options'],
summary: 'Update global payment options',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
tpagoEnabled: { type: 'boolean' },
tpagoLink: { type: 'string' },
tpagoInstructions: { type: 'string' },
tpagoInstructionsEs: { type: 'string' },
bankTransferEnabled: { type: 'boolean' },
bankName: { type: 'string' },
bankAccountHolder: { type: 'string' },
bankAccountNumber: { type: 'string' },
bankAlias: { type: 'string' },
bankPhone: { type: 'string' },
bankNotes: { type: 'string' },
bankNotesEs: { type: 'string' },
lightningEnabled: { type: 'boolean' },
cashEnabled: { type: 'boolean' },
cashInstructions: { type: 'string' },
cashInstructionsEs: { type: 'string' },
},
},
},
},
},
responses: {
200: { description: 'Options updated' },
},
},
},
'/api/payment-options/event/{eventId}': {
get: {
tags: ['Payment Options'],
summary: 'Get payment options for event',
description: 'Get merged payment options (global + event overrides).',
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Payment options for event' },
},
},
},
'/api/payment-options/event/{eventId}/overrides': {
get: {
tags: ['Payment Options'],
summary: 'Get event payment overrides',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Event-specific overrides' },
},
},
put: {
tags: ['Payment Options'],
summary: 'Update event payment overrides',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Overrides updated' },
},
},
delete: {
tags: ['Payment Options'],
summary: 'Delete event payment overrides',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Overrides deleted' },
},
},
},
// ==================== Users Endpoints (Admin) ====================
'/api/users': {
get: {
tags: ['Users'],
summary: 'Get all users (admin)',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'role', in: 'query', schema: { type: 'string' } },
],
responses: {
200: { description: 'List of users' },
},
},
},
'/api/users/{id}': {
get: {
tags: ['Users'],
summary: 'Get user by ID',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'User details' },
},
},
put: {
tags: ['Users'],
summary: 'Update user',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'User updated' },
},
},
delete: {
tags: ['Users'],
summary: 'Delete user',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'User deleted' },
},
},
},
'/api/users/{id}/history': {
get: {
tags: ['Users'],
summary: 'Get user ticket history',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'User ticket history' },
},
},
},
'/api/users/stats/overview': {
get: {
tags: ['Users'],
summary: 'Get user statistics',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'User statistics' },
},
},
},
// ==================== Contacts Endpoints ====================
'/api/contacts': {
get: {
tags: ['Contacts'],
summary: 'Get all contacts (admin)',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'status', in: 'query', schema: { type: 'string', enum: ['new', 'read', 'replied'] } },
],
responses: {
200: { description: 'List of contacts' },
},
},
post: {
tags: ['Contacts'],
summary: 'Submit contact form',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['name', 'email', 'message'],
properties: {
name: { type: 'string' },
email: { type: 'string', format: 'email' },
message: { type: 'string', minLength: 10 },
},
},
},
},
},
responses: {
201: { description: 'Message sent' },
},
},
},
'/api/contacts/{id}': {
put: {
tags: ['Contacts'],
summary: 'Update contact status',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
properties: {
status: { type: 'string', enum: ['new', 'read', 'replied'] },
},
},
},
},
},
responses: {
200: { description: 'Contact updated' },
},
},
},
'/api/contacts/subscribe': {
post: {
tags: ['Contacts'],
summary: 'Subscribe to newsletter',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['email'],
properties: {
email: { type: 'string', format: 'email' },
name: { type: 'string' },
},
},
},
},
},
responses: {
201: { description: 'Subscribed successfully' },
},
},
},
// ==================== Email Endpoints ====================
'/api/emails/templates': {
get: {
tags: ['Emails'],
summary: 'Get all email templates',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'List of templates' },
},
},
post: {
tags: ['Emails'],
summary: 'Create email template',
security: [{ bearerAuth: [] }],
responses: {
201: { description: 'Template created' },
},
},
},
'/api/emails/templates/{id}': {
get: {
tags: ['Emails'],
summary: 'Get template by ID',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Template details' },
},
},
put: {
tags: ['Emails'],
summary: 'Update template',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Template updated' },
},
},
delete: {
tags: ['Emails'],
summary: 'Delete template',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Template deleted' },
},
},
},
'/api/emails/send/event/{eventId}': {
post: {
tags: ['Emails'],
summary: 'Send email to event attendees',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['templateSlug'],
properties: {
templateSlug: { type: 'string' },
customVariables: { type: 'object' },
recipientFilter: { type: 'string', enum: ['all', 'confirmed', 'pending', 'checked_in'] },
},
},
},
},
},
responses: {
200: { description: 'Emails sent' },
},
},
},
'/api/emails/send/custom': {
post: {
tags: ['Emails'],
summary: 'Send custom email',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['to', 'subject', 'bodyHtml'],
properties: {
to: { type: 'string', format: 'email' },
toName: { type: 'string' },
subject: { type: 'string' },
bodyHtml: { type: 'string' },
bodyText: { type: 'string' },
eventId: { type: 'string' },
},
},
},
},
},
responses: {
200: { description: 'Email sent' },
},
},
},
'/api/emails/preview': {
post: {
tags: ['Emails'],
summary: 'Preview email template',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['templateSlug'],
properties: {
templateSlug: { type: 'string' },
variables: { type: 'object' },
locale: { type: 'string', enum: ['en', 'es'] },
},
},
},
},
},
responses: {
200: { description: 'Preview HTML' },
},
},
},
'/api/emails/logs': {
get: {
tags: ['Emails'],
summary: 'Get email logs',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'query', schema: { type: 'string' } },
{ name: 'status', in: 'query', schema: { type: 'string' } },
{ name: 'limit', in: 'query', schema: { type: 'integer' } },
{ name: 'offset', in: 'query', schema: { type: 'integer' } },
],
responses: {
200: { description: 'Email logs' },
},
},
},
'/api/emails/stats': {
get: {
tags: ['Emails'],
summary: 'Get email stats',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'query', schema: { type: 'string' } },
],
responses: {
200: { description: 'Email statistics' },
},
},
},
'/api/emails/seed-templates': {
post: {
tags: ['Emails'],
summary: 'Seed default templates',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'Templates seeded' },
},
},
},
// ==================== Media Endpoints ====================
'/api/media/upload': {
post: {
tags: ['Media'],
summary: 'Upload file',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'multipart/form-data': {
schema: {
type: 'object',
properties: {
file: { type: 'string', format: 'binary' },
relatedId: { type: 'string' },
relatedType: { type: 'string' },
},
},
},
},
},
responses: {
201: { description: 'File uploaded' },
},
},
},
'/api/media/{id}': {
delete: {
tags: ['Media'],
summary: 'Delete media',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Media deleted' },
},
},
},
// ==================== Lightning (LNBits) Endpoints ====================
'/api/lnbits/invoice': {
post: {
tags: ['Lightning'],
summary: 'Create Lightning invoice',
description: 'Create a Lightning Network invoice for payment.',
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['ticketId'],
properties: {
ticketId: { type: 'string' },
},
},
},
},
},
responses: {
200: { description: 'Invoice created' },
},
},
},
'/api/lnbits/status/{ticketId}': {
get: {
tags: ['Lightning'],
summary: 'Check payment status',
description: 'Check the payment status for a ticket.',
parameters: [
{ name: 'ticketId', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'Payment status' },
},
},
},
'/api/lnbits/webhook': {
post: {
tags: ['Lightning'],
summary: 'LNBits webhook',
description: 'Webhook endpoint for LNBits payment notifications.',
responses: {
200: { description: 'Webhook processed' },
},
},
},
// ==================== Admin Endpoints ====================
'/api/admin/dashboard': {
get: {
tags: ['Admin'],
summary: 'Get admin dashboard',
description: 'Get statistics, recent activity, and overview data.',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'Dashboard data' },
},
},
},
'/api/admin/analytics': {
get: {
tags: ['Admin'],
summary: 'Get analytics',
description: 'Get detailed analytics data.',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'Analytics data' },
},
},
},
'/api/admin/export/tickets': {
get: {
tags: ['Admin'],
summary: 'Export tickets',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'eventId', in: 'query', schema: { type: 'string' } },
],
responses: {
200: { description: 'Exported ticket data' },
},
},
},
'/api/admin/export/financial': {
get: {
tags: ['Admin'],
summary: 'Export financial data',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'startDate', in: 'query', schema: { type: 'string', format: 'date' } },
{ name: 'endDate', in: 'query', schema: { type: 'string', format: 'date' } },
{ name: 'eventId', in: 'query', schema: { type: 'string' } },
],
responses: {
200: { description: 'Exported financial data' },
},
},
},
// ==================== FAQ Endpoints ====================
'/api/faq': {
get: {
tags: ['FAQ'],
summary: 'Get FAQ list (public)',
description: 'Returns enabled FAQ questions, ordered by rank. Use ?homepage=true to get only questions enabled for homepage.',
parameters: [
{ name: 'homepage', in: 'query', schema: { type: 'boolean' }, description: 'If true, only return questions with showOnHomepage' },
],
responses: {
200: { description: 'List of FAQ items (id, question, questionEs, answer, answerEs, rank)' },
},
},
},
'/api/faq/admin/list': {
get: {
tags: ['FAQ'],
summary: 'Get all FAQ questions (admin)',
description: 'Returns all FAQ questions for management, ordered by rank.',
security: [{ bearerAuth: [] }],
responses: {
200: { description: 'List of all FAQ questions' },
401: { description: 'Unauthorized' },
},
},
},
'/api/faq/admin/:id': {
get: {
tags: ['FAQ'],
summary: 'Get FAQ by ID (admin)',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'FAQ details' },
404: { description: 'FAQ not found' },
},
},
put: {
tags: ['FAQ'],
summary: 'Update FAQ (admin)',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
requestBody: {
content: {
'application/json': {
schema: {
type: 'object',
properties: {
question: { type: 'string' },
questionEs: { type: 'string' },
answer: { type: 'string' },
answerEs: { type: 'string' },
enabled: { type: 'boolean' },
showOnHomepage: { type: 'boolean' },
},
},
},
},
},
responses: {
200: { description: 'FAQ updated' },
404: { description: 'FAQ not found' },
},
},
delete: {
tags: ['FAQ'],
summary: 'Delete FAQ (admin)',
security: [{ bearerAuth: [] }],
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
],
responses: {
200: { description: 'FAQ deleted' },
404: { description: 'FAQ not found' },
},
},
},
'/api/faq/admin': {
post: {
tags: ['FAQ'],
summary: 'Create FAQ (admin)',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['question', 'answer'],
properties: {
question: { type: 'string' },
questionEs: { type: 'string' },
answer: { type: 'string' },
answerEs: { type: 'string' },
enabled: { type: 'boolean', default: true },
showOnHomepage: { type: 'boolean', default: false },
},
},
},
},
},
responses: {
201: { description: 'FAQ created' },
400: { description: 'Validation error' },
},
},
},
'/api/faq/admin/reorder': {
post: {
tags: ['FAQ'],
summary: 'Reorder FAQ questions (admin)',
description: 'Set order by sending an ordered array of FAQ ids.',
security: [{ bearerAuth: [] }],
requestBody: {
required: true,
content: {
'application/json': {
schema: {
type: 'object',
required: ['ids'],
properties: {
ids: { type: 'array', items: { type: 'string' } },
},
},
},
},
},
responses: {
200: { description: 'Order updated, returns full FAQ list' },
400: { description: 'ids array required' },
},
},
},
},
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT token obtained from login endpoint',
},
},
schemas: {
User: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
name: { type: 'string' },
phone: { type: 'string' },
role: { type: 'string', enum: ['admin', 'organizer', 'staff', 'marketing', 'user'] },
languagePreference: { type: 'string' },
isClaimed: { type: 'boolean' },
rucNumber: { type: 'string' },
accountStatus: { type: 'string', enum: ['active', 'unclaimed', 'suspended'] },
createdAt: { type: 'string', format: 'date-time' },
},
},
Event: {
type: 'object',
properties: {
id: { type: 'string' },
title: { type: 'string' },
titleEs: { type: 'string' },
description: { type: 'string' },
descriptionEs: { type: 'string' },
startDatetime: { type: 'string', format: 'date-time' },
endDatetime: { type: 'string', format: 'date-time' },
location: { type: 'string' },
locationUrl: { type: 'string' },
price: { type: 'number' },
currency: { type: 'string' },
capacity: { type: 'integer' },
status: { type: 'string', enum: ['draft', 'published', 'cancelled', 'completed', 'archived'] },
bannerUrl: { type: 'string' },
createdAt: { type: 'string', format: 'date-time' },
},
},
Ticket: {
type: 'object',
properties: {
id: { type: 'string' },
userId: { type: 'string' },
eventId: { type: 'string' },
attendeeFirstName: { type: 'string' },
attendeeLastName: { type: 'string' },
attendeeEmail: { type: 'string' },
attendeePhone: { type: 'string' },
attendeeRuc: { type: 'string' },
preferredLanguage: { type: 'string' },
status: { type: 'string', enum: ['pending', 'confirmed', 'cancelled', 'checked_in'] },
qrCode: { type: 'string' },
checkinAt: { type: 'string', format: 'date-time' },
createdAt: { type: 'string', format: 'date-time' },
},
},
Payment: {
type: 'object',
properties: {
id: { type: 'string' },
ticketId: { type: 'string' },
provider: { type: 'string', enum: ['lightning', 'cash', 'bank_transfer', 'tpago'] },
amount: { type: 'number' },
currency: { type: 'string' },
status: { type: 'string', enum: ['pending', 'pending_approval', 'paid', 'refunded', 'failed', 'cancelled'] },
reference: { type: 'string' },
paidAt: { type: 'string', format: 'date-time' },
createdAt: { type: 'string', format: 'date-time' },
},
},
Invoice: {
type: 'object',
properties: {
id: { type: 'string' },
paymentId: { type: 'string' },
userId: { type: 'string' },
invoiceNumber: { type: 'string' },
rucNumber: { type: 'string' },
legalName: { type: 'string' },
amount: { type: 'number' },
currency: { type: 'string' },
pdfUrl: { type: 'string' },
status: { type: 'string', enum: ['generated', 'voided'] },
createdAt: { type: 'string', format: 'date-time' },
},
},
},
},
};
// API documentation is disabled in production to avoid exposing the full API
// surface (and schema) to anonymous users. Enable locally / in non-prod only.
if (!isProduction) {
// OpenAPI JSON endpoint
app.get('/openapi.json', (c) => {
return c.json(openApiSpec);
});
// Swagger UI
app.get('/api-docs', swaggerUI({ url: '/openapi.json' }));
} else {
app.get('/openapi.json', (c) => c.json({ error: 'Not Found' }, 404));
app.get('/api-docs', (c) => c.json({ error: 'Not Found' }, 404));
}
// Static file serving for uploads.
// Uploads are validated as images at write time, but as defense-in-depth we force
// any non-image path to download as an opaque attachment so a stray/legacy
// .html/.svg can never be rendered (and therefore never execute script) in-origin.
app.use('/uploads/*', async (c, next) => {
await next();
const path = c.req.path.toLowerCase();
const isInlineImage = /\.(jpg|jpeg|png|gif|webp|avif)$/.test(path);
if (!isInlineImage) {
c.header('Content-Disposition', 'attachment');
c.header('Content-Type', 'application/octet-stream');
}
});
app.use('/uploads/*', serveStatic({ root: './' }));
// 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(),
redis: describeRedis(),
backends: describeBackends(),
});
});
// API Routes
app.route('/api/auth', authRoutes);
app.route('/api/events', eventsRoutes);
app.route('/api/tickets', ticketsRoutes);
app.route('/api/users', usersRoutes);
app.route('/api/contacts', contactsRoutes);
app.route('/api/payments', paymentsRoutes);
app.route('/api/admin', adminRoutes);
app.route('/api/media', mediaRoutes);
app.route('/api/lnbits', lnbitsRoutes);
app.route('/api/emails', emailsRoutes);
app.route('/api/payment-options', paymentOptionsRoutes);
app.route('/api/dashboard', dashboardRoutes);
app.route('/api/site-settings', siteSettingsRoutes);
app.route('/api/legal-pages', legalPagesRoutes);
app.route('/api/legal-settings', legalSettingsRoutes);
app.route('/api/faq', faqRoutes);
// 404 handler
app.notFound((c) => {
return c.json({ error: 'Not Found' }, 404);
});
// Error handler
app.onError((err, c) => {
console.error('Error:', err);
return c.json({ error: 'Internal Server Error' }, 500);
});
const port = parseInt(process.env.PORT || '3001');
// Initialize email queue with the email service reference
initEmailQueue(emailService);
// Periodically expire abandoned pending bookings so they stop holding seats.
startBookingCleanup();
// Periodically put stale pending-approval payments on hold, releasing their seats.
startHoldSweep();
// Periodically auto-reject unconfirmed payments once their event is over (no email).
startEventEndSweep();
// Initialize email templates on startup.
// Guarded by a distributed lock so that, when running multiple replicas, only
// one instance seeds/updates templates per boot instead of all of them racing.
// onUnavailable 'run': at boot the Redis connection may not be ready yet, and
// seeding is upsert-idempotent, so racing replicas are safe — never skipping
// beats never seeding on a first boot during a Redis blip.
getLock()
.withLock('seed-templates', 30_000, () => emailService.seedDefaultTemplates(), { onUnavailable: 'run' })
.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();
const server = serve({
fetch: app.fetch,
port,
});
// Graceful shutdown: stop the periodic jobs, stop accepting connections, then
// close Redis and exit. Open SSE payment streams hold sockets forever, so
// server.close() alone never completes — force-close remaining connections
// after a short grace period, with a hard exit as the final backstop.
let shuttingDown = false;
function shutdown(signal: string): void {
if (shuttingDown) return;
shuttingDown = true;
console.log(`[shutdown] ${signal} received, draining...`);
stopBookingCleanup();
stopHoldSweep();
stopEventEndSweep();
stopQueue();
server.close(() => {
console.log('[shutdown] server closed, closing redis');
closeRedis().finally(() => process.exit(0));
});
const forceClose = setTimeout(() => {
console.warn('[shutdown] force-closing remaining connections (SSE streams)');
(server as any).closeAllConnections?.();
}, 5_000);
forceClose.unref();
const forceExit = setTimeout(() => {
console.warn('[shutdown] drain timed out, forcing exit');
process.exit(1);
}, 10_000);
forceExit.unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));