EventBuzz_Backend/src/app.js

454 lines
17 KiB
JavaScript

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;