454 lines
17 KiB
JavaScript
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;
|