Event finance - Finance tab on the event page: P&L summary with a revenue-to-result waterfall, costs and other income in one ledger, and the partner split with payouts. Lifecycle stepper (Selling, Adding costs, Ready to close, Finalized) with what is left to do; closing the books goes through a checklist dialog that freezes the numbers. - Expense modal shows only the fields each calculation type needs, a live preview with the event's real counts, and a category suggested from the description. Date inputs follow the UI language. - Global Finance page: profit per event with outliers clipped and labelled, and a "Ready to close" list of past events whose books are still open. - Calculation service (integer PYG, basis points) with pinned regression scenarios; PYG formatting centralised in lib/money with locale-aware separators. Event team permissions - Per-event members with role presets and requireEventPermission; the header stat "Confirmed" is relabelled "Not checked in yet", which is what it counts. Public sales state - One sales state (online, door, sold out, ended, external, cancelled) for the event page, listings and JSON-LD, with the door price and tenders shown only while people can still pay at the door. Frontend unit tests run with vitest (npm test in frontend/). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2138 lines
70 KiB
TypeScript
2138 lines
70 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 eventFinanceRoutes from './routes/eventFinance.js';
|
|
import financeRoutes from './routes/finance.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. The server prices the charge from the event: walk-ins pay the walk-in price (or the ticket price when none is set) x quantity, existing tickets the ticket price x quantity. 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', 'pos', 'guest'] },
|
|
quantity: { type: 'integer', minimum: 1, maximum: 50, description: 'Tickets paid for in one go (defaults to 1). The server multiplies it by the resolved unit price.' },
|
|
amount: { type: 'number', description: 'Ignored unless amountOverride is true.' },
|
|
amountOverride: { type: 'boolean', description: 'Charge `amount` instead of the computed price. Admin/organizer only; written to audit_logs.' },
|
|
},
|
|
},
|
|
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, or the payment method is not enabled for this event' },
|
|
403: { description: 'amountOverride sent by a role that may not override the door amount' },
|
|
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);
|
|
// Per-event finance, partners and team access (/api/events/:id/finance, /expenses, /members, ...)
|
|
app.route('/api/events', eventFinanceRoutes);
|
|
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);
|
|
app.route('/api/finance', financeRoutes);
|
|
|
|
// 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'));
|