const Hapi = require('@hapi/hapi'); const HapiJwt = require('hapi-auth-jwt2'); const { connectDB } = require('./config/database'); const authRoutes = require('./routes/auth.routes'); const eventRoutes = require('./routes/event.routes'); const participantRoutes = require('./routes/participant.routes'); const participantListRoutes = require('./routes/participant-list.routes'); const participantDocumentsRoutes = require('./routes/participantDocuments.routes'); const sessionRoutes = require('./routes/session.routes'); const trackRoutes = require('./routes/track.routes'); const speakerRoutes = require('./routes/speaker.routes'); const exhibitorRoutes = require('./routes/exhibitor.routes'); const questionRoutes = require('./routes/question.routes'); const quizPollRoutes = require('./routes/quizPoll.routes'); const campaignRoutes = require('./routes/campaign.routes'); const notificationRoutes = require('./routes/notification.routes'); const formRoutes = require('./routes/form.routes'); const designTemplateRoutes = require('./routes/designTemplate.routes'); const rewardPointRoutes = require('./routes/rewardPoint.routes'); const pageRoutes = require('./routes/page.routes'); const mediaRoutes = require('./routes/media.routes'); const contentCollectionRoutes = require('./routes/contentCollection.routes'); const paymentRoutes = require('./routes/payment.routes'); const organizationRoutes = require('./routes/organization.routes'); const roleRoutes = require('./routes/role.routes'); const subscriptionRoutes = require('./routes/subscription.routes'); const platformAdminRoutes = require('./routes/platformAdmin.routes'); const adminRequestRoutes = require('./routes/adminRequest.routes'); const eventStaffRoutes = require('./routes/eventStaff.routes'); const websiteSettingsRoutes = require('./routes/websiteSettings.routes'); const eventMicrositeRoutes = require('./routes/eventMicrosite.routes'); const visitorRoutes = require('./routes/visitor.routes'); const whatsappTemplateRoutes = require('./routes/whatsappTemplate.routes'); const badgeBatchRoutes = require('./routes/badgeBatch.routes'); const eventTicketRoutes = require('./routes/eventTicket.routes'); const publicEventTicketRoutes = require('./routes/publicEventTicket.routes'); const publicMicrositeRoutes = require('./routes/publicMicrosite.routes'); const publicCaptchaRoutes = require('./routes/publicCaptcha.routes'); const publicDemoRequestRoutes = require('./routes/publicDemoRequest.routes'); const publicAttendanceRoutes = require('./routes/publicAttendance.routes'); const publicParticipantQrRoutes = require('./routes/publicParticipantQr.routes'); const attendanceRoutes = require('./routes/attendance.routes'); const checkinsRoutes = require('./routes/checkins.routes'); const ticketManualRoutes = require('./routes/ticketManual.routes'); const eventTicketingManualRoutes = require('./routes/eventTicketingManual.routes'); const conciergeBotRoutes = require('./routes/conciergeBot.routes'); const publicConciergeRoutes = require('./routes/publicConcierge.routes'); const publicQuizPollRoutes = require('./routes/publicQuizPoll.routes'); const publicPhotoWallRoutes = require('./routes/publicPhotoWall.routes'); const staticMicrositeRoutes = require('./routes/staticMicrosite.routes'); const eventPhotoRoutes = require('./routes/eventPhoto.routes'); const photoWallRoutes = require('./routes/photoWall.routes'); const createServer = async () => { /** Production: set CORS_ORIGIN (or FRONTEND_URL) to every origin that loads the SPA, e.g. https://console.example.com (no trailing slash). */ const fromEnv = (process.env.CORS_ORIGIN || process.env.FRONTEND_URL || '') .split(',') .map((s) => s.trim().replace(/\/$/, '')) .filter(Boolean); /** Local Vite / dashboard ports — keep in sync with eventbuzz (5174) and main frontend (5173). */ const builderExtras = (process.env.WEBSITE_BUILDER_CORS_ORIGINS || '') .split(',') .map((s) => s.trim().replace(/\/$/, '')) .filter(Boolean); const localDevOrigins = [ 'http://localhost:3000', 'http://localhost:5173', 'http://localhost:5174', 'http://localhost:5175', 'http://localhost:8080', 'http://127.0.0.1:5173', 'http://127.0.0.1:5174', 'http://127.0.0.1:5175', 'http://127.0.0.1:8080', ]; const isProd = process.env.NODE_ENV === 'production'; // In dev, merge env with local defaults so CORS_ORIGIN=http://localhost:5173 still allows eventbuzz on :5174. const originList = isProd ? fromEnv.length > 0 ? fromEnv : localDevOrigins : fromEnv.length > 0 ? [...new Set([...localDevOrigins, ...fromEnv])] : localDevOrigins; /** Extra allowed origins (e.g. GrapesJS app on a different port/domain). Also set WEBSITE_BUILDER_CORS_ORIGINS in prod. */ const conciergeExtras = (process.env.CONCIERGE_PUBLIC_ALLOWED_ORIGINS || '') .split(',') .map((s) => s.trim().replace(/\/$/, '')) .filter(Boolean); const allowedOrigins = [...new Set([...originList, ...builderExtras, ...conciergeExtras])]; function isPublicMicrositeApiPath(path) { if (!path) return false; if (path.startsWith('/api/public/')) return true; return /^\/api\/forms\/[^/]+\/submit$/.test(path); } function resolveCorsForRequest(request) { const origin = request.headers.origin; const allowed = resolveCorsAllowOrigin(origin); if (allowed) return allowed; if (origin && isPublicMicrositeApiPath(request.path)) { return String(origin).trim().replace(/\/$/, ''); } return null; } /** In development, allow any http://localhost or http://127.0.0.1 port (Vite may use 5175, 5176, …). Production: whitelist only. */ function resolveCorsAllowOrigin(rawOrigin) { if (!rawOrigin) return null; const origin = String(rawOrigin).trim().replace(/\/$/, ''); if (allowedOrigins.includes(origin)) return origin; if (isProd) return null; try { const u = new URL(origin); if (u.protocol !== 'http:') return null; if (u.hostname !== 'localhost' && u.hostname !== '127.0.0.1') return null; return origin; } catch { return null; } } /** Hapi default is 1MB; event logo/banner are sent as base64 in JSON and easily exceed that. Override via PAYLOAD_MAX_BYTES. */ const payloadMaxBytes = (() => { const n = Number(process.env.PAYLOAD_MAX_BYTES); return Number.isFinite(n) && n > 0 ? n : 25 * 1024 * 1024; })(); const server = Hapi.server({ port: process.env.PORT || 3001, host: process.env.HOST || 'localhost', // JWT auth uses Authorization headers; ignore third-party/stale browser cookies on localhost. state: { strictHeader: false, ignoreErrors: true, }, routes: { payload: { maxBytes: payloadMaxBytes, }, state: { failAction: 'ignore', }, validate: { failAction: (request, h, err) => { throw err; }, }, }, }); server.route({ method: 'OPTIONS', path: '/{p*}', options: { auth: false, handler: (request, h) => { const allowOrigin = resolveCorsForRequest(request); const res = h.response().code(204); if (allowOrigin) { res.header('Access-Control-Allow-Origin', allowOrigin); res.header('Access-Control-Allow-Credentials', 'true'); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); res.header('Access-Control-Expose-Headers', 'Content-Disposition'); res.header('Access-Control-Max-Age', '86400'); } return res; }, }, }); await server.register(HapiJwt); const User = require('./models/User'); const jwtSecret = process.env.JWT_SECRET || 'default-secret-change-in-production'; const attendeeSecret = process.env.ATTENDEE_JWT_SECRET || jwtSecret; const Participant = require('./models/Participant'); server.auth.strategy('jwt', 'jwt', { key: jwtSecret, validate: async (decoded, request, h) => { if (decoded.scope === '2fa_pending') return { isValid: false }; const user = await User.findById(decoded.id).select('-password').populate('organization'); if (!user || !user.isActive) return { isValid: false }; return { isValid: true, credentials: { user, id: user._id.toString() } }; }, verifyOptions: { algorithms: ['HS256'] }, }); // OR-auth for endpoints that must accept either admin/user JWT or self-attendee JWT. // Hapi v17 does not support `auth.strategy: ['a','b']` for a single route. server.auth.strategy('jwt-any', 'jwt', { key: attendeeSecret, validate: async (decoded, request, h) => { // Try attendee first (audience + pid) if (decoded?.aud === 'attendee' && decoded?.pid) { const participant = await Participant.findById(decoded.pid).lean(); if (!participant) return { isValid: false }; return { isValid: true, credentials: { participant, attendee: decoded } }; } // Fallback to admin/user JWT using the same token validation as `jwt`. // Note: admin/user JWT has `decoded.id`. if (!decoded?.id) return { isValid: false }; const user = await User.findById(decoded.id).select('-password').populate('organization'); if (!user || !user.isActive) return { isValid: false }; return { isValid: true, credentials: { user, id: user._id.toString() } }; }, verifyOptions: { algorithms: ['HS256'] }, }); server.auth.strategy('jwt-attendee', 'jwt', { key: attendeeSecret, validate: async (decoded, request, h) => { if (decoded.aud !== 'attendee' || !decoded.pid) return { isValid: false }; const participant = await Participant.findById(decoded.pid).lean(); if (!participant) return { isValid: false }; return { isValid: true, credentials: { participant, attendee: decoded } }; }, verifyOptions: { algorithms: ['HS256'] }, }); server.auth.default('jwt'); server.route({ method: 'GET', path: '/', handler: () => ({ name: 'EventBuzz API', version: '1.0.0', status: 'ok' }), options: { auth: false }, }); server.route({ method: 'GET', path: '/health', handler: () => ({ status: 'ok', timestamp: new Date().toISOString() }), options: { auth: false }, }); // Embeddable concierge widget assets (served directly from backend/public). const fs = require('fs/promises'); const path = require('path'); const PUBLIC_DIR = path.join(__dirname, '..', 'public'); const sendFile = async (h, relPath, contentType) => { const abs = path.join(PUBLIC_DIR, relPath); const body = await fs.readFile(abs, 'utf8'); return h.response(body).type(contentType); }; server.route([ { method: 'GET', path: '/embed/concierge-widget.js', options: { auth: false }, handler: async (request, h) => sendFile(h, 'embed/concierge-widget.js', 'application/javascript'), }, { method: 'GET', path: '/embed/concierge-widget.css', options: { auth: false }, handler: async (request, h) => sendFile(h, 'embed/concierge-widget.css', 'text/css'), }, { method: 'GET', path: '/embed/demo', options: { auth: false }, handler: async (request, h) => sendFile(h, 'embed-demo.html', 'text/html'), }, ]); server.route(visitorRoutes); server.route(publicEventTicketRoutes); server.route(publicMicrositeRoutes); server.route(publicCaptchaRoutes); server.route(publicDemoRequestRoutes); server.route(publicAttendanceRoutes); server.route(publicParticipantQrRoutes); server.route(publicConciergeRoutes); server.route(publicQuizPollRoutes); server.route(publicPhotoWallRoutes); server.route(authRoutes); server.route(eventRoutes); server.route(participantRoutes); server.route(participantListRoutes); server.route(participantDocumentsRoutes); server.route(sessionRoutes); server.route(trackRoutes); server.route(speakerRoutes); server.route(exhibitorRoutes); server.route(questionRoutes); server.route(quizPollRoutes); server.route(campaignRoutes); server.route(notificationRoutes); server.route(formRoutes); server.route(designTemplateRoutes); server.route(rewardPointRoutes); server.route(pageRoutes); server.route(mediaRoutes); server.route(contentCollectionRoutes); server.route(paymentRoutes); server.route(organizationRoutes); server.route(roleRoutes); server.route(subscriptionRoutes); server.route(platformAdminRoutes); server.route(adminRequestRoutes); server.route(eventStaffRoutes); server.route(websiteSettingsRoutes); server.route(eventMicrositeRoutes); server.route(staticMicrositeRoutes); server.route(whatsappTemplateRoutes); server.route(badgeBatchRoutes); server.route(eventTicketRoutes); server.route(ticketManualRoutes); server.route(eventTicketingManualRoutes); server.route(eventPhotoRoutes); server.route(photoWallRoutes); server.route(conciergeBotRoutes); server.route(attendanceRoutes); server.route(checkinsRoutes); server.ext('onPreResponse', (request, h) => { const response = request.response; const allowOrigin = resolveCorsForRequest(request); if (response.isBoom && response.output) { const statusCode = response.output.statusCode; const joiDetails = Array.isArray(response.details) ? response.details : Array.isArray(response.data) ? response.data : []; const detailMsgs = joiDetails .map((d) => (d && typeof d === 'object' ? d.message : null)) .filter(Boolean); const message = detailMsgs.length > 0 ? detailMsgs.slice(0, 3).join('; ') : response.message; const payload = { success: false, error: { code: response.output.payload.error || 'ERROR', message, details: joiDetails, }, timestamp: new Date().toISOString(), }; const res = h.response(payload).code(statusCode); if (allowOrigin) { res.header('Access-Control-Allow-Origin', allowOrigin); res.header('Access-Control-Allow-Credentials', 'true'); } return res; } if (allowOrigin && typeof response.header === 'function') { response.header('Access-Control-Allow-Origin', allowOrigin); response.header('Access-Control-Allow-Credentials', 'true'); response.header('Access-Control-Expose-Headers', 'Content-Disposition'); } return h.continue; }); return server; }; const app = { _server: null, async initialize() { await connectDB(); const { ensureSystemEmailTemplates } = require('./utils/ensureSystemEmailTemplates'); try { const { created, updated } = await ensureSystemEmailTemplates(); if (created > 0) console.log(`[startup] seeded ${created} system email template(s)`); if (updated > 0) console.log(`[startup] updated ${updated} system email template(s)`); } catch (err) { console.error('[startup] ensureSystemEmailTemplates failed', err); } const { warmPlanCache } = require('./config/subscriptionCatalog'); await warmPlanCache(); this._server = await createServer(); const cron = require('node-cron'); const { runSubscriptionExpiry } = require('./jobs/subscriptionExpiry.job'); cron.schedule( '0 3 * * *', async () => { try { const n = await runSubscriptionExpiry(); if (n > 0) console.log(`[cron] subscription expiry: ${n} organisation(s) set to free`); } catch (err) { console.error('[cron] subscription expiry failed', err); } }, { timezone: process.env.CRON_TZ || undefined }, ); const { runWhatsAppTemplateSync } = require('./jobs/whatsappTemplateSync.job'); const waCron = process.env.WHATSAPP_TEMPLATE_SYNC_CRON || '*/12 * * * *'; cron.schedule( waCron, async () => { try { const n = await runWhatsAppTemplateSync(); if (n > 0) console.log(`[cron] whatsapp templates: synced ${n} organisation(s)`); } catch (err) { console.error('[cron] whatsapp template sync failed', err); } }, { timezone: process.env.CRON_TZ || undefined }, ); // Automatic photo retention cleanup — runs daily at ___. const { runPhotoCleanup } = require('./services/photoCleanupService'); cron.schedule( '45 9 * * *', async () => { try { const r = await runPhotoCleanup(); if (r.deleted > 0 || r.failed > 0) { console.log( `[cron] photo retention cleanup: processed=${r.processed} deleted=${r.deleted} failed=${r.failed} retentionMonths=${r.retentionMonths}`, ); } } catch (err) { console.error('[cron] photo retention cleanup failed', err); } }, { timezone: process.env.CRON_TZ || undefined }, ); const { startEmbeddedQueueWorkers } = require('./utils/queueWorkerKick'); startEmbeddedQueueWorkers(); }, async start() { if (!this._server) await this.initialize(); await this._server.start(); }, get info() { return this._server ? this._server.info : null; }, }; module.exports = app;