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 { auth } from './lib/betterAuth.js'; import authExtRoutes from './routes/authExt.js'; import { getClientIp } from './lib/rateLimit.js'; import eventsRoutes from './routes/events.js'; import ticketsRoutes from './routes/tickets.js'; import doorRoutes from './routes/door.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([ 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; }, // Session cookies must be allowed on cross-origin API calls (api.* vhost). 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 ==================== // Authentication is handled by Better Auth, mounted at /api/auth/*. // Sessions are httpOnly cookies (spanglish.session_token); the endpoints // below are the subset the frontend uses. See https://better-auth.com/docs // for the full endpoint reference. '/api/auth/sign-up/email': { post: { tags: ['Auth'], summary: 'Register a new user (Better Auth)', description: 'Create a user account and start a cookie session. First registered user becomes admin. Password policy: 10-128 chars, upper+lower+digit-or-symbol, common passwords rejected.', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['email', 'password', 'name'], properties: { email: { type: 'string', format: 'email' }, password: { type: 'string', minLength: 10 }, name: { type: 'string' }, phone: { type: 'string' }, languagePreference: { type: 'string', enum: ['en', 'es'] }, }, }, }, }, }, responses: { 200: { description: 'User created; session cookie set' }, 422: { description: 'Email already registered' }, 400: { description: 'Validation error' }, }, }, }, '/api/auth/sign-in/email': { post: { tags: ['Auth'], summary: 'Login with email and password (Better Auth)', description: 'Starts a cookie session. Per-email lockout: 5 failures / 15 min. Per-IP rate limited.', 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; session cookie set' }, 401: { description: 'Invalid credentials' }, 429: { description: 'Too many attempts' }, }, }, }, '/api/auth/sign-in/social': { post: { tags: ['Auth'], summary: 'Login or register with Google (Better Auth)', description: 'Sign in with a Google ID token (Google Identity Services credential). Links to an existing account by verified email.', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['provider'], properties: { provider: { type: 'string', enum: ['google'] }, idToken: { type: 'object', properties: { token: { type: 'string', description: 'Google ID token' } }, }, }, }, }, }, }, responses: { 200: { description: 'Login successful; session cookie set' }, 401: { description: 'Invalid Google token' }, }, }, }, '/api/auth/sign-in/magic-link': { post: { tags: ['Auth'], summary: 'Request magic link login (Better Auth)', description: 'Emails a one-time login link (10 min TTL, single use, hashed at rest). Does not create accounts.', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['email'], properties: { email: { type: 'string', format: 'email' }, callbackURL: { type: 'string' }, }, }, }, }, }, responses: { 200: { description: 'Magic link sent (if account exists)' } }, }, }, '/api/auth/magic-link/verify': { get: { tags: ['Auth'], summary: 'Verify magic link token (Better Auth)', parameters: [ { name: 'token', in: 'query', required: true, schema: { type: 'string' } }, ], responses: { 200: { description: 'Login successful; session cookie set' }, 400: { description: 'Invalid or expired token' }, }, }, }, '/api/auth/request-password-reset': { post: { tags: ['Auth'], summary: 'Request password reset (Better Auth)', description: 'Emails a reset link. Token expires in 30 minutes.', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['email'], properties: { email: { type: 'string', format: 'email' }, redirectTo: { type: 'string' }, }, }, }, }, }, responses: { 200: { description: 'Reset link sent (if account exists)' } }, }, }, '/api/auth/reset-password': { post: { tags: ['Auth'], summary: 'Reset password with token (Better Auth)', description: 'Sets a new password and revokes all existing sessions.', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['newPassword', 'token'], properties: { newPassword: { type: 'string', minLength: 10 }, token: { type: 'string' }, }, }, }, }, }, responses: { 200: { description: 'Password reset successful' }, 400: { description: 'Invalid or expired token' }, }, }, }, '/api/auth/change-password': { post: { tags: ['Auth'], summary: 'Change password (Better Auth)', security: [{ bearerAuth: [] }], requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['currentPassword', 'newPassword'], properties: { currentPassword: { type: 'string' }, newPassword: { type: 'string', minLength: 10 }, revokeOtherSessions: { type: 'boolean' }, }, }, }, }, }, responses: { 200: { description: 'Password changed' }, 400: { description: 'Current password incorrect' }, 401: { description: 'Unauthorized' }, }, }, }, '/api/auth/get-session': { get: { tags: ['Auth'], summary: 'Get current session and user (Better Auth)', security: [{ bearerAuth: [] }], responses: { 200: { description: '{ session, user } or null when not authenticated' }, }, }, }, '/api/auth/sign-out': { post: { tags: ['Auth'], summary: 'Logout (Better Auth)', description: 'Revokes the current session and clears the session cookie.', security: [{ bearerAuth: [] }], responses: { 200: { description: 'Logged out' } }, }, }, '/api/auth/list-sessions': { get: { tags: ['Auth'], summary: 'List active sessions (Better Auth)', security: [{ bearerAuth: [] }], responses: { 200: { description: 'Active sessions for the current user' } }, }, }, '/api/auth-ext/claim-account': { post: { tags: ['Auth'], summary: 'Claim a guest-created account', description: 'Completes the progressive-account claim: requires a session established via the claim magic link, sets the password, and activates the account.', security: [{ bearerAuth: [] }], requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['password'], properties: { password: { type: 'string', minLength: 10 } }, }, }, }, }, responses: { 200: { description: 'Account claimed successfully' }, 400: { description: 'Already claimed or validation error' }, 401: { description: 'No session (claim link required)' }, }, }, }, '/api/auth-ext/claim-eligibility': { get: { tags: ['Auth'], summary: 'Check whether an email has an unclaimed account', parameters: [ { name: 'email', in: 'query', required: true, schema: { type: 'string', format: 'email' } }, ], responses: { 200: { description: '{ canClaim: boolean }' } }, }, }, // ==================== 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' }, }, }, }, // ==================== Door Check-in Screen ==================== '/api/events/{eventId}/door-attendees': { get: { tags: ['Tickets'], summary: 'Full attendee list for the door check-in screen', description: 'One payload the door screen searches entirely client-side. Includes cancelled tickets so staff can see and reactivate them.', security: [{ bearerAuth: [] }], parameters: [ { name: 'eventId', in: 'path', required: true, schema: { type: 'string' } }, ], responses: { 200: { description: 'Event, attendees and check-in stats' }, 404: { description: 'Event not found' }, }, }, }, '/api/events/{eventId}/door-checkin': { post: { tags: ['Tickets'], summary: 'Check in, settle payment, or create a walk-in (atomic)', description: 'Pass ticketId to check in an existing attendee, or attendee to create a walk-in born confirmed, paid and checked in. Idempotent on idempotencyKey: replays return the original response instead of writing again.', security: [{ bearerAuth: [] }], parameters: [ { name: 'eventId', in: 'path', required: true, schema: { type: 'string' } }, ], requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['idempotencyKey'], properties: { ticketId: { type: 'string' }, attendee: { type: 'object', required: ['firstName'], properties: { firstName: { type: 'string' }, lastName: { type: 'string' }, phone: { type: 'string' }, email: { type: 'string', format: 'email' }, ruc: { type: 'string' }, }, }, payment: { type: 'object', required: ['method'], properties: { method: { type: 'string', enum: ['cash', 'bitcoin', 'transfer', 'guest'] }, amount: { type: 'number', description: 'Defaults to the event price; a multiple covers a group paid in one go.' }, }, }, entryMethod: { type: 'string', enum: ['scan', 'search', 'walkin'] }, idempotencyKey: { type: 'string' }, }, }, }, }, }, responses: { 201: { description: 'Attendee checked in; warnings may contain at_capacity' }, 200: { description: 'Replay of an already-processed idempotencyKey' }, 400: { description: 'Ticket belongs to a different event' }, 404: { description: 'Event or ticket not found' }, }, }, }, '/api/events/{eventId}/door-checkin/undo': { post: { tags: ['Tickets'], summary: 'Reverse one door check-in action', description: 'Reverts exactly what the keyed action did: restores the previous check-in and payment state, or cancels a ticket that was created at the door.', security: [{ bearerAuth: [] }], parameters: [ { name: 'eventId', in: 'path', required: true, schema: { type: 'string' } }, ], requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['idempotencyKey'], properties: { idempotencyKey: { type: 'string' } }, }, }, }, }, responses: { 200: { description: 'Action reversed (or already undone)' }, 404: { description: 'No action recorded for this key' }, }, }, }, '/api/events/{eventId}/door-summary': { get: { tags: ['Payments'], summary: 'Door cash-up and pre-sale/door revenue split', description: 'Totals per door tender (cash, bitcoin, transfer, guest) for end-of-night reconciliation, plus the pre-sale versus door revenue split shown on the event dashboard.', security: [{ bearerAuth: [] }], parameters: [ { name: 'eventId', in: 'path', required: true, schema: { type: 'string' } }, ], responses: { 200: { description: 'Door totals by method, door lines, and pre-sale totals' }, 404: { description: 'Event not found' }, }, }, }, '/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: 'apiKey', in: 'cookie', name: 'spanglish.session_token', description: 'Better Auth httpOnly session cookie (set by sign-in; __Secure- prefixed in production)', }, }, 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 // Better Auth handles all /api/auth/* endpoints (sign-in/up/out, magic link, // password reset, Google, session management). CORS above runs first. // // Better Auth only sees the Request (no TCP peer address), so its per-IP rate // limiting is fed the socket-anchored client IP resolved by getClientIp via a // private header. The inbound value is always discarded — a client cannot // choose its own rate-limit bucket. app.on(['POST', 'GET'], '/api/auth/*', (c) => { const headers = new Headers(c.req.raw.headers); headers.delete('x-client-ip'); const clientIp = getClientIp(c); if (clientIp && clientIp !== 'unknown') { headers.set('x-client-ip', clientIp); } return auth.handler( new Request(c.req.raw, { headers, // Node's fetch requires duplex for requests carrying a body stream ...(c.req.raw.body ? { duplex: 'half' as const } : {}), } as RequestInit) ); }); app.route('/api/auth-ext', authExtRoutes); // Door check-in screen endpoints live under /api/events/:eventId/door-*. // Mounted first so the generic /:id routes below can never shadow them. app.route('/api/events', doorRoutes); 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'));