Files
Spanglish/backend/src/index.ts
T
MichilisandClaude Opus 5 e296e80e48 Rebuild the Scanner page into a unified door check-in screen.
Most attendees arrive without their QR open, and taking money at the
door meant leaving the scanner for the event dashboard, where the Add
Ticket modal demanded an email and recorded no payment method. The
screen now leads with manual name search, keeps the camera one tap away
behind a fullscreen overlay, and creates and charges walk-ins inline.

Check-in and payment are one action: anything done here is born
confirmed, paid (or comp) and checked in through a single endpoint,
POST /api/events/:eventId/door-checkin. There are no confirm dialogs
anywhere, because they stall the queue; a ten-second Undo replaces
them, reversing exactly what the action changed via the undo state
recorded alongside its idempotency key. Writes fire in the background
with retries, so venue wifi never blocks the person at the door, and a
capacity limit only warns, since staff at the door are the authority.

Every write carries a client-generated idempotency key, inserted in the
same transaction as the writes it guards, so a double tap or a retry
after a timeout cannot produce a second ticket, payment or check-in.
Search runs entirely in memory over one preloaded list: names are
matched accent- and case-insensitively in both directions, per word,
prefix before substring, with a mostly-numeric query searching phone
digits so two people with the same name can be told apart.

Door money is recorded as payments.source 'door' plus payments.method
(cash, bitcoin, transfer or guest) while provider keeps its existing
value, so capacity counting, the stale-booking sweeps and the admin
payment lists are unaffected and revenue can still be split pre-sale
versus door. Bitcoin records the payment as made, on the same trust
model as cash, with no invoice generated; lib/doorPayments.ts is where
a real Lightning flow slots in later.

Also fixes the SQLite tickets DDL, which still created the pre-split
attendee_name column with NOT NULL email and phone. Only fresh
databases were affected -- existing ones were relaxed by later ALTERs
-- but on those, door walk-ins (and any other ticket) could not be
inserted at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 06:09:55 +00:00

2130 lines
69 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 { 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<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;
},
// 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'));