This commit is contained in:
Ravi Gulhane 2026-07-01 16:38:11 +05:30
parent 2a5629c2ae
commit 03b1c6a7d8
52 changed files with 3323 additions and 437 deletions

View File

@ -7,10 +7,14 @@ JWT_SECRET=your-super-secret-jwt-key-change-this
JWT_REFRESH_SECRET=your-refresh-secret-key-change-this
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# For Vite dev server use http://localhost:5173; for other frontends adjust (comma-separated for multiple).
# In NODE_ENV!==production, http://localhost:* and http://127.0.0.1:* are also allowed automatically.
# Allowed browser origins for cookies + CORS (must match the dashboard URL exactly: scheme + host + port).
# Comma-separated if you have several (e.g. staging + prod).
# Production example: CORS_ORIGIN=https://console.eventbuzz.live
# If this list does not include your dashboard origin, login/XHR will fail with "not allowed by Access-Control-Allow-Origin".
# eventbuzz Vite app often runs on :5174 — include it when NODE_ENV=production.
# In NODE_ENV!==production, http://localhost:* and http://127.0.0.1:* are also allowed automatically (production is whitelist-only).
FRONTEND_URL=http://localhost:5173
CORS_ORIGIN=http://localhost:5173
CORS_ORIGIN=http://localhost:5173,http://localhost:5174
# Optional: comma-separated origins for the GrapesJS microsite builder (iframe) calling the API
# WEBSITE_BUILDER_CORS_ORIGINS=http://localhost:5173,https://builder.example.com
@ -24,7 +28,16 @@ SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
# Payment (optional)
# Optional IANA timezone for the daily subscription-expiry job (node-cron). Default: server local time.
# CRON_TZ=Asia/Kolkata
# Payment — platform Razorpay (subscriptions / platform checkout). Either pair works:
# RAZORPAY_PLATFORM_KEY_ID / RAZORPAY_PLATFORM_KEY_SECRET (preferred)
# or RAZORPAY_KEY_ID / RAZORPAY_KEY_SECRET (alias; same as resolver fallback)
# Optional webhook verify: RAZORPAY_PLATFORM_WEBHOOK_SECRET
# You can also store keys in DB via super_admin → platform payment settings and set enabled there.
RAZORPAY_PLATFORM_KEY_ID=
RAZORPAY_PLATFORM_KEY_SECRET=
RAZORPAY_KEY_ID=
RAZORPAY_KEY_SECRET=
@ -33,14 +46,93 @@ AWS_SECRET_ACCESS_KEY=ZpIxby1vbCLGB7CPWZsAltZBEqkhwzgBpFOKDGj8
AWS_DEFAULT_REGION=ap-south-1
AWS_BUCKET=rapportapp
AWS_URL=https://did94hhhcgq1d.cloudfront.net
# Amazon Bedrock (AI event generation). Model must be enabled in your AWS account for this region.
# BEDROCK_MODEL_ID=anthropic.claude-3-haiku-20240307-v1:0
# Amazon Bedrock (AI event quick-create). Uses Converse API.
# 1) Bedrock console → Model access (ap-south-1): enable Meta Llama 3 8B / 70B.
# 2) IAM user/role needs bedrock:InvokeModel + bedrock:Converse on those foundation models.
# See bedrock-iam-policy.example.json — attach to the same user as AWS_ACCESS_KEY_ID (e.g. rapport_upload).
# ap-south-1 model IDs: meta.llama3-8b-instruct-v1:0, meta.llama3-70b-instruct-v1:0
BEDROCK_MODEL_ID=meta.llama3-8b-instruct-v1:0
# Use only AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY here, OR AWS_PROFILE in your shell — not both (SDK warning).
# Optional: region override (defaults to AWS_DEFAULT_REGION). For Llama 3.1: BEDROCK_REGION=us-east-1
# BEDROCK_REGION=ap-south-1
# Optional: comma-separated extra model IDs to try if the primary is invalid
# BEDROCK_MODEL_FALLBACKS=meta.llama3-70b-instruct-v1:0
# Bedrock embeddings for WhatsApp concierge knowledge base (enable Titan Embeddings in Bedrock console)
# ap-south-1: prefer v2; v1 is 1536-dim, v2 default is 1024-dim — re-sync knowledge after switching models
# BEDROCK_EMBED_MODEL_ID=amazon.titan-embed-text-v2:0
# BEDROCK_EMBED_DIMENSIONS=1024
# BEDROCK_EMBED_MODEL_FALLBACKS=amazon.titan-embed-text-v1:0,amazon.titan-embed-text-v1
# Redis + BullMQ (WhatsApp concierge ingest / inbound workers in chatbot/)
# Local: redis://127.0.0.1:6379
# DigitalOcean Managed Redis: use rediss://...:25061 or REDIS_TLS=true with redis:// URL
# REDIS_URL=redis://127.0.0.1:6379
# REDIS_TLS=true
# CONCIERGE_QUEUE_PREFIX=eventbuzz:concierge:
# Concierge URL crawl (multi-page ingest for knowledge sources)
# CONCIERGE_CRAWL_MAX_PAGES=50
# CONCIERGE_CRAWL_MAX_ASSETS=20
# CONCIERGE_CRAWL_MAX_DEPTH=5
# CONCIERGE_CRAWL_TIMEOUT_MS=30000
# CONCIERGE_CRAWL_DELAY_MS=200
# CONCIERGE_CRAWL_MAX_ASSET_BYTES=15728640
# Public API base URL injected into published microsite HTML for contact forms (e.g. https://api.example.com/api or http://localhost:3001/api)
MICROSITE_API_BASE_URL=http://localhost:3001/api
# Public web app (forms, registration, default microsites)
# EVENTBUZZ_PUBLIC_URL=https://web.eventbuzz.live
# Registration link template for microsite/emailer CTAs: {eventId} and {formId} placeholders
# MICROSITE_REGISTRATION_URL_TEMPLATE=https://web.eventbuzz.live/event/{eventId}/register
# Published microsite folder base (custom microsite static HTML)
# MICROSITE_PUBLIC_BASE_URL=https://web.eventbuzz.live
# Comma-separated CORS origins (include console + public web app)
# CORS_ORIGIN=https://console.eventbuzz.live,https://web.eventbuzz.live
# Firebase Realtime Database (optional — quiz/poll sync). Paths relative to backend/ when starting the API from backend/
FIREBASE_DATABASE_URL=https://eventbuzz-live-default-rtdb.firebaseio.com
GOOGLE_APPLICATION_CREDENTIALS=./eventbuzz-live-firebase-adminsdk-fbsvc-bb3dcfd679.json
# Or if JSON lives in repo root: ../eventbuzz-live-firebase-adminsdk-fbsvc-bb3dcfd679.json
# Or: FIREBASE_SERVICE_ACCOUNT_PATH= / FIREBASE_SERVICE_ACCOUNT_JSON= (see firebase.js)
# Or: FIREBASE_SERVICE_ACCOUNT_PATH= / FIREBASE_SERVICE_ACCOUNT_JSON= (see firebase.js)
# Encrypted storage for organisation SMTP passwords (32-byte key: hex 64 chars or base64)
# SMTP_ENCRYPTION_KEY=
# Queue workers (email, WhatsApp, badge batches) run inside the API by default.
# Set EMBEDDED_QUEUE_WORKERS=false and run npm run email-worker / whatsapp-worker / badge-batch-worker separately if needed.
# EMBEDDED_QUEUE_WORKERS=true
# EMBEDDED_QUEUE_WORKER_POLL_MS=3000
# Standalone worker tuning (npm run email-worker, whatsapp-worker, badge-batch-worker)
# EMAIL_WORKER_POLL_MS=2000
# EMAIL_WORKER_MAX_ATTEMPTS=3
# WHATSAPP_WORKER_POLL_MS=2000
# WHATSAPP_WORKER_MAX_ATTEMPTS=3
# Min delay between Graph /messages calls (rate limiting)
# WHATSAPP_WORKER_MIN_INTERVAL_MS=400
# BADGE_BATCH_WORKER_POLL_MS=3000
# WhatsApp Cloud API (Meta Graph) — optional org-level config in Settings; these override when set:
# META_GRAPH_API_VERSION=v21.0
# Log every Meta Graph request + response to the server console (tokens shown as ***; large JSON truncated — see META_WHATSAPP_LOG_MAX_CHARS):
# META_WHATSAPP_DEBUG=1
# or: META_WHATSAPP_LOG=true
# Optional: max characters per logged JSON block (default 250000)
# META_WHATSAPP_LOG_MAX_CHARS=250000
# Meta App ID (numeric) — required for IMAGE header templates: sample URLs are uploaded via Graph resumable upload.
# META_WHATSAPP_APP_ID=
# or: META_APP_ID=
# Optional cap for header sample download size (bytes). Default 5MB.
# META_WHATSAPP_HEADER_UPLOAD_MAX_BYTES=5242880
# META_WHATSAPP_DEFAULT_WABA_ID=
# META_WHATSAPP_ACCESS_TOKEN=
# Must be a phone number id from GET /{WABA_ID}/phone_numbers on the *same* WABA as above (otherwise templates return Meta #132001).
# META_WHATSAPP_DEFAULT_PHONE_NUMBER_ID=
# Only if you must bypass GET /{waba-id}/phone_numbers validation (token cannot list phones but send is known-good):
# META_WHATSAPP_SKIP_PHONE_WABA_CHECK=1
# Cron: sync pending template statuses (default every 12 minutes)
# WHATSAPP_TEMPLATE_SYNC_CRON=*/12 * * * *
# Optional future webhook (template status updates from Meta):
# META_WHATSAPP_WEBHOOK_VERIFY_TOKEN=
# One-time fee (INR) org pays per event to unlock manual UPI checkout — recorded under Settings → Billing → invoices.
# EVENT_TICKETING_MANUAL_ENABLEMENT_FEE_INR=500

View File

@ -97,7 +97,7 @@
if (res.json && res.json.success && res.json.data) {
title.textContent = res.json.data.eventName ? ('Concierge · ' + res.json.data.eventName) : 'Event Concierge';
var greet = res.json.data.greeting;
if (greet) addMessage('assistant', greet.replace(/\{eventName\}/g, res.json.data.eventName || 'the event'));
if (greet) addMessage('assistant', greet);
if (res.json.data.maxMessageLength) input.setAttribute('maxlength', String(res.json.data.maxMessageLength));
}
});

View File

@ -0,0 +1,54 @@
/**
* Drop legacy Participant indexes from the pre–multi-event schema.
* The old unique index on { event, email } blocks guest sign-in (no email) because
* MongoDB treats missing fields as null and only allows one { event: null, email: null } row.
*
* Usage (from backend/): node scripts/drop-legacy-participant-indexes.js
*/
require('dotenv').config();
const mongoose = require('mongoose');
const Participant = require('../src/models/Participant');
const LEGACY_INDEX_NAMES = [
'event_1_email_1',
'event_1',
'email_1',
];
async function main() {
const uri = process.env.MONGODB_URI || process.env.MONGO_URI;
if (!uri) {
console.error('Set MONGODB_URI or MONGO_URI');
process.exit(1);
}
await mongoose.connect(uri);
const coll = mongoose.connection.db.collection('participants');
const existing = await coll.indexes();
const names = new Set(existing.map((i) => i.name));
for (const name of LEGACY_INDEX_NAMES) {
if (!names.has(name)) {
console.log(`Skip (not present): ${name}`);
continue;
}
await coll.dropIndex(name);
console.log(`Dropped legacy index: ${name}`);
}
const synced = await Participant.syncIndexes();
console.log('syncIndexes result:', synced);
const after = await coll.indexes();
console.log(
'Current indexes:',
after.map((i) => i.name).join(', ')
);
await mongoose.disconnect();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View File

@ -32,6 +32,7 @@ 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 publicAttendanceRoutes = require('./routes/publicAttendance.routes');
const attendanceRoutes = require('./routes/attendance.routes');
const checkinsRoutes = require('./routes/checkins.routes');
@ -39,6 +40,7 @@ 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 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). */
@ -108,10 +110,18 @@ const createServer = async () => {
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;
@ -217,8 +227,10 @@ const createServer = async () => {
server.route(visitorRoutes);
server.route(publicEventTicketRoutes);
server.route(publicMicrositeRoutes);
server.route(publicAttendanceRoutes);
server.route(publicConciergeRoutes);
server.route(publicQuizPollRoutes);
server.route(authRoutes);
server.route(eventRoutes);
@ -322,6 +334,8 @@ const app = {
},
{ timezone: process.env.CRON_TZ || undefined },
);
const { startEmbeddedQueueWorkers } = require('./utils/queueWorkerKick');
startEmbeddedQueueWorkers();
},
async start() {

View File

@ -5,7 +5,7 @@ const PARTICIPANT_STATUS = ['pending', 'confirmed', 'checked_in', 'cancelled'];
const SESSION_TYPES = ['keynote', 'workshop', 'panel', 'breakout', 'networking'];
const SESSION_STATUS = ['scheduled', 'ongoing', 'completed', 'cancelled'];
const FORM_TYPES = ['registration', 'feedback', 'survey', 'custom', 'other'];
const FIELD_TYPES = ['text', 'email', 'phone', 'select', 'stars', 'radio', 'checkbox', 'checkboxes', 'file'];
const FIELD_TYPES = ['text', 'textarea', 'email', 'phone', 'select', 'stars', 'radio', 'checkbox', 'checkboxes', 'file'];
/** Registration field → Participant property mapping (or `custom` + customMapsKey). */
const FORM_FIELD_MAPS_TO = [
'firstName',
@ -41,6 +41,18 @@ const WEBSITE_SECTION_TYPES = [
'faq',
'tickets',
];
/** Default section order for the public /event/:id microsite when none are configured. */
const DEFAULT_MICROSITE_SECTIONS = [
'header',
'banner',
'countdown',
'about',
'agenda',
'speakers',
'register_links',
'sponsors',
'footer',
];
const DESIGN_TEMPLATE_TYPES = ['brochure', 'microsite', 'badge', 'emailer', 'certificate'];
const REWARD_SOURCES = [
'registration',
@ -72,6 +84,7 @@ module.exports = {
CAMPAIGN_TYPES,
PAGE_TYPES,
WEBSITE_SECTION_TYPES,
DEFAULT_MICROSITE_SECTIONS,
DESIGN_TEMPLATE_TYPES,
REWARD_SOURCES,
};

View File

@ -25,8 +25,16 @@ const connectDB = async () => {
serverSelectionTimeoutMS: Number(process.env.MONGODB_SERVER_SELECTION_MS) || 30000,
socketTimeoutMS: Number(process.env.MONGODB_SOCKET_TIMEOUT_MS) || 45000,
})
.then((conn) => {
.then(async (conn) => {
console.log(`MongoDB connected: ${conn.connection.host}`);
if (process.env.SYNC_MONGOOSE_INDEXES !== 'false') {
try {
const Participant = require('../models/Participant');
await Participant.syncIndexes();
} catch (err) {
console.error('Participant index sync failed:', err);
}
}
return conn;
})
.catch((err) => {

View File

@ -155,6 +155,9 @@ const create = async (request, h) => {
backgroundImageUrl: String(p.backgroundImageUrl || '').trim(),
backgroundColor: p.backgroundColor || '#0B4DD1',
fields: Array.isArray(p.fields) ? p.fields : [],
layout: Array.isArray(p.layout) ? p.layout : [],
customTexts: Array.isArray(p.customTexts) ? p.customTexts : [],
customImages: Array.isArray(p.customImages) ? p.customImages : [],
outputFormat: p.outputFormat,
badgeTypeLabel: p.badgeTypeLabel || 'Attendee',
};
@ -168,6 +171,9 @@ const create = async (request, h) => {
config,
});
const { kickBadgeBatchWorker } = require('../utils/queueWorkerKick');
kickBadgeBatchWorker();
return h
.response(
success(

View File

@ -116,7 +116,7 @@ const remove = async (request, h) => {
};
async function getTemplateBodies(campaign) {
async function getTemplateBodies(campaign, eventDoc) {
let subject = campaign.content?.subject || '';
let body = campaign.content?.body || '';
const tplId = campaign.content?.template;
@ -124,7 +124,10 @@ async function getTemplateBodies(campaign) {
const tpl = await DesignTemplate.findById(tplId).lean();
if (tpl?.content != null) {
const c = tpl.content;
if (typeof c === 'string') body = body || c;
if (c && typeof c === 'object' && eventDoc && Array.isArray(c.blocks) && c.blocks.length) {
const { buildEmailerHtmlFromContent } = require('../utils/emailerHtml');
body = body || (await buildEmailerHtmlFromContent(c, eventDoc));
} else if (typeof c === 'string') body = body || c;
else if (c && typeof c.html === 'string') body = body || c.html;
if (c && typeof c === 'object') {
const subjTpl = String(c.subjectLine || c.subject || '').trim();
@ -153,7 +156,7 @@ async function enqueueCampaignEmails(campaignDoc, processAfter) {
const resolved = await resolveRecipientsForCampaign(campaignDoc, tenantOrg, idsForList);
if (resolved.error) return resolved;
const rows = resolved.rows || [];
const { subject: subjTpl, body: bodyTpl } = await getTemplateBodies(campaignDoc);
const { subject: subjTpl, body: bodyTpl } = await getTemplateBodies(campaignDoc, eventDoc);
if (!String(bodyTpl).trim()) {
return { error: { code: 'VALIDATION_ERROR', message: 'Message body is required (add content or pick a design template with HTML)' } };
}
@ -184,6 +187,17 @@ async function enqueueCampaignEmails(campaignDoc, processAfter) {
}
await EmailQueue.insertMany(docs);
const when = processAfter || new Date();
console.log(
'[campaign:email] enqueued',
docs.length,
'recipient(s) for campaign',
campaignDoc._id,
'processAfter=',
when.toISOString(),
);
const { kickEmailQueueWorker } = require('../utils/queueWorkerKick');
kickEmailQueueWorker();
return { count: docs.length };
}
@ -259,6 +273,8 @@ async function enqueueWhatsAppSends(campaignDoc, processAfter) {
}
await WhatsAppQueue.insertMany(docs);
const { kickWhatsAppQueueWorker } = require('../utils/queueWorkerKick');
kickWhatsAppQueueWorker();
return { count: docs.length };
}
@ -309,6 +325,7 @@ const send = async (request, h) => {
const patch = await applyDraftPatchIfPresent(campaign, request.payload);
if (!patch.ok) return h.response(error('VALIDATION_ERROR', patch.message)).code(400);
if (campaign.channel === 'email') {
console.log('[campaign:email] send requested for campaign', campaign._id, 'name=', campaign.name);
await EmailQueue.deleteMany({ campaign: campaign._id, status: 'pending' });
const enq = await enqueueCampaignEmails(campaign, new Date());
if (enq.error) return h.response(error(enq.error.code, enq.error.message)).code(400);
@ -476,7 +493,7 @@ const preview = async (request, h) => {
if (f) flat = f;
}
}
const { subject: subjTpl, body: bodyTpl } = await getTemplateBodies(campaign);
const { subject: subjTpl, body: bodyTpl } = await getTemplateBodies(campaign, eventDoc);
const subjResolved = resolveSubjectForSend(campaign, subjTpl);
const merged = mergeCampaignContent(subjResolved, bodyTpl, flat, eventDoc);
return h.response(success(merged));

View File

@ -16,6 +16,7 @@ const {
computeQuickCreateGapsFromRawAi,
serializeMergedPreviewForApi,
} = require('../utils/aiEventMergeAndGaps');
const { buildEventAnalytics } = require('../services/eventAnalytics.service');
function parseDataUrl(dataUrl) {
if (typeof dataUrl !== 'string') return null;
@ -571,69 +572,7 @@ const analytics = async (request, h) => {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const Participant = require('../models/Participant');
const Session = require('../models/Session');
const QuizPoll = require('../models/QuizPoll');
const RewardPoint = require('../models/RewardPoint');
const orgId = event.organization;
const eid = event._id;
const participantBase = [
{ $match: { tenantOrganization: orgId } },
{ $unwind: '$eventMemberships' },
{ $match: { 'eventMemberships.event': eid } },
];
const [
statusAgg,
participantCountArr,
sessionCount,
upcomingSessionCount,
quizPollCount,
rewardByType,
] = await Promise.all([
Participant.aggregate([...participantBase, { $group: { _id: '$eventMemberships.status', count: { $sum: 1 } } }]),
Participant.aggregate([...participantBase, { $count: 'total' }]),
Session.countDocuments({ event: eid }),
Session.countDocuments({
event: eid,
startTime: { $gt: new Date() },
status: { $ne: 'cancelled' },
}),
QuizPoll.countDocuments({ event: eid }),
RewardPoint.aggregate([
{ $match: { event: eid } },
{ $group: { _id: '$type', total: { $sum: '$points' } } },
]),
]);
const statusCounts = statusAgg.reduce((acc, c) => {
acc[c._id] = c.count;
return acc;
}, {});
const participantCount = participantCountArr[0]?.total ?? 0;
const rewardMap = rewardByType.reduce((acc, r) => {
acc[r._id] = r.total;
return acc;
}, {});
const pointsAwarded = Math.round(rewardMap.earned || 0);
const pointsRedeemed = Math.round(rewardMap.redeemed || 0);
const meta =
event.metadata && typeof event.metadata.toObject === 'function'
? event.metadata.toObject()
: event.metadata || {};
const analyticsData = {
...meta,
statusCounts,
participantCount,
sessionCount,
upcomingSessionCount,
quizPollCount,
pointsAwarded,
pointsRedeemed,
};
const analyticsData = await buildEventAnalytics(event);
return h.response(success(analyticsData));
};

View File

@ -26,24 +26,73 @@ function canManageStaff(user, event) {
return ['admin', 'moderator'].includes(user.role);
}
function defaultEventRoleForMap(map, user) {
const r = map?.role || user?.role;
if (r === 'admin') return 'Co-organiser';
if (r === 'moderator') return 'Moderator';
return 'Viewer';
}
/** Whether this org member can open this event (matches attendance / org-wide access rules). */
function userHasEventAccess(map, user, eventId) {
if (!user?._id) return false;
if (user.role === 'super_admin' || user.role === 'admin') return true;
if (map?.role === 'admin') return true;
const accessList = map?.eventAccess || [];
if (!accessList.length) return true;
const eid = eventId.toString();
return accessList.some((x) => x.event && x.event.toString() === eid);
}
/**
* All org members with access to this event — explicit eventAccess rows plus org-wide admins/moderators.
*/
function staffListFromMaps(maps, eventId) {
const eid = eventId.toString();
const members = [];
const seen = new Set();
for (const m of maps) {
const u = m.user;
if (!u || !u._id) continue;
const uid = u._id.toString();
if (seen.has(uid)) continue;
const ea = (m.eventAccess || []).find((x) => x.event && x.event.toString() === eid);
if (!ea) continue;
members.push({
userId: u._id,
email: u.email,
firstName: u.firstName,
lastName: u.lastName,
role: u.role,
sessionIds: (ea.sessionIds || []).map((id) => id.toString()),
eventRole: ea.eventRole || 'Viewer',
});
if (ea) {
seen.add(uid);
members.push({
userId: u._id,
email: u.email,
firstName: u.firstName,
lastName: u.lastName,
role: u.role,
sessionIds: (ea.sessionIds || []).map((id) => id.toString()),
eventRole: ea.eventRole || 'Viewer',
});
continue;
}
if (userHasEventAccess(m, u, eventId)) {
seen.add(uid);
members.push({
userId: u._id,
email: u.email,
firstName: u.firstName,
lastName: u.lastName,
role: u.role,
sessionIds: [],
eventRole: defaultEventRoleForMap(m, u),
});
}
}
members.sort((a, b) => {
const an = [a.firstName, a.lastName].filter(Boolean).join(' ') || a.email;
const bn = [b.firstName, b.lastName].filter(Boolean).join(' ') || b.email;
return an.localeCompare(bn, undefined, { sensitivity: 'base' });
});
return members;
}
@ -57,6 +106,32 @@ const listStaff = async (request, h) => {
const orgId = event.organization._id || event.organization;
const maps = await OrganizationUserMap.find({ organization: orgId }).populate('user', '-password').lean();
const members = staffListFromMaps(maps, eventId);
const seen = new Set(members.map((m) => m.userId.toString()));
// Org members without an OrganizationUserMap row still have org-wide access when eventAccess is empty.
const orgUsers = await User.find({ organization: orgId }).select('-password').lean();
for (const u of orgUsers) {
const uid = u._id.toString();
if (seen.has(uid)) continue;
const map = maps.find((m) => m.user && m.user._id.toString() === uid);
if (!userHasEventAccess(map, u, eventId)) continue;
seen.add(uid);
members.push({
userId: u._id,
email: u.email,
firstName: u.firstName,
lastName: u.lastName,
role: u.role,
sessionIds: [],
eventRole: defaultEventRoleForMap(map, u),
});
}
members.sort((a, b) => {
const an = [a.firstName, a.lastName].filter(Boolean).join(' ') || a.email;
const bn = [b.firstName, b.lastName].filter(Boolean).join(' ') || b.email;
return an.localeCompare(bn, undefined, { sensitivity: 'base' });
});
const invites = await EventInvite.find({ event: eventId, status: 'pending' })
.select('email eventRole sessionIds createdAt expiresAt')

View File

@ -7,6 +7,11 @@ const Participant = require('../models/Participant');
const ParticipantList = require('../models/ParticipantList');
const { success, error } = require('../utils/response');
const { computeEventRatingsSummary } = require('../utils/eventRatingsSummary');
const {
extractObjectIdString,
buildPublicEventPayload,
resolveEventForPublicForm,
} = require('../utils/formPublicHelpers');
function normalizeSubmitData(raw) {
if (!raw) return {};
@ -14,77 +19,6 @@ function normalizeSubmitData(raw) {
return { ...raw };
}
/** Normalize form.event from lean() (ObjectId, string, or rare populated { _id }). */
function extractObjectIdString(ref) {
if (ref == null) return null;
if (ref instanceof mongoose.Types.ObjectId) return ref.toString();
if (Buffer.isBuffer(ref)) {
try {
return new mongoose.Types.ObjectId(ref).toString();
} catch {
return null;
}
}
if (typeof ref === 'object' && ref._id != null) {
return extractObjectIdString(ref._id);
}
if (typeof ref === 'string') {
const t = ref.trim();
if (t.length === 24 && mongoose.Types.ObjectId.isValid(t)) return t;
}
return null;
}
const EVENT_PUBLIC_SELECT =
'name subtitle type startDate endDate location locationType image bannerImage website.logo';
function buildPublicEventPayload(ev, micrositeLogo) {
const toIso = (d) => (d && !Number.isNaN(new Date(d).getTime()) ? new Date(d).toISOString() : null);
const siteLogo = ev.website?.logo && String(ev.website.logo).trim() ? String(ev.website.logo).trim() : null;
const msLogo = micrositeLogo && String(micrositeLogo).trim() ? String(micrositeLogo).trim() : null;
return {
name: ev.name,
subtitle: ev.subtitle || '',
eventType: ev.type,
startDate: toIso(ev.startDate),
endDate: toIso(ev.endDate),
location: ev.location ? String(ev.location) : '',
locationType: ev.locationType || 'physical',
logoUrl: siteLogo || msLogo || null,
imageUrl: (() => {
const a = ev.image && String(ev.image).trim();
const b = ev.bannerImage && String(ev.bannerImage).trim();
return a || b || null;
})(),
};
}
/** Resolve Event for a public form: direct form.event, then Event.website.registrationFormId, then EventMicrosite.registrationFormId. */
async function resolveEventForPublicForm(form) {
const directId = extractObjectIdString(form.event);
if (directId) {
const ev = await Event.findById(directId).select(EVENT_PUBLIC_SELECT).lean();
if (ev) return { ev, micrositeLogo: null };
}
const formIdStr = String(form._id);
let ev = await Event.findOne({
$or: [{ 'website.registrationFormId': formIdStr }, { 'website.registrationFormId': form._id }],
})
.select(EVENT_PUBLIC_SELECT)
.lean();
if (ev) return { ev, micrositeLogo: null };
const ms = await EventMicrosite.findOne({
$or: [{ registrationFormId: formIdStr }, { registrationFormId: form._id }],
})
.select('event logo')
.lean();
if (!ms || !ms.event) return { ev: null, micrositeLogo: null };
ev = await Event.findById(ms.event).select(EVENT_PUBLIC_SELECT).lean();
return { ev, micrositeLogo: ev ? ms.logo : null };
}
async function upsertParticipantFromRegistrationForm(form, tenantOrganization, dataObj) {
const fields = form.fields || [];
const orgOid =

View File

@ -176,6 +176,58 @@ function listAllProjectStages() {
const BULK_MAX = 500;
/** Shared pipeline for event-scoped participant queries (list, matching-ids, tags). */
function eventParticipantPreStages(eventId, orgId, query = {}) {
const { status, designation, q, tag } = query;
const eid = new mongoose.Types.ObjectId(String(eventId));
const stages = [
{ $match: { tenantOrganization: orgId } },
{ $unwind: '$eventMemberships' },
{ $match: { 'eventMemberships.event': eid } },
];
if (status) stages.push({ $match: { 'eventMemberships.status': status } });
if (designation) stages.push({ $match: { designation } });
if (q && String(q).trim()) {
const search = String(q).trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(search, 'i');
stages.push({
$match: {
$or: [
{ firstName: re },
{ lastName: re },
{ email: re },
{ phone: re },
{ organization: re },
{ designation: re },
],
},
});
}
if (tag && String(tag).trim()) {
stages.push({
$addFields: {
_allTags: {
$setUnion: [{ $ifNull: ['$tags', []] }, { $ifNull: ['$eventMemberships.tags', []] }],
},
},
});
stages.push({ $match: { _allTags: String(tag).trim() } });
}
return stages;
}
async function assertEventListAccess(eventId, user) {
const event = await Event.findById(eventId);
if (!event) return { error: (h) => h.response(error('NOT_FOUND', 'Event not found')).code(404) };
if (
user.role !== 'super_admin' &&
event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()
) {
return { error: (h) => h.response(error('FORBIDDEN', 'Access denied')).code(403) };
}
return { event };
}
const listDistinctTags = async (request, h) => {
const user = request.auth.credentials.user;
const r = listAllTenantOnlyMatch(user, request.query.organization);
@ -246,27 +298,52 @@ const bulkParticipants = async (request, h) => {
tags: tagPayload,
eventIds: evIdsRaw,
listId,
eventId: bulkEventId,
status: filterStatus,
designation: filterDesignation,
} = request.payload || {};
let participantIds = [...new Set((idsRaw || []).map((x) => String(x)))].filter((id) => mongoose.Types.ObjectId.isValid(id));
if (selectAllMatching) {
const r = listAllTenantOnlyMatch(user, request.query.organization, request.payload?.organization);
if (r.error) return h.response(error(r.error.code, r.error.message)).code(400);
const { tenantMatch } = r;
const stages = [
{ $match: tenantMatch },
...listAllCommonStages({ q, tag: filterTag }, { skipSort: true }),
{ $project: { _id: 1 } },
{ $limit: BULK_MAX + 1 },
];
const rows = await Participant.aggregate(stages);
if (rows.length > BULK_MAX) {
return h
.response(error('VALIDATION_ERROR', `Too many participants match (max ${BULK_MAX}). Narrow filters or select fewer rows.`))
.code(400);
if (bulkEventId) {
const access = await assertEventListAccess(bulkEventId, user);
if (access.error) return access.error(h);
const stages = [
...eventParticipantPreStages(bulkEventId, access.event.organization, {
q,
tag: filterTag,
status: filterStatus,
designation: filterDesignation,
}),
{ $project: { _id: 1 } },
{ $limit: BULK_MAX + 1 },
];
const rows = await Participant.aggregate(stages);
if (rows.length > BULK_MAX) {
return h
.response(error('VALIDATION_ERROR', `Too many participants match (max ${BULK_MAX}). Narrow filters or select fewer rows.`))
.code(400);
}
participantIds = rows.map((r) => String(r._id));
} else {
const r = listAllTenantOnlyMatch(user, request.query.organization, request.payload?.organization);
if (r.error) return h.response(error(r.error.code, r.error.message)).code(400);
const { tenantMatch } = r;
const stages = [
{ $match: tenantMatch },
...listAllCommonStages({ q, tag: filterTag }, { skipSort: true }),
{ $project: { _id: 1 } },
{ $limit: BULK_MAX + 1 },
];
const rows = await Participant.aggregate(stages);
if (rows.length > BULK_MAX) {
return h
.response(error('VALIDATION_ERROR', `Too many participants match (max ${BULK_MAX}). Narrow filters or select fewer rows.`))
.code(400);
}
participantIds = rows.map((r) => String(r._id));
}
participantIds = rows.map((r) => String(r._id));
}
if (!participantIds.length) {
@ -353,6 +430,26 @@ const bulkParticipants = async (request, h) => {
return h.response(success({ updated: participants.length, listSize: list.participantIds.length }));
}
if (action === 'remove_from_event') {
if (!bulkEventId || !mongoose.Types.ObjectId.isValid(String(bulkEventId))) {
return h.response(error('VALIDATION_ERROR', 'eventId required')).code(400);
}
const access = await assertEventListAccess(bulkEventId, user);
if (access.error) return access.error(h);
let removed = 0;
for (const p of participants) {
if (!getMembership(p, bulkEventId)) continue;
p.eventMemberships = p.eventMemberships.filter((m) => String(m.event) !== String(bulkEventId));
if (p.eventMemberships.length === 0) {
await p.deleteOne();
} else {
await p.save();
}
removed += 1;
}
return h.response(success({ removed }));
}
return h.response(error('BAD_REQUEST', 'Unknown action')).code(400);
};
@ -385,45 +482,63 @@ const listAll = async (request, h) => {
.header('X-Total-Count', String(total));
};
const listEventDistinctTags = async (request, h) => {
const { eventId } = request.params;
const user = request.auth.credentials.user;
const access = await assertEventListAccess(eventId, user);
if (access.error) return access.error(h);
const rows = await Participant.aggregate([
...eventParticipantPreStages(eventId, access.event.organization, {}),
{
$addFields: {
_allTags: {
$setUnion: [{ $ifNull: ['$tags', []] }, { $ifNull: ['$eventMemberships.tags', []] }],
},
},
},
{ $unwind: { path: '$_allTags', preserveNullAndEmptyArrays: false } },
{ $group: { _id: '$_allTags' } },
{ $sort: { _id: 1 } },
{ $limit: 200 },
]);
const tags = rows.map((r) => r._id).filter((t) => t != null && String(t).trim() !== '');
return h.response(success({ tags }));
};
const listEventMatchingIds = async (request, h) => {
const { eventId } = request.params;
const user = request.auth.credentials.user;
const access = await assertEventListAccess(eventId, user);
if (access.error) return access.error(h);
const { q, tag, status, designation } = request.query;
const stages = [
...eventParticipantPreStages(eventId, access.event.organization, { q, tag, status, designation }),
{ $project: { _id: 1 } },
{ $limit: BULK_MAX + 1 },
];
const rows = await Participant.aggregate(stages);
const capped = rows.length > BULK_MAX;
const trimmed = capped ? rows.slice(0, BULK_MAX) : rows;
const ids = trimmed.map((r) => String(r._id));
return h.response(success({ ids, total: ids.length, capped }));
};
const list = async (request, h) => {
const { eventId } = request.params;
const event = await Event.findById(eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const user = request.auth.credentials.user;
if (
user.role !== 'super_admin' &&
event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()
) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const access = await assertEventListAccess(eventId, user);
if (access.error) return access.error(h);
const orgId = event.organization;
const { page = 1, limit = 20, status, q, designation } = request.query;
const orgId = access.event.organization;
const { page = 1, limit = 20, status, q, designation, tag } = request.query;
const eid = new mongoose.Types.ObjectId(String(eventId));
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100);
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
const skip = (pageNum - 1) * limitNum;
const preStages = [{ $match: { tenantOrganization: orgId } }, { $unwind: '$eventMemberships' }, { $match: { 'eventMemberships.event': eid } }];
if (status) preStages.push({ $match: { 'eventMemberships.status': status } });
if (designation) preStages.push({ $match: { designation } });
if (q && String(q).trim()) {
const search = String(q).trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(search, 'i');
preStages.push({
$match: {
$or: [
{ firstName: re },
{ lastName: re },
{ email: re },
{ phone: re },
{ organization: re },
{ designation: re },
],
},
});
}
const preStages = eventParticipantPreStages(eventId, orgId, { status, designation, q, tag });
const facet = await Participant.aggregate([
...preStages,
@ -952,6 +1067,8 @@ module.exports = {
listAll,
listDistinctTags,
listMatchingIds,
listEventDistinctTags,
listEventMatchingIds,
bulkParticipants,
list,
create,

View File

@ -5,6 +5,7 @@ const Event = require('../models/Event');
const WebChatSession = require('../models/WebChatSession');
const { success, error } = require('../utils/response');
const { executeConciergeRag } = require('../services/conciergeRag.service');
const { interpolateConciergeGreeting } = require('../utils/conciergeGreeting');
const MAX_MESSAGE_LENGTH = 800;
const MAX_HISTORY_MESSAGES = 40;
@ -40,12 +41,17 @@ const config = async (request, h) => {
const { botId } = request.params;
const { bot, event, err } = await loadBotOr404(botId);
if (err) return h.response(err).code(err.error?.code === 'NOT_FOUND' ? 404 : 400);
const eventName = event?.name || undefined;
const greetingTemplate = bot.greeting?.message || '';
return h.response(
success({
botId: String(bot._id),
eventId: String(bot.event),
eventName: event?.name || 'Event',
greeting: bot.greeting?.message || '',
eventName: eventName || 'Event',
greeting: interpolateConciergeGreeting(greetingTemplate, {
profileName: undefined,
eventName,
}),
maxMessageLength: MAX_MESSAGE_LENGTH,
}),
);

View File

@ -0,0 +1,198 @@
const mongoose = require('mongoose');
const Event = require('../models/Event');
const Form = require('../models/Form');
const { success, error } = require('../utils/response');
const { loadAgendaAndSpeakers } = require('../utils/eventAgendaData');
const { resolvePublishPage, eventBuzzPublicBase } = require('../utils/micrositeBuild');
const {
extractObjectIdString,
buildPublicEventPayload,
resolveEventForPublicForm,
} = require('../utils/formPublicHelpers');
function publicEventSummary(event) {
const website = event.website || {};
const logoUrl =
(website.logo && String(website.logo).trim()) ||
(event.image && String(event.image).trim()) ||
null;
const imageUrl =
(event.image && String(event.image).trim()) ||
(event.bannerImage && String(event.bannerImage).trim()) ||
(Array.isArray(website.banners) && website.banners[0] ? String(website.banners[0]).trim() : null) ||
null;
const toIso = (d) => (d && !Number.isNaN(new Date(d).getTime()) ? new Date(d).toISOString() : null);
return {
_id: String(event._id),
name: event.name,
subtitle: event.subtitle || '',
eventType: event.type,
startDate: toIso(event.startDate),
endDate: toIso(event.endDate),
location: event.location ? String(event.location) : '',
locationType: event.locationType || 'physical',
logoUrl,
imageUrl,
};
}
function publicWebsiteBag(website) {
const w = website || {};
return {
enabled: w.enabled !== false,
title: w.title || '',
description: w.description || '',
logo: w.logo || '',
banners: Array.isArray(w.banners) ? w.banners : [],
favicon: w.favicon || '',
primaryColor: w.primaryColor || '',
accentColor: w.accentColor || '',
fontPair: w.fontPair || '',
layoutTemplate: w.layoutTemplate || 'conference',
seoDescription: w.seoDescription || '',
};
}
async function resolveRegistrationFormId(event) {
const website = event.website || {};
const fromWebsite = website.registrationFormId != null ? String(website.registrationFormId).trim() : '';
if (fromWebsite) return fromWebsite;
const reg = await Form.findOne({
event: event._id,
type: 'registration',
'settings.isActive': { $ne: false },
})
.select('_id')
.sort({ createdAt: 1 })
.lean();
return reg ? String(reg._id) : '';
}
function registrationPath(eventId) {
return `/event/${eventId}/register`;
}
async function formBelongsToEvent(form, event, eventId) {
const formEventId = extractObjectIdString(form.event);
if (formEventId === eventId) return true;
const regId = event.website?.registrationFormId;
if (regId && String(regId) === String(form._id)) return true;
if (form.type === 'feedback' && String(form.tenantOrganization) === String(event.organization)) return true;
return false;
}
async function getPublicMicrosite(request, h) {
const { eventId } = request.params;
if (!mongoose.Types.ObjectId.isValid(eventId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400);
}
const event = await Event.findById(eventId).lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const website = event.website || {};
const [page, agendaData, registrationFormId] = await Promise.all([
resolvePublishPage(event, website),
loadAgendaAndSpeakers(event._id),
resolveRegistrationFormId(event),
]);
const base = eventBuzzPublicBase().replace(/\/$/, '');
const eid = String(event._id);
const payload = {
event: publicEventSummary(event),
website: publicWebsiteBag(website),
title: page.title,
sections: page.sections || [],
agenda: agendaData.agendaItems || [],
speakers: (agendaData.speakers || []).map((s) => ({
name: s.name || '',
designation: s.designation || '',
photoUrl: s.photoUrl || '',
bio: s.bio || '',
})),
registrationFormId: registrationFormId || null,
registrationUrl: `${base}${registrationPath(eid)}`,
};
const micrositeHtml = website.micrositeHtml != null ? String(website.micrositeHtml).trim() : '';
if (micrositeHtml) payload.micrositeHtml = micrositeHtml;
return h.response(success(payload));
}
async function getPublicRegistration(request, h) {
const { eventId } = request.params;
if (!mongoose.Types.ObjectId.isValid(eventId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400);
}
const event = await Event.findById(eventId).lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const registrationFormId = await resolveRegistrationFormId(event);
if (!registrationFormId) {
return h.response(error('NOT_FOUND', 'Registration is not available for this event')).code(404);
}
const base = eventBuzzPublicBase().replace(/\/$/, '');
return h.response(
success({
eventId: String(event._id),
formId: registrationFormId,
registrationUrl: `${base}${registrationPath(String(event._id))}`,
}),
);
}
async function getPublicEventForm(request, h) {
const { eventId, formId } = request.params;
if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(formId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400);
}
const [event, form] = await Promise.all([
Event.findById(eventId).select('organization website').lean(),
Form.findById(formId).lean(),
]);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404);
if (!form.settings?.isActive) {
return h.response(error('VALIDATION_ERROR', 'This form is not accepting responses.')).code(400);
}
if (!(await formBelongsToEvent(form, event, eventId))) {
return h.response(error('FORBIDDEN', 'Form is not available for this event')).code(403);
}
const { ev: resolvedEvent, micrositeLogo } = await resolveEventForPublicForm(form);
const eventPublic = resolvedEvent ? buildPublicEventPayload(resolvedEvent, micrositeLogo) : publicEventSummary(event);
const safe = {
_id: form._id,
name: form.name,
description: form.description || '',
type: form.type,
event: eventPublic,
fields: (form.fields || []).map((f) => ({
label: f.label,
type: f.type,
required: f.required,
options: f.options,
placeholder: f.placeholder,
mapsTo: f.mapsTo,
isFixed: f.isFixed,
})),
settings: {
thankYouMessage: form.settings?.thankYouMessage,
showThankYou: form.settings?.showThankYou !== false,
collectEmail: !!form.settings?.collectEmail,
allowAnonymous: !!form.settings?.allowAnonymous,
},
};
return h.response(success(safe));
}
module.exports = {
getPublicMicrosite,
getPublicRegistration,
getPublicEventForm,
};

View File

@ -0,0 +1,85 @@
const QuizPoll = require('../models/QuizPoll');
const QuizPollResponse = require('../models/QuizPollResponse');
const { success, error } = require('../utils/response');
const { buildQuestionPresentationStats } = require('../utils/quizPollResultsSummary');
function presentationPublicBase() {
return (process.env.EVENTBUZZ_PUBLIC_URL || 'http://localhost:5174').replace(/\/$/, '');
}
function mapQuestionForPresentation(q) {
const o = q && typeof q.toObject === 'function' ? q.toObject() : { ...q };
return {
_id: o._id != null ? String(o._id) : '',
question: o.question != null ? o.question : '',
type: o.type != null ? o.type : 'single',
options: (o.options || []).map((opt) => ({
_id: opt._id != null ? String(opt._id) : '',
text: opt.text != null ? opt.text : '',
})),
points: o.points,
timeLimit: o.timeLimit,
};
}
async function loadQuizPollByPresentationToken(token) {
const t = String(token || '').trim();
if (!t) return null;
return QuizPoll.findOne({
'settings.presentationToken': t,
deletedAt: null,
}).lean();
}
/** GET /api/public/quiz-polls/present/{token} */
async function getPresentation(request, h) {
const quizPoll = await loadQuizPollByPresentationToken(request.params.token);
if (!quizPoll) return h.response(error('NOT_FOUND', 'Presentation not found')).code(404);
const settings = quizPoll.settings || {};
return h.response(
success({
_id: String(quizPoll._id),
eventId: quizPoll.event != null ? String(quizPoll.event) : '',
sessionId: quizPoll.session != null ? String(quizPoll.session) : '',
title: quizPoll.title != null ? quizPoll.title : '',
type: quizPoll.type != null ? quizPoll.type : 'poll',
settings: {
displayMode: settings.displayMode != null ? String(settings.displayMode) : undefined,
liveQuestionId:
settings.liveQuestionId != null && String(settings.liveQuestionId).trim() !== ''
? String(settings.liveQuestionId).trim()
: null,
showResults: settings.showResults !== false,
},
questions: (quizPoll.questions || []).map(mapQuestionForPresentation),
}),
);
}
/** GET /api/public/quiz-polls/present/{token}/questions/{questionIndex}/stats */
async function getQuestionStats(request, h) {
const quizPoll = await loadQuizPollByPresentationToken(request.params.token);
if (!quizPoll) return h.response(error('NOT_FOUND', 'Presentation not found')).code(404);
const questionIndex = Number(request.params.questionIndex);
const qCount = (quizPoll.questions || []).length;
if (!Number.isFinite(questionIndex) || questionIndex < 0 || questionIndex >= qCount) {
return h.response(error('VALIDATION_ERROR', 'Invalid question index')).code(400);
}
const allResponses = await QuizPollResponse.find({ quizPoll: quizPoll._id })
.populate('participant', 'displayName firstName lastName')
.lean();
const stats = buildQuestionPresentationStats(quizPoll, allResponses, questionIndex);
if (!stats) return h.response(error('NOT_FOUND', 'Question not found')).code(404);
return h.response(success(stats));
}
module.exports = {
getPresentation,
getQuestionStats,
presentationPublicBase,
};

View File

@ -4,6 +4,35 @@ const Event = require('../models/Event');
const { success, error } = require('../utils/response');
const { syncQuizPollToFirebase } = require('../utils/quizPollFirebaseSync');
function presentationPublicBase() {
return (process.env.EVENTBUZZ_PUBLIC_URL || 'http://localhost:5174').replace(/\/$/, '');
}
function displayModeOf(item) {
return String(item?.settings?.displayMode || '').trim();
}
function questionIdString(q) {
if (!q || q._id == null) return '';
return String(q._id);
}
function findQuestionById(item, questionId) {
if (!questionId) return null;
const target = String(questionId).trim();
return (item.questions || []).find((q) => questionIdString(q) === target) || null;
}
async function assertQuizPollAccess(request, h, item) {
const event = await Event.findById(item.event);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const user = request.auth.credentials.user;
if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
return null;
}
const list = async (request, h) => {
const { eventId } = request.params;
const event = await Event.findById(eventId);
@ -72,6 +101,18 @@ const remove = async (request, h) => {
const publish = async (request, h) => {
const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null });
if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404);
const denied = await assertQuizPollAccess(request, h, item);
if (denied) return denied;
if (displayModeOf(item) === 'quiz_master') {
return h
.response(
error(
'VALIDATION_ERROR',
'Quiz Master mode uses per-question live control. Use POST /quiz-polls/{id}/live-question instead.',
),
)
.code(400);
}
item.settings = item.settings || {};
item.settings.isLive = true;
await item.save();
@ -82,13 +123,90 @@ const publish = async (request, h) => {
const close = async (request, h) => {
const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null });
if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404);
const denied = await assertQuizPollAccess(request, h, item);
if (denied) return denied;
item.settings = item.settings || {};
item.settings.isLive = false;
item.settings.liveQuestionId = null;
item.settings.showCorrectAnswer = false;
await item.save();
await syncQuizPollToFirebase(item);
return h.response(success(item, 'Closed'));
};
const setLiveQuestion = async (request, h) => {
const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null });
if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404);
const denied = await assertQuizPollAccess(request, h, item);
if (denied) return denied;
if (displayModeOf(item) !== 'quiz_master') {
return h
.response(error('VALIDATION_ERROR', 'Per-question live control is only available in Quiz Master mode'))
.code(400);
}
const rawQuestionId = request.payload?.questionId;
item.settings = item.settings || {};
if (rawQuestionId == null || String(rawQuestionId).trim() === '') {
item.settings.liveQuestionId = null;
item.settings.showCorrectAnswer = false;
await item.save();
await syncQuizPollToFirebase(item);
return h.response(success(item, 'Live question cleared'));
}
const question = findQuestionById(item, rawQuestionId);
if (!question) {
return h.response(error('NOT_FOUND', 'Question not found on this quiz')).code(404);
}
const nextLiveId = questionIdString(question);
if (item.settings.liveQuestionId !== nextLiveId) {
item.settings.showCorrectAnswer = false;
}
item.settings.liveQuestionId = nextLiveId;
item.settings.isLive = true;
await item.save();
await syncQuizPollToFirebase(item);
return h.response(success(item, 'Question is now live'));
};
const setShowCorrect = async (request, h) => {
const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null });
if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404);
const denied = await assertQuizPollAccess(request, h, item);
if (denied) return denied;
if (displayModeOf(item) !== 'quiz_master') {
return h
.response(error('VALIDATION_ERROR', 'Show correct is only available in Quiz Master mode'))
.code(400);
}
item.settings = item.settings || {};
item.settings.showCorrectAnswer = Boolean(request.payload?.show);
await item.save();
await syncQuizPollToFirebase(item);
return h.response(success(item, item.settings.showCorrectAnswer ? 'Correct answer shown' : 'Correct answer hidden'));
};
const ensurePresentationToken = async (request, h) => {
const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null });
if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404);
const denied = await assertQuizPollAccess(request, h, item);
if (denied) return denied;
item.settings = item.settings || {};
if (!item.settings.presentationToken || String(item.settings.presentationToken).trim() === '') {
item.settings.presentationToken = QuizPoll.generatePresentationToken();
await item.save();
}
const token = String(item.settings.presentationToken).trim();
const presentationUrl = `${presentationPublicBase()}/quiz-present/${encodeURIComponent(token)}`;
return h.response(success({ presentationToken: token, presentationUrl }));
};
const results = async (request, h) => {
const item = await QuizPoll.findOne({ _id: request.params.id, deletedAt: null });
if (!item) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404);
@ -133,6 +251,6 @@ const sessionQuizPolls = async (request, h) => {
};
module.exports = {
list, create, get, update, remove, publish, close, results,
list, create, get, update, remove, publish, close, setLiveQuestion, setShowCorrect, ensurePresentationToken, results,
submitResponse, listResponses, sessionQuizPolls,
};

View File

@ -17,6 +17,8 @@ const { mergeRules } = require('../utils/rewardRulesResolve');
const { isObjectiveQuestionCorrect } = require('../utils/quizLoyaltyScore');
const { awardIfNew } = require('../services/rewardAward');
const { computeEventRatingsSummary } = require('../utils/eventRatingsSummary');
const { buildVisitorPollResultsSummary } = require('../utils/quizPollResultsSummary');
const { ensureBuiltinVisitorForm } = require('../utils/visitorBuiltinForms');
/** All event fields for JSON except website, timestamps; adds logoUrl from website.logo || image. Never expose inviteCode. */
function publicEventLoginPayload(doc) {
@ -304,10 +306,14 @@ function publicSessionPreview(session) {
moderator,
audienceFeedbackEnabled: session.audienceFeedbackEnabled !== false,
audienceRatingsEnabled: session.audienceRatingsEnabled !== false,
audienceKeyPersonsEnabled: session.audienceKeyPersonsEnabled !== false,
audienceMySessionsEnabled: session.audienceMySessionsEnabled !== false,
audienceAgendaEnabled: session.audienceAgendaEnabled !== false,
audienceFeedbackForm:
session.audienceFeedbackForm != null ? String(session.audienceFeedbackForm) : null,
audienceRatingsForm:
session.audienceRatingsForm != null ? String(session.audienceRatingsForm) : null,
enableQnA: session.enableQnA !== false,
};
}
@ -372,6 +378,13 @@ function visitorQuestionAuthor(q) {
return 'Guest';
}
function sessionQnAEnabled(session) {
if (!session) return true;
if (session.enableQnA === false) return false;
if (session.qnaEnabled === false) return false;
return true;
}
function publicVisitorQuestion(q) {
return {
_id: String(q._id),
@ -394,6 +407,9 @@ async function listSessionQuestions(request, h) {
if (!session || String(session.event) !== String(eventId)) {
return h.response(error('NOT_FOUND', 'Session not found')).code(404);
}
if (!sessionQnAEnabled(session)) {
return h.response(success({ questions: [] }));
}
const rows = await Question.find({
session: sessionId,
event: eventId,
@ -418,6 +434,9 @@ async function createSessionQuestion(request, h) {
if (!session || String(session.event) !== String(eventId)) {
return h.response(error('NOT_FOUND', 'Session not found')).code(404);
}
if (!sessionQnAEnabled(session)) {
return h.response(error('FORBIDDEN', 'Q&A is not enabled for this session')).code(403);
}
let participantId;
const auth = request.auth;
@ -524,6 +543,9 @@ async function voteSessionQuestion(request, h) {
if (!session || String(session.event) !== String(eventId)) {
return h.response(error('NOT_FOUND', 'Session not found')).code(404);
}
if (!sessionQnAEnabled(session)) {
return h.response(error('FORBIDDEN', 'Q&A is not enabled for this session')).code(403);
}
const question = await Question.findOne({
_id: questionId,
@ -586,6 +608,26 @@ async function voteSessionQuestion(request, h) {
);
}
function enrichResponseItems(items, timeSpent) {
const now = new Date();
const list = items || [];
const fallbackSec = Math.max(0, Number(timeSpent) || 0);
return list.map((item) => {
const raw = item || {};
const fromItem =
raw.timeSpent != null && Number.isFinite(Number(raw.timeSpent))
? Math.max(0, Math.floor(Number(raw.timeSpent)))
: null;
const sec = fromItem != null ? fromItem : list.length === 1 ? fallbackSec : 0;
const { timeSpent: _drop, ...rest } = raw;
return {
...rest,
answeredAt: now,
responseTimeSec: sec,
};
});
}
/** POST …/visitor/events/{eventId}/quiz-polls/{quizPollId}/responses — attendee JWT */
async function submitVisitorQuizPollResponse(request, h) {
const { eventId, quizPollId } = request.params;
@ -605,6 +647,18 @@ async function submitVisitorQuizPollResponse(request, h) {
}
const settings = quizPoll.settings || {};
const displayMode = String(settings.displayMode || '').trim();
if (displayMode === 'quiz_master') {
return submitVisitorQuizMasterQuestionResponse(request, h, {
eventId,
quizPollId,
participant,
quizPoll,
settings,
});
}
if (settings.allowMultipleAttempts === false) {
const existing = await QuizPollResponse.findOne({
quizPoll: quizPollId,
@ -618,11 +672,109 @@ async function submitVisitorQuizPollResponse(request, h) {
const doc = await QuizPollResponse.create({
quizPoll: quizPollId,
participant: participant._id,
responses: request.payload.responses || [],
responses: enrichResponseItems(request.payload.responses || [], request.payload.timeSpent),
totalScore: request.payload.totalScore,
timeSpent: request.payload.timeSpent,
});
const pointsEarned = await awardVisitorQuizPollResponse({
eventId,
quizPollId,
quizPoll,
participantId: participant._id,
responseId: String(doc._id),
responses: request.payload.responses || [],
isQuizMasterPartial: false,
});
const responsePayload = {
...(doc.toObject ? doc.toObject() : doc),
...(pointsEarned > 0 ? { reward: { pointsEarned } } : {}),
};
return h.response(success(responsePayload, 'Response submitted', 201)).code(201);
}
async function submitVisitorQuizMasterQuestionResponse(request, h, ctx) {
const { eventId, quizPollId, participant, quizPoll, settings } = ctx;
const payloadResponses = request.payload.responses || [];
if (payloadResponses.length !== 1) {
return h
.response(error('VALIDATION_ERROR', 'Quiz Master mode accepts exactly one question response per submit'))
.code(400);
}
const liveQuestionId = settings.liveQuestionId != null ? String(settings.liveQuestionId).trim() : '';
if (!liveQuestionId) {
return h.response(error('FORBIDDEN', 'No question is currently live for this quiz')).code(403);
}
const questions = quizPoll.questions || [];
const liveIndex = questions.findIndex((q) => q._id != null && String(q._id) === liveQuestionId);
if (liveIndex < 0) {
return h.response(error('FORBIDDEN', 'The live question is no longer available')).code(403);
}
const item = enrichResponseItems(payloadResponses, request.payload.timeSpent)[0];
const questionIndex = Number(item.questionIndex);
if (!Number.isFinite(questionIndex) || questionIndex !== liveIndex) {
return h.response(error('VALIDATION_ERROR', 'Response must be for the currently live question')).code(400);
}
let doc = await QuizPollResponse.findOne({
quizPoll: quizPollId,
participant: participant._id,
});
if (doc) {
const already = (doc.responses || []).some((r) => Number(r.questionIndex) === questionIndex);
if (already) {
const responsePayload = doc.toObject ? doc.toObject() : doc;
return h.response(success(responsePayload, 'Response already recorded'));
}
doc.responses = [...(doc.responses || []), item];
const partialScore = (doc.responses || []).reduce((sum, r) => sum + (Number(r.pointsEarned) || 0), 0);
doc.totalScore = partialScore;
doc.timeSpent = (Number(doc.timeSpent) || 0) + (Number(request.payload.timeSpent) || 0);
await doc.save();
} else {
doc = await QuizPollResponse.create({
quizPoll: quizPollId,
participant: participant._id,
responses: [item],
totalScore: Number(item.pointsEarned) || 0,
timeSpent: request.payload.timeSpent,
});
}
const pointsEarned = await awardVisitorQuizPollResponse({
eventId,
quizPollId,
quizPoll,
participantId: participant._id,
responseId: String(doc._id),
responses: [item],
isQuizMasterPartial: true,
questionIndex,
});
const responsePayload = {
...(doc.toObject ? doc.toObject() : doc),
...(pointsEarned > 0 ? { reward: { pointsEarned } } : {}),
};
return h.response(success(responsePayload, 'Response submitted', 201)).code(201);
}
async function awardVisitorQuizPollResponse({
eventId,
quizPollId,
quizPoll,
participantId,
responseId,
responses,
isQuizMasterPartial,
questionIndex,
}) {
const eventLean = await Event.findById(eventId).select('rewardRules').lean();
let sessionRules = {};
if (quizPoll.session) {
@ -631,29 +783,33 @@ async function submitVisitorQuizPollResponse(request, h) {
}
const quizRules = (quizPoll.settings && quizPoll.settings.rewardRules) || {};
const rules = mergeRules(eventLean?.rewardRules || {}, sessionRules, quizRules);
const responseId = String(doc._id);
const typeLower = String(quizPoll.type || '').toLowerCase();
let pointsEarned = 0;
if (typeLower === 'quiz') {
const arSubmit = await awardIfNew({
participantId: participant._id,
eventId,
points: rules.quizPollSubmitted,
source: 'quiz_poll',
sourceId: `quiz_poll_submit:${quizPollId}:${responseId}`,
description: 'Quiz submission',
metadata: { ruleSnapshot: rules, quizPollId: String(quizPollId) },
});
pointsEarned += arSubmit.pointsAwarded || 0;
if (!isQuizMasterPartial) {
const arSubmit = await awardIfNew({
participantId,
eventId,
points: rules.quizPollSubmitted,
source: 'quiz_poll',
sourceId: `quiz_poll_submit:${quizPollId}:${responseId}`,
description: 'Quiz submission',
metadata: { ruleSnapshot: rules, quizPollId: String(quizPollId) },
});
pointsEarned += arSubmit.pointsAwarded || 0;
}
const perCorrect = rules.quizCorrectPerQuestion;
if (perCorrect > 0) {
const questions = quizPoll.questions || [];
for (let i = 0; i < questions.length; i++) {
const item = (request.payload.responses || []).find((r) => Number(r.questionIndex) === i);
if (!isObjectiveQuestionCorrect(questions[i], item)) continue;
const indices = isQuizMasterPartial
? [questionIndex]
: questions.map((_, i) => i);
for (const i of indices) {
const respItem = responses.find((r) => Number(r.questionIndex) === i);
if (!isObjectiveQuestionCorrect(questions[i], respItem)) continue;
const arCor = await awardIfNew({
participantId: participant._id,
participantId,
eventId,
points: perCorrect,
source: 'quiz_poll',
@ -666,7 +822,7 @@ async function submitVisitorQuizPollResponse(request, h) {
}
} else if (typeLower === 'poll' || typeLower === 'survey') {
const arPoll = await awardIfNew({
participantId: participant._id,
participantId,
eventId,
points: rules.pollParticipation,
source: 'poll',
@ -676,112 +832,7 @@ async function submitVisitorQuizPollResponse(request, h) {
});
pointsEarned += arPoll.pointsAwarded || 0;
}
const responsePayload = {
...(doc.toObject ? doc.toObject() : doc),
...(pointsEarned > 0 ? { reward: { pointsEarned } } : {}),
};
return h.response(success(responsePayload, 'Response submitted', 201)).code(201);
}
function normAnswerVal(ans) {
if (ans == null) return '';
if (typeof ans === 'object' && ans !== null && typeof ans.toString === 'function') {
return String(ans.toString());
}
return String(ans);
}
/**
* Aggregate all stored responses for poll/survey questions (percent = share of respondents).
* @param {object} quizPoll lean doc
* @param {object[]} allResponses QuizPollResponse lean docs
*/
function buildVisitorPollResultsSummary(quizPoll, allResponses) {
const questions = quizPoll.questions || [];
const totalVoters = allResponses.length;
return questions.map((q, questionIndex) => {
const qType = String(q.type || 'single').toLowerCase();
const base = {
questionIndex,
questionId: q._id != null ? String(q._id) : '',
type: q.type || 'single',
question: q.question != null ? q.question : '',
totalResponses: totalVoters,
};
const countForOptionBuckets = (buckets) => {
for (const doc of allResponses) {
const item = (doc.responses || []).find((r) => Number(r.questionIndex) === questionIndex);
if (!item) continue;
const id = normAnswerVal(item.answer);
const b = buckets.find((x) => x.optionId === id);
if (b) b.count += 1;
}
buckets.forEach((b) => {
b.percent = totalVoters > 0 ? Math.round((b.count / totalVoters) * 1000) / 10 : 0;
});
return buckets;
};
if (qType === 'text') {
let answered = 0;
for (const doc of allResponses) {
const item = (doc.responses || []).find((r) => Number(r.questionIndex) === questionIndex);
if (!item) continue;
const t = normAnswerVal(item.answer).trim();
if (t.length > 0) answered += 1;
}
return {
...base,
kind: 'text',
answeredCount: answered,
percentAnswered: totalVoters > 0 ? Math.round((answered / totalVoters) * 1000) / 10 : 0,
};
}
if (qType === 'rating') {
const buckets = [1, 2, 3, 4, 5].map((n) => ({
optionId: String(n),
text: String(n),
count: 0,
percent: 0,
}));
countForOptionBuckets(buckets);
return { ...base, kind: 'rating', options: buckets };
}
if (qType === 'multiple') {
const opts = (q.options || []).map((o) => ({
optionId: o._id != null ? String(o._id) : '',
text: o.text != null ? o.text : '',
count: 0,
percent: 0,
}));
for (const doc of allResponses) {
const item = (doc.responses || []).find((r) => Number(r.questionIndex) === questionIndex);
if (!item || !Array.isArray(item.answer)) continue;
for (const rawId of item.answer) {
const id = normAnswerVal(rawId);
const o = opts.find((x) => x.optionId === id);
if (o) o.count += 1;
}
}
opts.forEach((o) => {
o.percent = totalVoters > 0 ? Math.round((o.count / totalVoters) * 1000) / 10 : 0;
});
return { ...base, kind: 'multiple', options: opts };
}
const opts = (q.options || []).map((o) => ({
optionId: o._id != null ? String(o._id) : '',
text: o.text != null ? o.text : '',
count: 0,
percent: 0,
}));
countForOptionBuckets(opts);
return { ...base, kind: 'single', options: opts };
});
return pointsEarned;
}
/** GET …/visitor/events/{eventId}/quiz-polls/{quizPollId}/results-summary — poll/survey aggregates; attendee JWT */
@ -1023,26 +1074,7 @@ async function visitorEventRewardHistory(request, h) {
}
/** GET …/visitor/events/{eventId}/feedback-forms/{formId} — public schema for rendering (org must match event). */
async function visitorFeedbackFormPreview(request, h) {
const { eventId, formId } = request.params;
if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(formId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400);
}
const [event, form] = await Promise.all([
Event.findById(eventId).select('organization').lean(),
Form.findById(formId).select('tenantOrganization type name description fields settings').lean(),
]);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404);
if (form.type !== 'feedback') {
return h.response(error('VALIDATION_ERROR', 'Form is not a feedback form')).code(400);
}
if (String(form.tenantOrganization) !== String(event.organization)) {
return h.response(error('FORBIDDEN', 'Form is not available for this event')).code(403);
}
if (!form.settings?.isActive) {
return h.response(error('VALIDATION_ERROR', 'Form is not accepting responses')).code(400);
}
function visitorFeedbackFormPayload(form) {
const fields = (form.fields || []).map((f) => ({
label: f.label,
type: f.type,
@ -1050,14 +1082,126 @@ async function visitorFeedbackFormPreview(request, h) {
options: Array.isArray(f.options) ? f.options.map((o) => String(o)) : [],
placeholder: f.placeholder ? String(f.placeholder) : '',
}));
return {
_id: String(form._id),
name: form.name,
description: form.description || '',
fields,
thankYouMessage: form.settings?.thankYouMessage || '',
showThankYou: form.settings?.showThankYou !== false,
};
}
function isVisitorFeedbackFormEligible(form, event) {
if (!form || !event) return false;
if (form.type !== 'feedback' && form.type !== 'survey') return false;
if (form.settings?.isActive === false) return false;
if (String(form.tenantOrganization) !== String(event.organization)) return false;
if (form.event != null && String(form.event) !== String(event._id)) return false;
return true;
}
async function findVisitorFeedbackFormForEvent(event, { sessionId, purpose = 'feedback' } = {}) {
const eventId = String(event._id);
const sessionFormKey = purpose === 'ratings' ? 'audienceRatingsForm' : 'audienceFeedbackForm';
if (sessionId && mongoose.Types.ObjectId.isValid(sessionId)) {
const session = await Session.findById(sessionId)
.select('event audienceFeedbackForm audienceRatingsForm')
.lean();
if (session && String(session.event) === eventId) {
const linkedId = session[sessionFormKey];
if (linkedId) {
const linked = await Form.findById(String(linkedId))
.select('tenantOrganization event type name description fields settings')
.lean();
if (isVisitorFeedbackFormEligible(linked, event)) return linked;
return null;
}
return ensureBuiltinVisitorForm(event, purpose);
}
}
return ensureBuiltinVisitorForm(event, purpose);
}
async function visitorFeedbackFormPreview(request, h) {
const { eventId, formId } = request.params;
if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(formId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400);
}
const [event, form] = await Promise.all([
Event.findById(eventId).select('organization').lean(),
Form.findById(formId).select('tenantOrganization event type name description fields settings').lean(),
]);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404);
if (!isVisitorFeedbackFormEligible(form, event)) {
if (form.type !== 'feedback' && form.type !== 'survey') {
return h.response(error('VALIDATION_ERROR', 'Form is not a feedback form')).code(400);
}
if (form.settings?.isActive === false) {
return h.response(error('VALIDATION_ERROR', 'Form is not accepting responses')).code(400);
}
return h.response(error('FORBIDDEN', 'Form is not available for this event')).code(403);
}
return h.response(success(visitorFeedbackFormPayload(form)));
}
/** GET …/feedback-forms/resolve — pick session-linked or default active feedback/survey form. */
async function visitorFeedbackFormResolve(request, h) {
const { eventId } = request.params;
const sessionId = request.query.session_id?.trim() || '';
const purpose = request.query.purpose === 'ratings' ? 'ratings' : 'feedback';
if (!mongoose.Types.ObjectId.isValid(eventId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400);
}
const event = await Event.findById(eventId).select('organization').lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const form = await findVisitorFeedbackFormForEvent(event, { sessionId, purpose });
if (!form) {
return h.response(error('NOT_FOUND', 'No feedback form is configured for this event')).code(404);
}
return h.response(success(visitorFeedbackFormPayload(form)));
}
async function getVisitorQuizPollMyResponse(request, h) {
const { eventId, quizPollId } = request.params;
const participant = request.auth.credentials.participant;
if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(quizPollId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400);
}
if (!participantInEvent(participant, eventId)) {
return h.response(error('FORBIDDEN', 'Event mismatch')).code(403);
}
const quizPoll = await QuizPoll.findOne({ _id: quizPollId, deletedAt: null }).lean();
if (!quizPoll) return h.response(error('NOT_FOUND', 'Quiz/Poll not found')).code(404);
if (String(quizPoll.event) !== String(eventId)) {
return h.response(error('FORBIDDEN', 'This poll does not belong to this event')).code(403);
}
const doc = await QuizPollResponse.findOne({
quizPoll: quizPollId,
participant: participant._id,
}).lean();
if (!doc) {
return h.response(success({ hasResponse: false, answeredQuestionIndices: [] }));
}
const answeredQuestionIndices = (doc.responses || [])
.map((r) => Number(r.questionIndex))
.filter((n) => Number.isFinite(n));
return h.response(
success({
_id: String(form._id),
name: form.name,
description: form.description || '',
fields,
thankYouMessage: form.settings?.thankYouMessage || '',
showThankYou: form.settings?.showThankYou !== false,
hasResponse: true,
answeredQuestionIndices,
totalScore: doc.totalScore,
}),
);
}
@ -1094,6 +1238,7 @@ module.exports = {
createSessionQuestion,
voteSessionQuestion,
submitVisitorQuizPollResponse,
getVisitorQuizPollMyResponse,
getVisitorQuizPollResultsSummary,
listVisitorSessionBookmarks,
putVisitorSessionBookmark,
@ -1102,4 +1247,5 @@ module.exports = {
visitorEventLeaderboard,
submitMicrositeContact,
visitorFeedbackFormPreview,
visitorFeedbackFormResolve,
};

View File

@ -105,6 +105,11 @@ function authTypeFromEvent(event) {
return 'otp_email';
}
function registrationRequiresEmail(event) {
const base = String(getSettings(event).registrationBaseFields || 'name_email');
return ['name_email', 'email_only', 'name_email_phone'].includes(base);
}
async function assertCapacity(event) {
const max = getSettings(event).maxParticipants;
if (!max || max <= 0) return { ok: true };
@ -521,7 +526,7 @@ async function exchangeLink(request, h) {
async function guestStart(request, h) {
const { eventId } = request.params;
const { joinType = 'event', sessionId, trackId, inviteToken, guestToken, displayName } =
const { joinType = 'event', sessionId, trackId, inviteToken, guestToken, displayName, email } =
request.payload || {};
const event = await loadEvent(eventId);
@ -531,6 +536,80 @@ async function guestStart(request, h) {
}
const orgId = event.organization;
const requiresEmail = registrationRequiresEmail(event);
const em = normalizeEmail(email);
if (requiresEmail) {
if (!em) {
return h.response(error('VALIDATION_ERROR', 'Email is required')).code(400);
}
let participant = await Participant.findOne({ tenantOrganization: orgId, email: em });
const inEvent = participant && getMembership(participant, eventId);
if (inEvent) {
const out = authPayload(
participant,
event,
eventId,
joinType,
oidStr(sessionId),
oidStr(trackId)
);
const m = getMembership(participant, eventId);
return h.response(success({ ...out, guestToken: m?.guestToken }));
}
if (!allowsNewRegistration(event)) {
return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403);
}
const inv = checkInviteForNewReg(event, inviteToken);
if (!inv.ok) return h.response(inv.err).code(403);
const cap = await assertCapacity(event);
if (!cap.ok) return h.response(cap.err).code(403);
const status = defaultParticipantStatus(event);
let registrationRewardPoints = 0;
if (!participant) {
participant = await Participant.create({
tenantOrganization: orgId,
email: em,
displayName: displayName ? String(displayName).trim() : undefined,
emailVerified: false,
eventMemberships: [
{
event: eventId,
status,
registrationDate: new Date(),
},
],
});
registrationRewardPoints = await awardRegistrationIfNew(participant, eventId, event);
} else {
participant.eventMemberships.push({
event: eventId,
status,
registrationDate: new Date(),
});
if (displayName) participant.displayName = String(displayName).trim();
await participant.save();
registrationRewardPoints = await awardRegistrationIfNew(participant, eventId, event);
}
const out = authPayload(
participant,
event,
eventId,
joinType,
oidStr(sessionId),
oidStr(trackId)
);
const payload =
registrationRewardPoints > 0
? { ...out, reward: { pointsEarned: registrationRewardPoints } }
: out;
return h.response(success(payload));
}
const gt = String(guestToken || '').trim();
if (gt) {
const existing = await Participant.findOne(membershipGuestQuery(orgId, eventId, gt));

View File

@ -1,5 +1,44 @@
const mongoose = require('mongoose');
const badgeLayoutElementSchema = new mongoose.Schema(
{
id: { type: String, required: true },
kind: { type: String, enum: ['field', 'custom_text', 'custom_image'], required: true },
field: { type: String },
text: { type: String },
imageUrl: { type: String },
imageAspectRatio: { type: Number },
x: { type: Number, required: true },
y: { type: Number, required: true },
w: { type: Number },
h: { type: Number },
fontSizePct: { type: Number },
fontFamily: { type: String },
color: { type: String },
fontWeight: { type: String, enum: ['normal', 'bold'] },
fullWidthCenter: { type: Boolean },
shrinkToFit: { type: Boolean },
textAlign: { type: String, enum: ['left', 'center', 'right'] },
},
{ _id: false },
);
const badgeCustomTextSchema = new mongoose.Schema(
{
id: { type: String, required: true },
text: { type: String, default: '' },
},
{ _id: false },
);
const badgeCustomImageSchema = new mongoose.Schema(
{
id: { type: String, required: true },
imageUrl: { type: String, default: '' },
},
{ _id: false },
);
const badgeConfigSchema = new mongoose.Schema(
{
sizePreset: { type: String, default: 'a6' },
@ -14,6 +53,9 @@ const badgeConfigSchema = new mongoose.Schema(
backgroundImageUrl: { type: String, default: '' },
backgroundColor: { type: String, default: '#0B4DD1' },
fields: [{ type: String }],
layout: [badgeLayoutElementSchema],
customTexts: [badgeCustomTextSchema],
customImages: [badgeCustomImageSchema],
outputFormat: { type: String, enum: ['pdf', 'png', 'jpeg'], required: true },
/** Display label for badge type tag when field badge_type is enabled */
badgeTypeLabel: { type: String, default: 'Attendee' },
@ -27,7 +69,6 @@ const badgeBatchSchema = new mongoose.Schema(
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
index: true,
},
event: {
type: mongoose.Schema.Types.ObjectId,

View File

@ -6,7 +6,6 @@ const conciergeBotSchema = new mongoose.Schema(
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
index: true,
},
event: {
type: mongoose.Schema.Types.ObjectId,

View File

@ -6,7 +6,6 @@ const contentCollectionSchema = new mongoose.Schema(
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
index: true,
},
name: { type: String, required: true },
description: { type: String, default: '' },

View File

@ -15,7 +15,6 @@ const eventMicrositeSchema = new mongoose.Schema(
type: String,
trim: true,
lowercase: true,
sparse: true,
},
enabled: { type: Boolean, default: false },
logo: String,

View File

@ -4,7 +4,7 @@ const formFieldSchema = new mongoose.Schema({
label: String,
type: {
type: String,
enum: ['text', 'email', 'phone', 'select', 'stars', 'radio', 'checkbox', 'checkboxes', 'file'],
enum: ['text', 'textarea', 'email', 'phone', 'select', 'stars', 'radio', 'checkbox', 'checkboxes', 'file'],
},
required: Boolean,
options: [String],
@ -21,7 +21,6 @@ const formSchema = new mongoose.Schema(
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
index: true,
},
event: {
type: mongoose.Schema.Types.ObjectId,

View File

@ -11,7 +11,6 @@ const formSubmissionSchema = new mongoose.Schema(
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
index: true,
},
participant: { type: mongoose.Schema.Types.ObjectId, ref: 'Participant' },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },

View File

@ -42,7 +42,5 @@ const organizationPaymentProfileSchema = new mongoose.Schema(
{ timestamps: true }
);
organizationPaymentProfileSchema.index({ organization: 1 });
module.exports = mongoose.model('OrganizationPaymentProfile', organizationPaymentProfileSchema);
module.exports.GATEWAY_PROVIDERS = GATEWAY_PROVIDERS;

View File

@ -1,4 +1,5 @@
const mongoose = require('mongoose');
const crypto = require('crypto');
const { rewardRulesSubSchema } = require('./rewardRulesSchema');
const questionOptionSchema = new mongoose.Schema({
@ -18,6 +19,8 @@ const quizQuestionSchema = new mongoose.Schema({
options: [questionOptionSchema],
points: { type: Number, default: 1 },
required: { type: Boolean, default: true },
/** Per-question seconds (quiz master + time restricted) */
timeLimit: { type: Number, default: 0, min: 0 },
});
const quizPollSchema = new mongoose.Schema(
@ -49,6 +52,12 @@ const quizPollSchema = new mongoose.Schema(
randomizeQuestions: { type: Boolean, default: false },
timeRestricted: { type: Boolean, default: false },
timeLimit: { type: Number, default: 0, min: 0 },
/** quiz_master: currently live question _id (at most one) */
liveQuestionId: { type: String, default: null },
/** quiz_master: projector page highlights correct answer(s) for the live question */
showCorrectAnswer: { type: Boolean, default: false },
/** Unguessable token for login-free presentation / projector page */
presentationToken: { type: String, trim: true },
rewardRules: rewardRulesSubSchema,
},
deletedAt: { type: Date, default: null },
@ -59,4 +68,10 @@ const quizPollSchema = new mongoose.Schema(
quizPollSchema.index({ event: 1 });
quizPollSchema.index({ session: 1 });
quizPollSchema.index({ deletedAt: 1 });
quizPollSchema.index({ 'settings.presentationToken': 1 }, { sparse: true });
quizPollSchema.statics.generatePresentationToken = function generatePresentationToken() {
return crypto.randomBytes(32).toString('hex');
};
module.exports = mongoose.model('QuizPoll', quizPollSchema);

View File

@ -5,6 +5,8 @@ const responseItemSchema = new mongoose.Schema({
answer: mongoose.Schema.Types.Mixed,
isCorrect: Boolean,
pointsEarned: Number,
answeredAt: Date,
responseTimeSec: Number,
});
const quizPollResponseSchema = new mongoose.Schema(

View File

@ -42,6 +42,12 @@ const sessionSchema = new mongoose.Schema(
audienceFeedbackEnabled: { type: Boolean, default: true },
/** When true (default), session page can surface ratings if the event has ratings enabled */
audienceRatingsEnabled: { type: Boolean, default: true },
/** When true (default), show Key Persons tab on attendee session page */
audienceKeyPersonsEnabled: { type: Boolean, default: true },
/** When true (default), show My Sessions tab on attendee session page */
audienceMySessionsEnabled: { type: Boolean, default: true },
/** When true (default), show Agenda tab on attendee session page */
audienceAgendaEnabled: { type: Boolean, default: true },
/** Dashboard: show Questions moderation (activity sessions default from activitySubtype). */
enableQnA: { type: Boolean, default: true },
/** Dashboard: show Quiz & Poll management (activity sessions default from activitySubtype). */

View File

@ -6,7 +6,6 @@ const speakerSchema = new mongoose.Schema(
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
index: true,
},
firstName: { type: String, required: true },
lastName: { type: String, required: true },

View File

@ -4,6 +4,26 @@ const { requireFeaturePre } = require('../services/featureAccess');
const FIELD_IDS = ['name', 'title', 'org', 'badge_type', 'qr', 'photo'];
const layoutElementSchema = Joi.object({
id: Joi.string().required(),
kind: Joi.string().valid('field', 'custom_text', 'custom_image').required(),
field: Joi.string().valid(...FIELD_IDS),
text: Joi.string().allow(''),
imageUrl: Joi.string().allow(''),
imageAspectRatio: Joi.number().positive().max(20),
x: Joi.number().min(0).max(100).required(),
y: Joi.number().min(0).max(100).required(),
w: Joi.number().min(0).max(100),
h: Joi.number().min(0).max(100),
fontSizePct: Joi.number().min(1).max(20),
fontFamily: Joi.string().max(64),
color: Joi.string().pattern(/^#[0-9A-Fa-f]{6}$/),
fontWeight: Joi.string().valid('normal', 'bold'),
fullWidthCenter: Joi.boolean(),
shrinkToFit: Joi.boolean(),
textAlign: Joi.string().valid('left', 'center', 'right'),
});
const createPayload = Joi.object({
name: Joi.string().required().trim(),
organization: Joi.string().hex().length(24),
@ -19,9 +39,33 @@ const createPayload = Joi.object({
backgroundMode: Joi.string().valid('image', 'color').required(),
backgroundImageUrl: Joi.string().allow(''),
backgroundColor: Joi.string().pattern(/^#[0-9A-Fa-f]{6}$/).default('#0B4DD1'),
fields: Joi.array().items(Joi.string().valid(...FIELD_IDS)).min(1).required(),
fields: Joi.array().items(Joi.string().valid(...FIELD_IDS)).default([]),
layout: Joi.array().items(layoutElementSchema),
customTexts: Joi.array().items(
Joi.object({
id: Joi.string().required(),
text: Joi.string().allow('').max(500),
}),
),
customImages: Joi.array().items(
Joi.object({
id: Joi.string().required(),
imageUrl: Joi.string().allow('').max(2000),
}),
),
outputFormat: Joi.string().valid('pdf', 'png', 'jpeg').required(),
badgeTypeLabel: Joi.string().allow('').max(80),
}).custom((value, helpers) => {
const hasFields = Array.isArray(value.fields) && value.fields.length > 0;
const hasCustom =
Array.isArray(value.layout) &&
value.layout.some((el) => el.kind === 'custom_text' || el.kind === 'custom_image');
if (!hasFields && !hasCustom) {
return helpers.error('any.custom', {
message: 'Select at least one badge field, fixed text, or fixed image',
});
}
return value;
});
module.exports = [

View File

@ -25,14 +25,17 @@ const participantUpdatePayload = Joi.object({
const bulkPayload = Joi.object({
action: Joi.string()
.valid('add_tags', 'remove_tags', 'assign_events', 'add_to_fixed_list', 'delete')
.valid('add_tags', 'remove_tags', 'assign_events', 'add_to_fixed_list', 'delete', 'remove_from_event')
.required(),
participantIds: Joi.array().items(Joi.string()).default([]),
selectAllMatching: Joi.boolean().default(false),
q: Joi.string().allow(''),
tag: Joi.string().allow(''),
status: Joi.string().allow(''),
designation: Joi.string().allow(''),
tags: Joi.array().items(Joi.string()),
eventIds: Joi.array().items(Joi.string()),
eventId: Joi.string(),
listId: Joi.string(),
/** Tenant scope for select-all / super_admin (also accepts ?organization= on the request). */
organization: Joi.string(),
@ -59,6 +62,8 @@ module.exports = [
},
{ method: 'GET', path: '/api/events/{eventId}/participants/export', options: { handler: controller.exportCsv } },
{ method: 'POST', path: '/api/events/{eventId}/participants/import', options: { validate: { payload: importPayload }, handler: controller.importCsv } },
{ method: 'GET', path: '/api/events/{eventId}/participants/tags', options: { handler: controller.listEventDistinctTags } },
{ method: 'GET', path: '/api/events/{eventId}/participants/matching-ids', options: { handler: controller.listEventMatchingIds } },
{ method: 'GET', path: '/api/events/{eventId}/participants', options: { handler: controller.list } },
{ method: 'POST', path: '/api/events/{eventId}/participants', options: { validate: { payload: participantPayload }, handler: controller.create } },
{ method: 'GET', path: '/api/participants/tags', options: { handler: controller.listDistinctTags } },

View File

@ -0,0 +1,36 @@
const Joi = require('joi');
const controller = require('../controllers/publicMicrosite.controller');
const mongoId = Joi.string().length(24).hex().required();
module.exports = [
{
method: 'GET',
path: '/api/public/events/{eventId}/microsite',
options: {
auth: false,
validate: { params: Joi.object({ eventId: mongoId }) },
handler: controller.getPublicMicrosite,
},
},
{
method: 'GET',
path: '/api/public/events/{eventId}/registration',
options: {
auth: false,
validate: { params: Joi.object({ eventId: mongoId }) },
handler: controller.getPublicRegistration,
},
},
{
method: 'GET',
path: '/api/public/events/{eventId}/forms/{formId}',
options: {
auth: false,
validate: {
params: Joi.object({ eventId: mongoId, formId: mongoId }),
},
handler: controller.getPublicEventForm,
},
},
];

View File

@ -0,0 +1,14 @@
const controller = require('../controllers/publicQuizPoll.controller');
module.exports = [
{
method: 'GET',
path: '/api/public/quiz-polls/present/{token}',
options: { auth: false, handler: controller.getPresentation },
},
{
method: 'GET',
path: '/api/public/quiz-polls/present/{token}/questions/{questionIndex}/stats',
options: { auth: false, handler: controller.getQuestionStats },
},
];

View File

@ -6,7 +6,8 @@ const questionOptionSchema = Joi.object({
text: Joi.string().allow(''),
isCorrect: Joi.boolean(),
imageUrl: Joi.string().allow('', null),
});
}).options({ stripUnknown: true });
const quizQuestionSchema = Joi.object({
question: Joi.string().required(),
imageUrl: Joi.string().allow('', null),
@ -14,7 +15,8 @@ const quizQuestionSchema = Joi.object({
options: Joi.array().items(questionOptionSchema),
points: Joi.number(),
required: Joi.boolean(),
});
timeLimit: Joi.number().integer().min(0),
}).options({ stripUnknown: true });
const rewardRulesPayload = Joi.object({
registrationComplete: Joi.number().min(0).allow(null),
questionAsk: Joi.number().min(0).allow(null),
@ -44,9 +46,12 @@ const quizPollPayload = Joi.object({
randomizeQuestions: Joi.boolean(),
timeRestricted: Joi.boolean(),
timeLimit: Joi.number().integer().min(0),
liveQuestionId: Joi.string().allow('', null),
showCorrectAnswer: Joi.boolean(),
presentationToken: Joi.string().allow('', null),
rewardRules: rewardRulesPayload,
}),
});
}).options({ stripUnknown: true });
const responsePayload = Joi.object({
participantId: Joi.string(),
@ -60,6 +65,14 @@ const responsePayload = Joi.object({
timeSpent: Joi.number(),
});
const liveQuestionPayload = Joi.object({
questionId: Joi.string().allow(null),
});
const showCorrectPayload = Joi.object({
show: Joi.boolean().required(),
});
module.exports = [
{ method: 'GET', path: '/api/events/{eventId}/quiz-polls', options: { handler: controller.list } },
{ method: 'POST', path: '/api/events/{eventId}/quiz-polls', options: { validate: { payload: quizPollPayload }, handler: controller.create } },
@ -68,6 +81,9 @@ module.exports = [
{ method: 'DELETE', path: '/api/quiz-polls/{id}', options: { handler: controller.remove } },
{ method: 'POST', path: '/api/quiz-polls/{id}/publish', options: { handler: controller.publish } },
{ method: 'POST', path: '/api/quiz-polls/{id}/close', options: { handler: controller.close } },
{ method: 'POST', path: '/api/quiz-polls/{id}/live-question', options: { validate: { payload: liveQuestionPayload }, handler: controller.setLiveQuestion } },
{ method: 'POST', path: '/api/quiz-polls/{id}/show-correct', options: { validate: { payload: showCorrectPayload }, handler: controller.setShowCorrect } },
{ method: 'POST', path: '/api/quiz-polls/{id}/presentation-token', options: { handler: controller.ensurePresentationToken } },
{ method: 'GET', path: '/api/quiz-polls/{id}/results', options: { handler: controller.results } },
{ method: 'POST', path: '/api/quiz-polls/{quizPollId}/responses', options: { validate: { payload: responsePayload }, handler: controller.submitResponse } },
{ method: 'GET', path: '/api/quiz-polls/{quizPollId}/responses', options: { handler: controller.listResponses } },

View File

@ -30,6 +30,9 @@ const sessionPayload = Joi.object({
rewardRules: rewardRulesPayload,
audienceFeedbackEnabled: Joi.boolean(),
audienceRatingsEnabled: Joi.boolean(),
audienceKeyPersonsEnabled: Joi.boolean(),
audienceMySessionsEnabled: Joi.boolean(),
audienceAgendaEnabled: Joi.boolean(),
audienceFeedbackForm: Joi.string().hex().length(24).allow(null, ''),
audienceRatingsForm: Joi.string().hex().length(24).allow(null, ''),
enableQnA: Joi.boolean(),

View File

@ -31,6 +31,7 @@ const guestPayload = Joi.object({
inviteToken: Joi.string().allow('', null),
guestToken: Joi.string().allow('', null),
displayName: Joi.string().allow('', null),
email: Joi.string().email().allow('', null),
});
const customFieldValueSchema = Joi.alternatives().try(
@ -56,6 +57,7 @@ const visitorQuizPollResponsePayload = Joi.object({
answer: Joi.any().allow(null),
isCorrect: Joi.boolean(),
pointsEarned: Joi.number(),
timeSpent: Joi.number().integer().min(0),
}),
)
.default([]),
@ -158,6 +160,23 @@ module.exports = [
handler: controller.trackPreview,
},
},
{
method: 'GET',
path: '/api/visitor/events/{eventId}/feedback-forms/resolve',
options: {
auth: false,
validate: {
params: Joi.object({
eventId: Joi.string().required(),
}),
query: Joi.object({
session_id: Joi.string().allow('', null),
purpose: Joi.string().valid('feedback', 'ratings').default('feedback'),
}),
},
handler: controller.visitorFeedbackFormResolve,
},
},
{
method: 'GET',
path: '/api/visitor/events/{eventId}/feedback-forms/{formId}',
@ -376,6 +395,20 @@ module.exports = [
handler: controller.submitVisitorQuizPollResponse,
},
},
{
method: 'GET',
path: '/api/visitor/events/{eventId}/quiz-polls/{quizPollId}/responses/me',
options: {
auth: 'jwt-attendee',
validate: {
params: Joi.object({
eventId: Joi.string().required(),
quizPollId: Joi.string().required(),
}),
},
handler: controller.getVisitorQuizPollMyResponse,
},
},
{
method: 'GET',
path: '/api/visitor/events/{eventId}/quiz-polls/{quizPollId}/results-summary',

View File

@ -0,0 +1,446 @@
const mongoose = require('mongoose');
const Participant = require('../models/Participant');
const Session = require('../models/Session');
const Track = require('../models/Track');
const QuizPoll = require('../models/QuizPoll');
const QuizPollResponse = require('../models/QuizPollResponse');
const RewardPoint = require('../models/RewardPoint');
const Form = require('../models/Form');
const FormSubmission = require('../models/FormSubmission');
const PaymentTransaction = require('../models/PaymentTransaction');
const Attendance = require('../models/Attendance');
const AttendanceParticipant = require('../models/AttendanceParticipant');
const { buildVisitorPollResultsSummary } = require('../utils/quizPollResultsSummary');
const STATUS_LABELS = {
pending: 'Pending',
confirmed: 'Confirmed',
checked_in: 'Checked in',
cancelled: 'Cancelled',
};
function startOfDay(d) {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function endOfDay(d) {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x;
}
function formatDayKey(d) {
return startOfDay(d).toISOString().slice(0, 10);
}
function formatDayLabel(isoDate, useWeek) {
const d = new Date(isoDate);
if (useWeek) {
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
/** Build daily or weekly buckets between event start and end (max ~60 points). */
function buildTrendBuckets(startDate, endDate) {
const start = startDate ? startOfDay(startDate) : startOfDay(new Date(Date.now() - 28 * 86400000));
const end = endDate ? endOfDay(endDate) : endOfDay(new Date());
const spanMs = Math.max(end.getTime() - start.getTime(), 86400000);
const useWeek = spanMs > 60 * 86400000;
const stepMs = useWeek ? 7 * 86400000 : 86400000;
const buckets = [];
let cursor = start.getTime();
const endMs = end.getTime();
while (cursor <= endMs && buckets.length < 60) {
const bucketStart = new Date(cursor);
const bucketEnd = new Date(Math.min(cursor + stepMs - 1, endMs));
buckets.push({
key: formatDayKey(bucketStart),
label: useWeek ? `Wk ${buckets.length + 1}` : formatDayLabel(bucketStart, false),
start: bucketStart,
end: bucketEnd,
});
cursor += stepMs;
}
if (!buckets.length) {
const now = new Date();
buckets.push({ key: formatDayKey(now), label: formatDayLabel(now, false), start: startOfDay(now), end: endOfDay(now) });
}
return buckets;
}
function countInBucket(dateVal, bucket) {
if (!dateVal) return false;
const t = new Date(dateVal).getTime();
return t >= bucket.start.getTime() && t <= bucket.end.getTime();
}
async function buildRegistrationTrend(event, participantBase) {
const members = await Participant.aggregate([
...participantBase,
{
$project: {
registrationDate: '$eventMemberships.registrationDate',
checkedInAt: '$eventMemberships.checkedInAt',
checkedIn: '$eventMemberships.checkedIn',
},
},
]);
const buckets = buildTrendBuckets(event.startDate, event.endDate);
return buckets.map((b) => {
let registrations = 0;
let checkIns = 0;
for (const m of members) {
if (countInBucket(m.registrationDate, b)) registrations += 1;
const checkDate = m.checkedInAt || (m.checkedIn ? m.registrationDate : null);
if (countInBucket(checkDate, b)) checkIns += 1;
}
return { date: b.label, registrations, checkIns };
});
}
async function buildQuizStats(eid) {
const items = await QuizPoll.find({ event: eid, deletedAt: null }).select('title type questions').lean();
const quizIds = items.filter((i) => i.type === 'quiz').map((i) => i._id);
if (!quizIds.length) return [];
const responseAgg = await QuizPollResponse.aggregate([
{ $match: { quizPoll: { $in: quizIds } } },
{
$group: {
_id: '$quizPoll',
responses: { $sum: 1 },
avgScore: { $avg: '$totalScore' },
},
},
]);
const byId = Object.fromEntries(responseAgg.map((r) => [String(r._id), r]));
return items
.filter((i) => i.type === 'quiz')
.map((q) => {
const agg = byId[String(q._id)];
return {
id: String(q._id),
title: q.title,
type: q.type,
responses: agg?.responses ?? 0,
avgScore: agg?.responses ? Math.round(agg.avgScore ?? 0) : null,
questionCount: (q.questions || []).length,
};
})
.sort((a, b) => b.responses - a.responses);
}
async function buildPollSummaries(eid) {
const polls = await QuizPoll.find({ event: eid, type: 'poll', deletedAt: null }).select('title questions').lean();
if (!polls.length) return [];
const pollIds = polls.map((p) => p._id);
const counts = await QuizPollResponse.aggregate([
{ $match: { quizPoll: { $in: pollIds } } },
{ $group: { _id: '$quizPoll', totalVotes: { $sum: 1 } } },
]);
const countByPoll = Object.fromEntries(counts.map((c) => [String(c._id), c.totalVotes]));
const sorted = polls
.map((p) => ({ ...p, totalVotes: countByPoll[String(p._id)] ?? 0 }))
.sort((a, b) => b.totalVotes - a.totalVotes)
.slice(0, 2);
const summaries = [];
for (const poll of sorted) {
const responses = await QuizPollResponse.find({ quizPoll: poll._id }).lean();
const questionStats = buildVisitorPollResultsSummary(poll, responses);
const first = questionStats.find((q) => q.options && q.options.length);
const options = (first?.options || []).map((o) => ({
label: o.text || o.optionId || 'Option',
count: o.count ?? 0,
pct: Math.round(o.percent ?? 0),
}));
summaries.push({
id: String(poll._id),
title: poll.title,
totalVotes: poll.totalVotes,
options,
});
}
return summaries;
}
async function buildParticipantBreakdown(participantBase, statusCounts) {
const tagAgg = await Participant.aggregate([
...participantBase,
{ $match: { tags: { $exists: true, $ne: [] } } },
{ $unwind: '$tags' },
{ $group: { _id: '$tags', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 12 },
]);
if (tagAgg.length) {
return {
source: 'tags',
breakdown: tagAgg.map((t) => ({ label: String(t._id), count: t.count })),
};
}
return {
source: 'status',
breakdown: Object.entries(statusCounts || {})
.filter(([, c]) => c > 0)
.map(([status, count]) => ({
label: STATUS_LABELS[status] || status,
count,
})),
};
}
async function buildRecentParticipants(participantBase) {
const rows = await Participant.aggregate([
...participantBase,
{
$project: {
firstName: 1,
lastName: 1,
email: 1,
status: '$eventMemberships.status',
checkedIn: '$eventMemberships.checkedIn',
checkedInAt: '$eventMemberships.checkedInAt',
registrationDate: '$eventMemberships.registrationDate',
},
},
{
$addFields: {
activityAt: {
$ifNull: ['$checkedInAt', '$registrationDate'],
},
},
},
{ $sort: { activityAt: -1 } },
{ $limit: 5 },
]);
return rows.map((r) => {
const name = [r.firstName, r.lastName].filter(Boolean).join(' ').trim() || r.email || 'Participant';
return {
id: String(r._id),
name,
status: r.status || 'pending',
checkedIn: Boolean(r.checkedIn),
activityAt: r.activityAt ? new Date(r.activityAt).toISOString() : new Date().toISOString(),
};
});
}
async function buildAttendanceSummary(eid) {
const attendances = await Attendance.find({ event: eid }).select('_id').lean();
let totalCheckedIn = 0;
let uniqueParticipants = 0;
for (const att of attendances) {
const rows = await AttendanceParticipant.find({ attendance: att._id }).select('checkIns').lean();
uniqueParticipants += rows.length;
for (const r of rows) {
totalCheckedIn += (r.checkIns || []).length;
}
}
return { totalCheckedIn, uniqueParticipants };
}
async function buildFormSubmissionTotal(eid) {
const forms = await Form.find({ event: eid }).select('_id').lean();
if (!forms.length) return 0;
const formIds = forms.map((f) => f._id);
const result = await FormSubmission.aggregate([
{ $match: { form: { $in: formIds } } },
{ $count: 'total' },
]);
return result[0]?.total ?? 0;
}
async function buildTicketRevenueMinor(eid) {
const result = await PaymentTransaction.aggregate([
{
$match: {
event: eid,
status: 'paid',
purpose: { $in: ['registration', 'ticket_purchase'] },
},
},
{ $group: { _id: null, total: { $sum: '$amountGross' } } },
]);
return Math.round(result[0]?.total ?? 0);
}
async function buildLeaderboardTop(eventId, limit = 6) {
const aggregated = await RewardPoint.aggregate([
{ $match: { event: eventId, type: { $in: ['earned', 'redeemed'] } } },
{
$group: {
_id: '$participant',
earned: { $sum: { $cond: [{ $eq: ['$type', 'earned'] }, '$points', 0] } },
redeemed: { $sum: { $cond: [{ $eq: ['$type', 'redeemed'] }, '$points', 0] } },
},
},
{ $project: { total: { $subtract: ['$earned', '$redeemed'] } } },
{ $match: { total: { $gt: 0 } } },
{ $sort: { total: -1 } },
{ $limit: limit },
{ $lookup: { from: 'participants', localField: '_id', foreignField: '_id', as: 'p' } },
{ $unwind: '$p' },
{
$project: {
participantId: '$_id',
total: 1,
firstName: '$p.firstName',
lastName: '$p.lastName',
email: '$p.email',
},
},
]);
return aggregated;
}
async function buildAgendaSummary(eid) {
const sessions = await Session.find({ event: eid, status: { $ne: 'cancelled' } })
.select('title startTime endTime type track speakers')
.sort({ startTime: 1 })
.lean();
const trackIds = [...new Set(sessions.map((s) => s.track).filter(Boolean).map(String))];
const tracks = trackIds.length
? await Track.find({ _id: { $in: trackIds } }).select('name').lean()
: [];
const trackNameById = Object.fromEntries(tracks.map((t) => [String(t._id), t.name || 'Track']));
const dayKeys = new Set(sessions.map((s) => formatDayKey(s.startTime)));
const speakerIds = new Set();
for (const s of sessions) {
for (const sp of s.speakers || []) speakerIds.add(String(sp));
}
const todayKey = formatDayKey(new Date());
const todaySessions = sessions
.filter((s) => formatDayKey(s.startTime) === todayKey)
.slice(0, 8)
.map((s) => ({
sessionId: String(s._id),
time: new Date(s.startTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }),
title: s.title,
track: s.track ? trackNameById[String(s.track)] || s.type || 'Session' : s.type || 'Session',
}));
return {
sessionCount: sessions.length,
dayCount: dayKeys.size,
speakerCount: speakerIds.size,
todaySessions,
};
}
/**
* Full analytics payload for GET /api/events/:id/analytics
*/
async function buildEventAnalytics(event) {
const orgId = event.organization;
const eid = event._id;
const participantBase = [
{ $match: { tenantOrganization: orgId } },
{ $unwind: '$eventMemberships' },
{ $match: { 'eventMemberships.event': eid } },
];
const [
statusAgg,
participantCountArr,
sessionCount,
upcomingSessionCount,
quizPollCount,
rewardByType,
registrationTrend,
quizStats,
pollSummaries,
recentParticipants,
attendanceSummary,
formSubmissionTotal,
ticketRevenueMinor,
leaderboardTop,
agendaSummary,
] = await Promise.all([
Participant.aggregate([...participantBase, { $group: { _id: '$eventMemberships.status', count: { $sum: 1 } } }]),
Participant.aggregate([...participantBase, { $count: 'total' }]),
Session.countDocuments({ event: eid }),
Session.countDocuments({
event: eid,
startTime: { $gt: new Date() },
status: { $ne: 'cancelled' },
}),
QuizPoll.countDocuments({ event: eid, deletedAt: null }),
RewardPoint.aggregate([
{ $match: { event: eid } },
{ $group: { _id: '$type', total: { $sum: '$points' } } },
]),
buildRegistrationTrend(event, participantBase),
buildQuizStats(eid),
buildPollSummaries(eid),
buildRecentParticipants(participantBase),
buildAttendanceSummary(eid),
buildFormSubmissionTotal(eid),
buildTicketRevenueMinor(eid),
buildLeaderboardTop(eid, 6),
buildAgendaSummary(eid),
]);
const statusCounts = statusAgg.reduce((acc, c) => {
acc[c._id] = c.count;
return acc;
}, {});
const participantCount = participantCountArr[0]?.total ?? 0;
const rewardMap = rewardByType.reduce((acc, r) => {
acc[r._id] = r.total;
return acc;
}, {});
const pointsAwarded = Math.round(rewardMap.earned || 0);
const pointsRedeemed = Math.round(rewardMap.redeemed || 0);
const { breakdown: participantBreakdown, source: participantBreakdownSource } =
await buildParticipantBreakdown(participantBase, statusCounts);
const meta =
event.metadata && typeof event.metadata.toObject === 'function'
? event.metadata.toObject()
: event.metadata || {};
const pollCount = await QuizPoll.countDocuments({ event: eid, type: 'poll', deletedAt: null });
const quizCount = await QuizPoll.countDocuments({ event: eid, type: 'quiz', deletedAt: null });
return {
...meta,
statusCounts,
participantCount,
sessionCount,
upcomingSessionCount,
quizPollCount,
quizCount,
pollCount,
pointsAwarded,
pointsRedeemed,
registrationTrend,
quizStats,
pollSummaries,
participantBreakdown,
participantBreakdownSource,
recentParticipants,
attendanceSummary,
formSubmissionTotal,
ticketRevenueMinor,
leaderboardTop,
agendaSummary,
};
}
module.exports = { buildEventAnalytics };

View File

@ -2,13 +2,42 @@ const { createCanvas, loadImage } = require('@napi-rs/canvas');
const QRCode = require('qrcode');
const sharp = require('sharp');
const { PDFDocument } = require('pdf-lib');
const { cloudfrontUrlForKey, getBufferFromS3Key } = require('./s3Media');
const PRINT_DPI = 200;
const AVATAR_COLORS = ['#2563eb', '#16a34a', '#d97706', '#7c3aed', '#0891b2', '#be185d', '#0d9488', '#b45309'];
function getEnv(name, fallback = '') {
const v = process.env[name];
if (v == null || String(v).trim() === '') return fallback;
return String(v).trim();
}
function mmToPx(mm) {
return Math.max(80, Math.round((mm * PRINT_DPI) / 25.4));
}
/** Mongoose subdocuments do not spread reliably — plain object required for layout/fields. */
function plainBadgeConfig(config) {
if (!config) return {};
if (typeof config.toObject === 'function') return config.toObject();
if (typeof config.toJSON === 'function') {
const j = config.toJSON();
return j && typeof j === 'object' ? j : {};
}
try {
return JSON.parse(JSON.stringify(config));
} catch {
return { ...config };
}
}
function layoutCoord(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
/**
* @param {object} config — BadgeBatch.config
* @returns {{ widthMm: number, heightMm: number, widthPx: number, heightPx: number }}
@ -56,9 +85,26 @@ function titleFromRow(row) {
return String(row.flat.designation || '').trim();
}
function orgFromRow(row) {
if (row.kind === 'faculty') return String(row.speaker.organizationName || '').trim();
return String(row.flat.organization || '').trim();
function tenantOrgName(config) {
return String(config?.tenantOrganizationName || '').trim();
}
function orgFromParticipantFlat(flat, config) {
let o = String(flat.organization || '').trim();
if (!o && flat.customFields && typeof flat.customFields === 'object') {
const cf = flat.customFields;
o = String(cf.organization || cf.organisation || cf.company || cf.Organization || '').trim();
}
return o || tenantOrgName(config);
}
function orgFromRow(row, config) {
if (row.kind === 'faculty') {
const s = row.speaker;
const fromSpeaker = String(s.organizationName || s.organisation || s.company || '').trim();
return fromSpeaker || tenantOrgName(config);
}
return orgFromParticipantFlat(row.flat, config);
}
function badgeTypeFromRow(row, config) {
@ -66,13 +112,60 @@ function badgeTypeFromRow(row, config) {
return String(config.badgeTypeLabel || 'Attendee').trim();
}
function photoUrlFromRow(row) {
if (row.kind === 'faculty' && row.speaker.photo) return String(row.speaker.photo).trim();
function firstNonEmptyString(...values) {
for (const v of values) {
const s = String(v || '').trim();
if (s) return s;
}
return '';
}
async function fetchImageBuffer(url) {
const u = String(url || '').trim();
function photoUrlFromRow(row) {
if (row.kind === 'faculty') {
const s = row.speaker;
return firstNonEmptyString(s.photo, s.photoUrl, s.profileImage, s.imageUrl);
}
const f = row.flat;
if (!f) return '';
const direct = firstNonEmptyString(f.photo, f.photoUrl, f.profileImage, f.imageUrl, f.avatar);
if (direct) return direct;
if (f.customFields && typeof f.customFields === 'object') {
const cf = f.customFields;
return firstNonEmptyString(cf.photo, cf.photoUrl, cf.profileImage, cf.imageUrl, cf.avatar);
}
return '';
}
/** @returns {{ url: string, s3Key: string }} */
function resolveMediaRef(ref) {
const raw = String(ref || '').trim();
if (!raw) return { url: '', s3Key: '' };
if (raw.startsWith('http://') || raw.startsWith('https://')) return { url: raw, s3Key: '' };
if (raw.startsWith('//')) return { url: `https:${raw}`, s3Key: '' };
if (raw.startsWith('eventbuzz/')) {
return { url: cloudfrontUrlForKey(raw), s3Key: raw };
}
const keyIdx = raw.indexOf('eventbuzz/');
if (keyIdx >= 0) {
const key = raw.slice(keyIdx);
return { url: cloudfrontUrlForKey(key), s3Key: key };
}
const base = getEnv('AWS_URL', '').replace(/\/+$/, '');
if (base && raw.startsWith('/')) return { url: `${base}${raw}`, s3Key: '' };
return { url: '', s3Key: '' };
}
async function fetchImageBuffer(ref) {
const { url, s3Key } = resolveMediaRef(ref);
if (s3Key) {
try {
const buf = await getBufferFromS3Key(s3Key);
if (buf && buf.length) return buf;
} catch {
/* try HTTP */
}
}
const u = url || (ref && String(ref).trim().startsWith('http') ? String(ref).trim() : '');
if (!u.startsWith('http://') && !u.startsWith('https://')) return null;
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 20000);
@ -87,6 +180,92 @@ async function fetchImageBuffer(url) {
}
}
function initialsFromRow(row) {
if (row.kind === 'faculty') {
const s = row.speaker;
const a = String(s.firstName || '').trim().charAt(0);
const b = String(s.lastName || '').trim().charAt(0);
const init = `${a}${b}`.toUpperCase();
if (init) return init.slice(0, 2);
return String(s.email || '?').charAt(0).toUpperCase();
}
const name = displayNameFromRow(row);
const parts = name.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return `${parts[0].charAt(0)}${parts[parts.length - 1].charAt(0)}`.toUpperCase();
return (parts[0]?.charAt(0) || '?').toUpperCase();
}
function avatarColorForRow(row) {
let n = 0;
const seed = String(row.id || displayNameFromRow(row));
for (let i = 0; i < seed.length; i += 1) n += seed.charCodeAt(i);
return AVATAR_COLORS[n % AVATAR_COLORS.length];
}
function drawInitialsCircle(ctx, cx, cy, r, row) {
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.closePath();
ctx.fillStyle = avatarColorForRow(row);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const fontSize = Math.max(10, Math.round(r * 0.85));
ctx.font = `600 ${fontSize}px sans-serif`;
ctx.fillText(initialsFromRow(row), cx, cy + 1);
ctx.restore();
}
/**
* Photo/avatar is a square sized from w% of badge width (matches designer preview aspect-ratio: 1).
* x,y are the top-left of that square — not a separate h% box on badge height.
*/
function photoSquareMetrics(x, y, widthPx, el) {
const sizePct = Number(el?.w ?? 18) || 18;
const sizePx = Math.round((sizePct / 100) * widthPx);
const r = Math.max(4, Math.round(sizePx / 2));
return {
r,
cx: x + r,
cy: y + r,
};
}
async function drawPhotoInBox(ctx, row, x, y, widthPx, heightPx, el) {
const { r, cx, cy } = photoSquareMetrics(x, y, widthPx, el);
const purl = photoUrlFromRow(row);
if (purl) {
const pbuf = await fetchImageBuffer(purl);
if (pbuf) {
try {
const ph = await loadImage(pbuf);
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
ctx.drawImage(ph, cx - r, cy - r, r * 2, r * 2);
ctx.restore();
return;
} catch {
/* fall through to initials */
}
}
}
drawInitialsCircle(ctx, cx, cy, r, row);
}
function effectiveBadgeFields(config) {
const fields = new Set(config.fields || []);
for (const el of config.layout || []) {
if (el && el.kind === 'field' && el.field) fields.add(String(el.field));
}
return fields;
}
function slug(s) {
return String(s || 'x')
.replace(/[^a-zA-Z0-9]+/g, '-')
@ -94,14 +273,35 @@ function slug(s) {
.slice(0, 40) || 'x';
}
/**
* @returns {Promise<Buffer>} PNG
*/
async function renderBadgePng(row, config, dims) {
const { widthPx, heightPx } = dims;
const canvas = createCanvas(widthPx, heightPx);
const ctx = canvas.getContext('2d');
function canvasFontFamily(family) {
const f = String(family || 'Inter').toLowerCase();
if (f.includes('georgia') || f.includes('times')) return 'serif';
return 'sans-serif';
}
function customTextMap(config) {
const m = new Map();
for (const c of config.customTexts || []) {
if (c && c.id) m.set(String(c.id), String(c.text || '').trim());
}
return m;
}
function customImageMap(config) {
const m = new Map();
for (const c of config.customImages || []) {
if (c && c.id) m.set(String(c.id), String(c.imageUrl || '').trim());
}
return m;
}
function imageUrlForLayoutElement(el, config) {
if (el.imageUrl) return String(el.imageUrl).trim();
const m = customImageMap(config);
return m.get(String(el.id)) || '';
}
async function drawBadgeBackground(ctx, config, widthPx, heightPx) {
if (config.backgroundMode === 'image' && config.backgroundImageUrl) {
const buf = await fetchImageBuffer(config.backgroundImageUrl);
if (buf) {
@ -113,24 +313,266 @@ async function renderBadgePng(row, config, dims) {
const dx = (widthPx - dw) / 2;
const dy = (heightPx - dh) / 2;
ctx.drawImage(img, dx, dy, dw, dh);
return;
} catch {
ctx.fillStyle = '#f1f5f9';
ctx.fillRect(0, 0, widthPx, heightPx);
/* fall through */
}
} else {
ctx.fillStyle = '#f1f5f9';
ctx.fillRect(0, 0, widthPx, heightPx);
}
} else {
ctx.fillStyle = config.backgroundColor || '#0B4DD1';
ctx.fillStyle = '#f1f5f9';
ctx.fillRect(0, 0, widthPx, heightPx);
return;
}
ctx.fillStyle = config.backgroundColor || '#0B4DD1';
ctx.fillRect(0, 0, widthPx, heightPx);
}
function textForLayoutElement(el, row, config, customMap) {
if (el.kind === 'custom_text') {
return String(el.text || customMap.get(el.id) || '').trim();
}
switch (el.field) {
case 'name':
return displayNameFromRow(row);
case 'title':
return titleFromRow(row);
case 'org':
return orgFromRow(row, config);
case 'badge_type':
return badgeTypeFromRow(row, config);
default:
return '';
}
}
async function renderLayoutElements(ctx, row, config, widthPx, heightPx) {
const fields = effectiveBadgeFields(config);
const customMap = customTextMap(config);
const layout = Array.isArray(config.layout) ? config.layout : [];
for (const el of layout) {
if (!el) continue;
const xPct = layoutCoord(el.x);
const yPct = layoutCoord(el.y);
if (xPct == null || yPct == null) continue;
const x = Math.round((xPct / 100) * widthPx);
const y = Math.round((yPct / 100) * heightPx);
try {
if (el.kind === 'field' && el.field === 'photo') {
if (!fields.has('photo')) continue;
await drawPhotoInBox(ctx, row, x, y, widthPx, heightPx, el);
continue;
}
if (el.kind === 'field' && el.field === 'qr') {
if (!fields.has('qr')) continue;
const data = `eventbuzz:${row.kind}:${row.id}`;
try {
const qw = Math.round(((el.w ?? 28) / 100) * widthPx);
const qrBuf = await QRCode.toBuffer(data, { type: 'png', width: Math.min(512, qw * 2), margin: 1 });
const qrImg = await loadImage(qrBuf);
ctx.drawImage(qrImg, x, y, qw, qw);
} catch {
/* skip */
}
continue;
}
if (el.kind === 'custom_image') {
const iurl = imageUrlForLayoutElement(el, config);
if (!iurl) continue;
const ibuf = await fetchImageBuffer(iurl);
if (!ibuf) continue;
try {
const img = await loadImage(ibuf);
const drawW = Math.round(((el.w ?? 22) / 100) * widthPx);
const drawH = Math.round(((el.h ?? 22) / 100) * heightPx);
const scale = Math.min(drawW / img.width, drawH / img.height);
const dw = img.width * scale;
const dh = img.height * scale;
const dx = x + (drawW - dw) / 2;
const dy = y + (drawH - dh) / 2;
ctx.drawImage(img, dx, dy, dw, dh);
} catch {
/* skip */
}
continue;
}
const isFieldText =
el.kind === 'custom_text' ||
(el.kind === 'field' && ['name', 'title', 'org', 'badge_type'].includes(el.field));
if (!isFieldText) continue;
if (el.kind === 'field' && el.field && !fields.has(el.field)) continue;
const text = textForLayoutElement(el, row, config, customMap);
if (!text) continue;
drawLayoutText(ctx, el, text, x, y, widthPx, heightPx);
} catch (err) {
console.error('[badgeRender] layout element failed', el.id || el.field, err?.message || err);
}
}
}
function usesFullWidthCenter(el) {
return el.fullWidthCenter !== false;
}
function usesShrinkToFit(el) {
return el.shrinkToFit !== false;
}
function layoutTextAlign(el) {
if (usesFullWidthCenter(el)) return el.textAlign || 'center';
return el.textAlign || 'left';
}
function resolveLayoutFontSize(ctx, el, text, boxW, heightPx) {
const weight = el.fontWeight === 'bold' ? 'bold ' : '';
const family = canvasFontFamily(el.fontFamily);
let fontSize = Math.round(heightPx * ((el.fontSizePct ?? 5) / 100));
const minSize = Math.max(8, Math.round(fontSize * 0.35));
const maxBlockH = Math.round(heightPx * 0.45);
const lineText = String(text);
const fitsSingleLine = (size) => {
ctx.font = `${weight}${size}px ${family}`;
const lineH = Math.round(size * 1.15);
if (lineH > maxBlockH) return false;
return ctx.measureText(lineText).width <= boxW;
};
const fitsWrapped = (size) => {
ctx.font = `${weight}${size}px ${family}`;
const lineH = Math.round(size * 1.15);
const blockH = lineBlockHeight(ctx, lineText, boxW, lineH);
if (blockH > maxBlockH) return false;
const words = lineText.split(/\s+/);
let line = '';
for (let n = 0; n < words.length; n += 1) {
const test = line + words[n] + ' ';
if (ctx.measureText(test).width > boxW && n > 0) {
if (ctx.measureText(line).width > boxW) return false;
line = `${words[n]} `;
} else {
line = test;
}
}
return ctx.measureText(line).width <= boxW;
};
if (el.shrinkToFit === false) {
ctx.font = `${weight}${fontSize}px ${family}`;
return fontSize;
}
const fields = new Set(config.fields || []);
const fits = usesShrinkToFit(el) ? fitsSingleLine : fitsWrapped;
const floorSize = usesShrinkToFit(el) ? 6 : minSize;
while (fontSize > floorSize && !fits(fontSize)) {
fontSize -= 1;
}
fontSize = Math.max(floorSize, fontSize);
ctx.font = `${weight}${fontSize}px ${family}`;
return fontSize;
}
function drawSingleLineAligned(ctx, text, boxX, boxY, boxW, color, align) {
ctx.fillStyle = color;
ctx.textBaseline = 'top';
const lineText = String(text);
const lw = ctx.measureText(lineText).width;
let drawX = boxX;
if (align === 'center') drawX = boxX + (boxW - lw) / 2;
else if (align === 'right') drawX = boxX + boxW - lw;
ctx.fillText(lineText, drawX, boxY);
}
function wrapDrawAligned(ctx, text, boxX, boxY, boxW, lineHeight, color, align) {
ctx.fillStyle = color;
ctx.textBaseline = 'top';
const words = String(text).split(/\s+/);
const lines = [];
let line = '';
for (let n = 0; n < words.length; n += 1) {
const test = line + words[n] + ' ';
if (ctx.measureText(test).width > boxW && n > 0) {
lines.push(line.trim());
line = `${words[n]} `;
} else {
line = test;
}
}
lines.push(line.trim());
let yy = boxY;
for (const ln of lines) {
let drawX = boxX;
const lw = ctx.measureText(ln).width;
if (align === 'center') drawX = boxX + (boxW - lw) / 2;
else if (align === 'right') drawX = boxX + boxW - lw;
ctx.fillText(ln, drawX, yy);
yy += lineHeight;
}
}
function drawLayoutText(ctx, el, text, x, y, widthPx, heightPx) {
const color = el.color || '#0f172a';
const fullWidth = usesFullWidthCenter(el);
const boxX = fullWidth ? 0 : x;
const boxW = fullWidth ? widthPx : Math.round(((el.w ?? 72) / 100) * widthPx);
const align = layoutTextAlign(el);
const fontSize = resolveLayoutFontSize(ctx, el, text, boxW, heightPx);
const weight = el.fontWeight === 'bold' ? 'bold ' : '';
const family = canvasFontFamily(el.fontFamily);
ctx.font = `${weight}${fontSize}px ${family}`;
const lineH = Math.round(fontSize * 1.15);
if (el.field === 'badge_type') {
const tw = ctx.measureText(text).width;
const pillW = tw + 16;
const boxH = Math.round(fontSize * 1.35);
let pillX = boxX;
if (align === 'center') pillX = boxX + (boxW - pillW) / 2;
else if (align === 'right') pillX = boxX + boxW - pillW;
ctx.fillStyle = color === '#ffffff' ? 'rgba(255,255,255,0.25)' : 'rgba(15,23,42,0.12)';
ctx.fillRect(pillX, y, pillW, boxH);
ctx.fillStyle = color;
ctx.fillText(text, pillX + 8, y + Math.round(fontSize * 0.15));
return;
}
if (usesShrinkToFit(el)) {
drawSingleLineAligned(ctx, text, boxX, y, boxW, color, align);
} else {
wrapDrawAligned(ctx, text, boxX, y, boxW, lineH, color, align);
}
}
/**
* @returns {Promise<Buffer>} PNG
*/
async function renderBadgePng(row, config, dims) {
const cfg = plainBadgeConfig(config);
const { widthPx, heightPx } = dims;
const canvas = createCanvas(widthPx, heightPx);
const ctx = canvas.getContext('2d');
await drawBadgeBackground(ctx, cfg, widthPx, heightPx);
const layout = Array.isArray(cfg.layout) ? cfg.layout : [];
if (layout.length > 0) {
await renderLayoutElements(ctx, row, cfg, widthPx, heightPx);
return canvas.toBuffer('image/png');
}
const fields = effectiveBadgeFields(cfg);
const pad = Math.round(widthPx * 0.06);
let textColor = '#0f172a';
if (config.backgroundMode === 'color') {
const hex = (config.backgroundColor || '#000').replace('#', '');
if (cfg.backgroundMode === 'color') {
const hex = (cfg.backgroundColor || '#000').replace('#', '');
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
@ -139,34 +581,17 @@ async function renderBadgePng(row, config, dims) {
}
let cursorY = pad + 8;
const maxTextW = widthPx - pad * 2 - (fields.has('photo') && photoUrlFromRow(row) ? Math.round(widthPx * 0.22) : 0);
const maxTextW = widthPx - pad * 2 - (fields.has('photo') ? Math.round(widthPx * 0.22) : 0);
ctx.fillStyle = textColor;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
if (fields.has('photo')) {
const purl = photoUrlFromRow(row);
if (purl) {
const pbuf = await fetchImageBuffer(purl);
if (pbuf) {
try {
const ph = await loadImage(pbuf);
const r = Math.round(widthPx * 0.18);
const cx = widthPx - pad - r;
const cy = pad + r;
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
ctx.drawImage(ph, cx - r, cy - r, r * 2, r * 2);
ctx.restore();
} catch {
/* skip */
}
}
}
const r = Math.round(widthPx * 0.18);
const boxX = widthPx - pad - r * 2;
const boxY = pad;
await drawPhotoInBox(ctx, row, boxX, boxY, widthPx, heightPx, { w: 36 });
}
if (fields.has('name')) {
@ -186,7 +611,7 @@ async function renderBadgePng(row, config, dims) {
}
if (fields.has('org')) {
const o = orgFromRow(row);
const o = orgFromRow(row, cfg);
if (o) {
ctx.font = `${Math.round(widthPx * 0.04)}px sans-serif`;
wrapDraw(ctx, o, pad, cursorY, maxTextW, Math.round(widthPx * 0.038), textColor);
@ -195,7 +620,7 @@ async function renderBadgePng(row, config, dims) {
}
if (fields.has('badge_type')) {
const bt = badgeTypeFromRow(row, config);
const bt = badgeTypeFromRow(row, cfg);
ctx.font = `${Math.round(widthPx * 0.035)}px sans-serif`;
const tw = ctx.measureText(bt).width;
const boxH = Math.round(widthPx * 0.055);
@ -279,7 +704,8 @@ async function buildMultiPagePdf(pngBuffers) {
* @returns {Promise<{ zipBuffer: Buffer, itemCount: number }>}
*/
async function buildBadgeZip(rows, config, outputFormat) {
const dims = getBadgeDimensions(config);
const cfg = plainBadgeConfig(config);
const dims = getBadgeDimensions(cfg);
const archiver = require('archiver');
const pngBuffers = [];
@ -287,7 +713,7 @@ async function buildBadgeZip(rows, config, outputFormat) {
let idx = 0;
for (const row of rows) {
idx += 1;
const png = await renderBadgePng(row, config, dims);
const png = await renderBadgePng(row, cfg, dims);
pngBuffers.push(png);
const base = `${row.kind}-${slug(displayNameFromRow(row))}-${row.id}`.slice(0, 120);
if (outputFormat === 'png') {
@ -326,5 +752,6 @@ module.exports = {
getBadgeDimensions,
renderBadgePng,
buildBadgeZip,
plainBadgeConfig,
mmToPx,
};

View File

@ -0,0 +1,29 @@
/** Default values when greeting template variables are missing (web embed, WhatsApp, etc.). */
const CONCIERGE_GREETING_DEFAULTS = {
profileName: 'Visitor',
eventName: 'Event',
};
/**
* Replace {profileName}, {eventName}, and other known placeholders in a concierge greeting.
* Empty or missing values fall back to CONCIERGE_GREETING_DEFAULTS.
*
* @param {string} template
* @param {{ profileName?: string, eventName?: string }} [values]
* @returns {string}
*/
function interpolateConciergeGreeting(template, values = {}) {
let out = String(template || '');
for (const [key, fallback] of Object.entries(CONCIERGE_GREETING_DEFAULTS)) {
const raw = values[key];
const val =
raw != null && String(raw).trim() !== '' ? String(raw).trim() : fallback;
out = out.replace(new RegExp(`\\{${key}\\}`, 'gi'), val);
}
return out;
}
module.exports = {
CONCIERGE_GREETING_DEFAULTS,
interpolateConciergeGreeting,
};

143
src/utils/emailerHtml.js Normal file
View File

@ -0,0 +1,143 @@
const Form = require('../models/Form');
const EventMicrosite = require('../models/EventMicrosite');
const { registrationUrlForForm } = require('./micrositeBuild');
const FONT_STACK = {
'Inter + Noto Sans': "system-ui, -apple-system, 'Segoe UI', sans-serif",
'Merriweather + Open Sans': "Georgia, 'Merriweather', serif",
'Poppins + Lato': "'Poppins', 'Lato', system-ui, sans-serif",
'Georgia + Source Sans': "Georgia, 'Source Sans Pro', sans-serif",
};
const LAYOUT_ACCENTS = {
e1: '#0B4DD1',
e2: '#1E293B',
e3: '#F1F5F9',
e4: '#D97706',
};
function escapeHtml(s) {
return String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function micrositePublicBase() {
return (process.env.MICROSITE_PUBLIC_BASE_URL || 'https://web.eventbuzz.live').replace(/\/$/, '');
}
/**
* @param {object} event — lean Event
*/
async function buildEmailerLinkTargets(event) {
const formsRaw = await Form.find({ event: event._id }).select('_id name').sort({ name: 1 }).lean();
const forms = formsRaw.map((f) => ({
id: String(f._id),
href: registrationUrlForForm(event, event.website, f._id) || '',
}));
const mss = await EventMicrosite.find({ event: event._id }).select('name publishFolder').sort({ name: 1 }).lean();
const microsites = mss
.filter((m) => m.publishFolder && String(m.publishFolder).trim())
.map((m) => {
const folder = String(m.publishFolder).trim().toLowerCase();
return {
id: String(m._id),
href: `${micrositePublicBase()}/${folder}/`,
};
});
return { forms, microsites };
}
/**
* @param {object} block — CTA block from emailer content.blocks
* @param {{ forms: { id: string, href: string }[], microsites: { id: string, href: string }[] }} targets
*/
function resolveCtaHref(block, targets) {
const linkKind = block.linkKind || 'custom';
if (linkKind === 'custom') return String(block.customUrl || '').trim() || '#';
if (linkKind === 'form') {
const f = targets.forms.find((x) => x.id === String(block.formId || ''));
return (f?.href && String(f.href).trim()) || '#';
}
if (linkKind === 'microsite') {
const m = targets.microsites.find((x) => x.id === String(block.micrositeId || ''));
return (m?.href && String(m.href).trim()) || '#';
}
return '#';
}
/**
* Rebuild email HTML from stored block JSON so CTA hrefs reflect current event/form URLs at send time.
* @param {object} content — design template content object
* @param {object} event — lean Event
*/
async function buildEmailerHtmlFromContent(content, event) {
const blocks = Array.isArray(content?.blocks) ? content.blocks : [];
if (!blocks.length) return typeof content?.html === 'string' ? content.html : '';
const targets = await buildEmailerLinkTargets(event);
const layoutId = String(content.layoutTemplateId || content.templateId || 'e1');
const accent = String(content.primaryColor || '').trim() || LAYOUT_ACCENTS[layoutId] || '#0B4DD1';
const font = FONT_STACK[content.fontPair] || FONT_STACK['Inter + Noto Sans'];
const headerImg = String(content.headerImageUrl || '').trim();
const footerText = String(content.footerText || '© EventBuzz');
const previewText = String(content.previewText || '').trim();
const blocksHtml = blocks
.map((blk) => {
if (blk.type === 'text') {
const inner = typeof blk.html === 'string' && blk.html.trim() ? blk.html : '';
return `<div style="margin:12px 0;font-size:15px;line-height:1.55;color:#334155;">${inner}</div>`;
}
if (blk.type === 'image' && blk.src && String(blk.src).trim()) {
return `<p style="text-align:center;margin:16px 0;"><img src="${escapeHtml(String(blk.src).trim())}" alt="" style="max-width:100%;border-radius:8px;" /></p>`;
}
if (blk.type === 'cta') {
const rawUrl = resolveCtaHref(blk, targets);
if (!rawUrl || rawUrl === '#') {
console.warn('[emailerHtml] CTA has no URL', {
label: blk.label,
linkKind: blk.linkKind,
formId: blk.formId,
micrositeId: blk.micrositeId,
});
}
const url = escapeHtml(rawUrl);
const label = escapeHtml(blk.label || 'Register');
return `<p style="text-align:center;margin:20px 0;"><a href="${url}" style="display:inline-block;padding:12px 24px;background:${escapeHtml(accent)};color:#fff;text-decoration:none;border-radius:8px;font-weight:600;">${label}</a></p>`;
}
if (blk.type === 'divider') return `<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0;" />`;
return '';
})
.join('\n');
const preHidden = previewText
? `<div style="display:none;font-size:1px;color:#fff;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;">${escapeHtml(previewText)}</div>`
: '';
return `<!DOCTYPE html><html><head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/></head>
<body style="margin:0;padding:0;background:#f8fafc;font-family:${font};">
${preHidden}
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f8fafc;padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" style="max-width:600px;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.08);">
${headerImg ? `<tr><td><img src="${escapeHtml(headerImg)}" alt="" width="600" style="display:block;width:100%;height:auto;"/></td></tr>` : ''}
<tr><td style="padding:24px 28px 8px;">
${previewText ? `<p style="margin:0 0 8px;font-size:12px;color:#64748b;">${escapeHtml(previewText)}</p>` : ''}
</td></tr>
<tr><td style="padding:0 28px 24px;">${blocksHtml}</td></tr>
<tr><td style="padding:16px 28px 24px;border-top:1px solid #f1f5f9;">
<p style="margin:0;font-size:12px;color:#94a3b8;">${escapeHtml(footerText)}</p>
</td></tr>
</table>
</td></tr></table>
</body></html>`;
}
module.exports = {
buildEmailerLinkTargets,
buildEmailerHtmlFromContent,
resolveCtaHref,
};

View File

@ -1,12 +1,17 @@
const Session = require('../models/Session');
const Speaker = require('../models/Speaker');
const Track = require('../models/Track');
const Event = require('../models/Event');
const Organization = require('../models/Organization');
function formatSessionTimeRange(start, end) {
const DEFAULT_EVENT_TIMEZONE = 'Asia/Kolkata';
function formatSessionTimeRange(start, end, timeZone = DEFAULT_EVENT_TIMEZONE) {
if (!start) return '';
const a = new Date(start).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
const opts = { hour: 'numeric', minute: '2-digit', hour12: true, timeZone };
const a = new Date(start).toLocaleTimeString('en-US', opts);
if (!end) return a;
const b = new Date(end).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
const b = new Date(end).toLocaleTimeString('en-US', opts);
return `${a} – ${b}`;
}
@ -32,7 +37,7 @@ function mapSessionToAgendaItem(session, opts = {}) {
const item = {
title: session.title,
time: formatSessionTimeRange(session.startTime, session.endTime),
time: formatSessionTimeRange(session.startTime, session.endTime, opts.timeZone),
track: trackLabel,
description: session.description || '',
speakerName: speakerNames[0] || '',
@ -78,6 +83,17 @@ async function loadTracks(eventId) {
async function loadAgendaAndSpeakers(eventId, options = {}) {
const speakerVisibility = options.speakerVisibility === 'all' ? 'all' : 'public';
const fullDetail = !!options.fullDetail;
let timeZone = options.timeZone || DEFAULT_EVENT_TIMEZONE;
if (!options.timeZone) {
const ev = await Event.findById(eventId).select('organization').lean();
if (ev?.organization) {
const org = await Organization.findById(ev.organization).select('timezone').lean();
if (org?.timezone) {
timeZone = org.timezone;
}
}
}
const sessions = await Session.find({ event: eventId })
.populate('track')
@ -109,7 +125,7 @@ async function loadAgendaAndSpeakers(eventId, options = {}) {
speakerList = await Speaker.find(speakerFilter).lean();
}
const agendaItems = sessions.map((s) => mapSessionToAgendaItem(s, { fullDetail }));
const agendaItems = sessions.map((s) => mapSessionToAgendaItem(s, { fullDetail, timeZone }));
const speakers = speakerList.map((s) => ({
name: speakerDisplayName(s),

View File

@ -0,0 +1,81 @@
const mongoose = require('mongoose');
const Event = require('../models/Event');
const EventMicrosite = require('../models/EventMicrosite');
const EVENT_PUBLIC_SELECT =
'name subtitle type startDate endDate location locationType image bannerImage website.logo';
/** Normalize form.event from lean() (ObjectId, string, or rare populated { _id }). */
function extractObjectIdString(ref) {
if (ref == null) return null;
if (ref instanceof mongoose.Types.ObjectId) return ref.toString();
if (Buffer.isBuffer(ref)) {
try {
return new mongoose.Types.ObjectId(ref).toString();
} catch {
return null;
}
}
if (typeof ref === 'object' && ref._id != null) {
return extractObjectIdString(ref._id);
}
if (typeof ref === 'string') {
const t = ref.trim();
if (t.length === 24 && mongoose.Types.ObjectId.isValid(t)) return t;
}
return null;
}
function buildPublicEventPayload(ev, micrositeLogo) {
const toIso = (d) => (d && !Number.isNaN(new Date(d).getTime()) ? new Date(d).toISOString() : null);
const siteLogo = ev.website?.logo && String(ev.website.logo).trim() ? String(ev.website.logo).trim() : null;
const msLogo = micrositeLogo && String(micrositeLogo).trim() ? String(micrositeLogo).trim() : null;
return {
name: ev.name,
subtitle: ev.subtitle || '',
eventType: ev.type,
startDate: toIso(ev.startDate),
endDate: toIso(ev.endDate),
location: ev.location ? String(ev.location) : '',
locationType: ev.locationType || 'physical',
logoUrl: siteLogo || msLogo || null,
imageUrl: (() => {
const a = ev.image && String(ev.image).trim();
const b = ev.bannerImage && String(ev.bannerImage).trim();
return a || b || null;
})(),
};
}
/** Resolve Event for a public form: direct form.event, then Event.website.registrationFormId, then EventMicrosite.registrationFormId. */
async function resolveEventForPublicForm(form) {
const directId = extractObjectIdString(form.event);
if (directId) {
const ev = await Event.findById(directId).select(EVENT_PUBLIC_SELECT).lean();
if (ev) return { ev, micrositeLogo: null };
}
const formIdStr = String(form._id);
let ev = await Event.findOne({
$or: [{ 'website.registrationFormId': formIdStr }, { 'website.registrationFormId': form._id }],
})
.select(EVENT_PUBLIC_SELECT)
.lean();
if (ev) return { ev, micrositeLogo: null };
const ms = await EventMicrosite.findOne({
$or: [{ registrationFormId: formIdStr }, { registrationFormId: form._id }],
})
.select('event logo')
.lean();
if (!ms || !ms.event) return { ev: null, micrositeLogo: null };
ev = await Event.findById(ms.event).select(EVENT_PUBLIC_SELECT).lean();
return { ev, micrositeLogo: ev ? ms.logo : null };
}
module.exports = {
EVENT_PUBLIC_SELECT,
extractObjectIdString,
buildPublicEventPayload,
resolveEventForPublicForm,
};

View File

@ -108,12 +108,34 @@ function clearOrgTransportCache(orgId) {
* @param {{ organizationId: string, to: string, subject: string, html: string, text?: string }} opts
*/
async function sendOrgMail({ organizationId, to, subject, html, text }) {
const from = organizationId ? orgFromAddress(await Organization.findById(organizationId).lean()) : getMailFrom();
const org =
organizationId ? await Organization.findById(organizationId).select('smtp name').lean() : null;
const from = organizationId ? orgFromAddress(org) : getMailFrom();
const t = organizationId ? await getTransporterForOrganization(organizationId) : getDefaultTransporter();
let smtpSource = 'none';
if (org?.smtp?.enabled && org?.smtp?.host) {
smtpSource = `organisation SMTP (${org.smtp.host}:${org.smtp.port || 587})`;
} else if (t) {
const { host, port } = resolveSmtp();
smtpSource = host ? `default env SMTP (${host}:${port})` : 'default transporter';
}
if (!t) {
console.log('[sendOrgMail skipped — no SMTP] to=', to, 'subject=', subject);
console.warn(
'[sendOrgMail] skipped — no SMTP configured, source=',
smtpSource,
'org=',
organizationId || '(none)',
'to=',
to,
'subject=',
subject,
);
return { ok: false, skipped: true };
}
console.log('[sendOrgMail] sending via', smtpSource, 'from=', from, 'to=', to, 'subject=', subject);
try {
await t.sendMail({
from,
@ -122,9 +144,10 @@ async function sendOrgMail({ organizationId, to, subject, html, text }) {
text: text || undefined,
html,
});
console.log('[sendOrgMail] accepted by SMTP server, to=', to);
return { ok: true };
} catch (e) {
console.error('sendOrgMail', e.message);
console.error('[sendOrgMail] SMTP error to=', to, ':', e.message);
return { ok: false, error: e.message };
}
}

View File

@ -4,10 +4,30 @@ const EventPage = require('../models/EventPage');
const { loadAgendaAndSpeakers } = require('./eventAgendaData');
const { buildSectionsHtml, escapeHtml } = require('./websiteHtml');
const { buildConferenceThemeStyleTag, buildConferenceFontLink } = require('./conferenceTheme');
const { WEBSITE_SECTION_TYPES } = require('../config/constants');
const { WEBSITE_SECTION_TYPES, DEFAULT_MICROSITE_SECTIONS } = require('../config/constants');
const LAYOUT_TO_FOLDER = { conference: 'conference' };
function eventBuzzPublicBase() {
return (process.env.EVENTBUZZ_PUBLIC_URL || process.env.FRONTEND_URL || 'http://localhost:5173').replace(
/\/$/,
'',
);
}
/**
* Public dashboard form page (fallback when MICROSITE_REGISTRATION_URL_TEMPLATE is unset).
* @param {string} formId
* @returns {string}
*/
function publicFormUrl(formId, eventId) {
const fid = formId != null ? String(formId).trim() : '';
if (!fid) return '';
const eid = eventId != null ? String(eventId).trim() : '';
if (eid) return `${eventBuzzPublicBase()}/event/${eid}/form/${fid}`;
return `${eventBuzzPublicBase()}/form/${fid}`;
}
/**
* @param {object} event
* @returns {string}
@ -16,12 +36,14 @@ function registrationHref(event, website) {
const w = website || event.website || {};
const template = process.env.MICROSITE_REGISTRATION_URL_TEMPLATE || '';
const formId = w.registrationFormId != null ? String(w.registrationFormId).trim() : '';
const eid = event._id != null ? String(event._id) : '';
const eid = event._id != null ? String(event._id).trim() : '';
if (template && formId && eid) {
return template.replace(/\{eventId\}/g, eid).replace(/\{formId\}/g, formId);
}
const legacy = w.registrationUrl != null ? String(w.registrationUrl).trim() : '';
return legacy;
if (legacy) return legacy;
if (eid) return `${eventBuzzPublicBase()}/event/${eid}/register`;
return '';
}
/**
@ -45,7 +67,10 @@ function registrationUrlForForm(event, website, explicitFormId) {
return template.replace(/\{eventId\}/g, eid).replace(/\{formId\}/g, fid);
}
const legacy = w.registrationUrl != null ? String(w.registrationUrl).trim() : '';
return legacy;
if (legacy) return legacy;
if (fid && eid) return `${eventBuzzPublicBase()}/event/${eid}/register`;
if (fid) return publicFormUrl(fid, eid);
return '';
}
/**
@ -374,7 +399,8 @@ async function resolvePublishPage(event, website, sourcePageId) {
function buildSyntheticPage(micrositeSections, event, website) {
const w = website || event.website || {};
const allowed = new Set(WEBSITE_SECTION_TYPES);
const types = (micrositeSections || []).filter((t) => allowed.has(t));
const configured = (micrositeSections || []).filter((t) => allowed.has(t));
const types = configured.length > 0 ? configured : DEFAULT_MICROSITE_SECTIONS;
const title = (w.title && String(w.title).trim()) || event.name || 'Event';
return {
title,
@ -592,7 +618,10 @@ module.exports = {
templateToWebsiteBag,
buildSyntheticPage,
defaultConfigFor,
resolvePublishPage,
LAYOUT_TO_FOLDER,
registrationHref,
registrationUrlForForm,
publicFormUrl,
eventBuzzPublicBase,
};

View File

@ -0,0 +1,83 @@
/**
* Run MongoDB queue workers inside the API process (no separate PM2 worker needed).
* Kicks are coalesced per queue; a periodic poll covers scheduled jobs and retries.
*/
const draining = {
email: false,
whatsapp: false,
badge: false,
};
async function drainQueue(name, processOne) {
if (draining[name]) {
console.log(`[queueKick:${name}] drain already in progress, skipping`);
return;
}
draining[name] = true;
let processed = 0;
try {
for (;;) {
let did;
try {
did = await processOne();
} catch (e) {
console.error(`[queueKick:${name}]`, e);
break;
}
if (!did) break;
processed += 1;
}
if (processed > 0) {
console.log(`[queueKick:${name}] processed ${processed} job(s)`);
}
} finally {
draining[name] = false;
}
}
function kickEmailQueueWorker() {
console.log('[queueKick:email] kick requested');
setImmediate(() => drainQueue('email', require('../workers/emailQueueWorker').processOne));
}
function kickWhatsAppQueueWorker() {
console.log('[queueKick:whatsapp] kick requested');
setImmediate(() => drainQueue('whatsapp', require('../workers/whatsappQueueWorker').processOne));
}
function kickBadgeBatchWorker() {
console.log('[queueKick:badge] kick requested');
setImmediate(() => drainQueue('badge', require('../workers/badgeBatchWorker').processOne));
}
function kickAllQueueWorkers() {
kickEmailQueueWorker();
kickWhatsAppQueueWorker();
kickBadgeBatchWorker();
}
let pollTimer = null;
function startEmbeddedQueueWorkers() {
const disabled =
process.env.EMBEDDED_QUEUE_WORKERS === '0' || process.env.EMBEDDED_QUEUE_WORKERS === 'false';
if (disabled || pollTimer) return;
const pollMs = Math.max(
1000,
parseInt(process.env.EMBEDDED_QUEUE_WORKER_POLL_MS || '3000', 10),
);
pollTimer = setInterval(kickAllQueueWorkers, pollMs);
console.log('[queueKick] embedded queue workers enabled, poll every', pollMs, 'ms');
kickAllQueueWorkers();
}
module.exports = {
kickEmailQueueWorker,
kickWhatsAppQueueWorker,
kickBadgeBatchWorker,
kickAllQueueWorkers,
startEmbeddedQueueWorkers,
};

View File

@ -33,6 +33,12 @@ function normalizeSettings(settings) {
}
if (s.displayMode != null) s.displayMode = String(s.displayMode).trim();
if (s.status != null) s.status = String(s.status).trim();
if (s.liveQuestionId != null && String(s.liveQuestionId).trim() !== '') {
s.liveQuestionId = String(s.liveQuestionId).trim();
} else {
s.liveQuestionId = null;
}
s.showCorrectAnswer = s.showCorrectAnswer === true || s.showCorrectAnswer === 'true';
return s;
}
@ -69,6 +75,7 @@ function mapQuestionForRtdb(q, index) {
if (o.imageUrl) out.imageUrl = o.imageUrl;
if (o.points != null) out.points = o.points;
if (o.required != null) out.required = o.required;
if (o.timeLimit != null) out.timeLimit = o.timeLimit;
return out;
}

View File

@ -0,0 +1,337 @@
function normAnswerVal(ans, seen) {
if (ans == null) return '';
const t = typeof ans;
if (t === 'string') return ans.trim();
if (t === 'number' || t === 'boolean' || t === 'bigint') return String(ans).trim();
if (t !== 'object') return String(ans).trim();
if (Array.isArray(ans)) {
if (ans.length === 0) return '';
return ans
.map((x) => normAnswerVal(x, seen))
.filter((x) => x.length > 0)
.join(',');
}
const track = seen || new WeakSet();
if (track.has(ans)) return '';
track.add(ans);
if (typeof ans.toString === 'function') {
const s = ans.toString();
if (/^[a-f0-9]{24}$/i.test(s)) return s;
}
if (ans._id != null && ans._id !== ans) {
const nested = normAnswerVal(ans._id, track);
if (nested) return nested;
}
if (typeof ans.toString === 'function') {
const s = String(ans.toString());
if (s && s !== '[object Object]') return s.trim();
}
return '';
}
function optionIdFromOpt(opt, index) {
if (opt == null) return `opt-${index}`;
const raw = opt._id;
if (raw != null && raw !== opt) {
const id = normAnswerVal(raw);
if (id) return id;
}
return `opt-${index}`;
}
/** Match stored answer to a stats option bucket (handles ObjectId / string mismatches). */
function findOptionForAnswer(opts, answer) {
const id = normAnswerVal(answer);
if (!id) return null;
let found = opts.find((x) => x.optionId === id);
if (found) return found;
found = opts.find((x) => String(x.optionId) === id);
if (found) return found;
const lower = id.toLowerCase();
return opts.find((x) => String(x.optionId).toLowerCase() === lower) || null;
}
function participantDisplayName(participant) {
if (!participant || typeof participant !== 'object') return 'Anonymous';
if (participant.displayName != null && String(participant.displayName).trim() !== '') {
return String(participant.displayName).trim();
}
const parts = [participant.firstName, participant.lastName]
.filter((x) => x != null && String(x).trim() !== '')
.map((x) => String(x).trim());
if (parts.length) return parts.join(' ');
return 'Anonymous';
}
function responseItemAnsweredAt(item, doc) {
if (item?.answeredAt) {
return item.answeredAt instanceof Date ? item.answeredAt : new Date(item.answeredAt);
}
if (doc?.updatedAt) {
return doc.updatedAt instanceof Date ? doc.updatedAt : new Date(doc.updatedAt);
}
if (doc?.completedAt) {
return doc.completedAt instanceof Date ? doc.completedAt : new Date(doc.completedAt);
}
return new Date(0);
}
/**
* Aggregate all stored responses for poll/survey questions (percent = share of respondents).
* @param {object} quizPoll lean doc
* @param {object[]} allResponses QuizPollResponse lean docs
*/
function buildVisitorPollResultsSummary(quizPoll, allResponses) {
const questions = quizPoll.questions || [];
const totalVoters = allResponses.length;
return questions.map((q, questionIndex) => {
const qType = String(q.type || 'single').toLowerCase();
const base = {
questionIndex,
questionId: q._id != null ? String(q._id) : '',
type: q.type || 'single',
question: q.question != null ? q.question : '',
totalResponses: totalVoters,
};
const countForOptionBuckets = (buckets) => {
for (const doc of allResponses) {
const item = (doc.responses || []).find((r) => Number(r.questionIndex) === questionIndex);
if (!item) continue;
const id = normAnswerVal(item.answer);
const b = buckets.find((x) => x.optionId === id);
if (b) b.count += 1;
}
buckets.forEach((b) => {
b.percent = totalVoters > 0 ? Math.round((b.count / totalVoters) * 1000) / 10 : 0;
});
return buckets;
};
if (qType === 'text') {
let answered = 0;
for (const doc of allResponses) {
const item = (doc.responses || []).find((r) => Number(r.questionIndex) === questionIndex);
if (!item) continue;
const t = normAnswerVal(item.answer).trim();
if (t.length > 0) answered += 1;
}
return {
...base,
kind: 'text',
answeredCount: answered,
percentAnswered: totalVoters > 0 ? Math.round((answered / totalVoters) * 1000) / 10 : 0,
};
}
if (qType === 'rating') {
const buckets = [1, 2, 3, 4, 5].map((n) => ({
optionId: String(n),
text: String(n),
count: 0,
percent: 0,
}));
countForOptionBuckets(buckets);
return { ...base, kind: 'rating', options: buckets };
}
if (qType === 'multiple') {
const opts = (q.options || []).map((o) => ({
optionId: o._id != null ? String(o._id) : '',
text: o.text != null ? o.text : '',
count: 0,
percent: 0,
}));
for (const doc of allResponses) {
const item = (doc.responses || []).find((r) => Number(r.questionIndex) === questionIndex);
if (!item || !Array.isArray(item.answer)) continue;
for (const rawId of item.answer) {
const id = normAnswerVal(rawId);
const o = opts.find((x) => x.optionId === id);
if (o) o.count += 1;
}
}
opts.forEach((o) => {
o.percent = totalVoters > 0 ? Math.round((o.count / totalVoters) * 1000) / 10 : 0;
});
return { ...base, kind: 'multiple', options: opts };
}
const opts = (q.options || []).map((o) => ({
optionId: o._id != null ? String(o._id) : '',
text: o.text != null ? o.text : '',
count: 0,
percent: 0,
}));
countForOptionBuckets(opts);
return { ...base, kind: 'single', options: opts };
});
}
function formatAnswerSnippet(q, item) {
if (!item) return '';
const qType = String(q?.type || 'single').toLowerCase();
const raw = item.answer;
if (qType === 'text') return normAnswerVal(raw).trim();
if (qType === 'multiple' && Array.isArray(raw)) {
const opts = q.options || [];
return raw
.map((id) => {
const o = opts.find((x) => x._id != null && String(x._id) === normAnswerVal(id));
return o?.text != null ? String(o.text) : normAnswerVal(id);
})
.filter(Boolean)
.join(', ');
}
if (qType === 'rating' || qType === 'single') {
const id = normAnswerVal(raw);
const o = (q.options || []).find((x) => x._id != null && String(x._id) === id);
return o?.text != null ? String(o.text) : id;
}
return normAnswerVal(raw);
}
function sortResponders(responders, quizType) {
const t = String(quizType || '').toLowerCase();
const isQuiz = t === 'quiz';
return [...responders].sort((a, b) => {
if (isQuiz) {
const aCor = a.isCorrect === true ? 0 : a.isCorrect === false ? 2 : 1;
const bCor = b.isCorrect === true ? 0 : b.isCorrect === false ? 2 : 1;
if (aCor !== bCor) return aCor - bCor;
}
const aTime = a.answeredAt ? new Date(a.answeredAt).getTime() : 0;
const bTime = b.answeredAt ? new Date(b.answeredAt).getTime() : 0;
if (aTime !== bTime) return aTime - bTime;
const aRt = Number(a.responseTimeSec) || 0;
const bRt = Number(b.responseTimeSec) || 0;
return aRt - bRt;
});
}
function mapPresentationOption(opt, index, isQuiz) {
const o =
opt && typeof opt.toObject === 'function' ? opt.toObject({ depopulate: true }) : opt || {};
const out = {
optionId: optionIdFromOpt(o, index),
text: o.text != null ? o.text : '',
count: 0,
percent: 0,
};
if (isQuiz) out.isCorrect = Boolean(o.isCorrect);
return out;
}
/**
* Per-question stats for presentation mode (bar chart + responder leaderboard).
* @param {object} quizPoll lean doc
* @param {object[]} allResponses populated with participant
* @param {number} questionIndex
*/
function buildQuestionPresentationStats(quizPoll, allResponses, questionIndex) {
const questions = quizPoll.questions || [];
const q = questions[questionIndex];
if (!q) return null;
const qType = String(q.type || 'single').toLowerCase();
const quizType = String(quizPoll.type || '').toLowerCase();
const isQuiz = quizType === 'quiz';
const responders = [];
for (const doc of allResponses) {
const item = (doc.responses || []).find((r) => Number(r.questionIndex) === questionIndex);
if (!item) continue;
const answeredAt = responseItemAnsweredAt(item, doc);
responders.push({
participantId: doc.participant != null ? String(doc.participant._id || doc.participant) : '',
displayName: participantDisplayName(doc.participant),
isCorrect: isQuiz ? (item.isCorrect === true ? true : item.isCorrect === false ? false : null) : null,
pointsEarned: Number(item.pointsEarned) || 0,
answeredAt: answeredAt.toISOString(),
responseTimeSec: Number(item.responseTimeSec) || 0,
answerText: formatAnswerSnippet(q, item),
});
}
const totalResponses = responders.length;
const base = {
questionIndex,
questionId: q._id != null ? String(q._id) : '',
question: q.question != null ? q.question : '',
type: q.type || 'single',
totalResponses,
responders: sortResponders(responders, quizType),
};
if (qType === 'text') {
const answered = responders.filter((r) => (r.answerText || '').trim().length > 0).length;
return {
...base,
kind: 'text',
answeredCount: answered,
percentAnswered: totalResponses > 0 ? Math.round((answered / totalResponses) * 1000) / 10 : 0,
};
}
const pctDenom = totalResponses > 0 ? totalResponses : 1;
if (qType === 'rating') {
const buckets = [1, 2, 3, 4, 5].map((n) => ({
optionId: String(n),
text: String(n),
count: 0,
percent: 0,
}));
for (const doc of allResponses) {
const item = (doc.responses || []).find((r) => Number(r.questionIndex) === questionIndex);
if (!item) continue;
const id = normAnswerVal(item.answer);
const b = buckets.find((x) => x.optionId === id || String(x.optionId) === id);
if (b) b.count += 1;
}
buckets.forEach((b) => {
b.percent = totalResponses > 0 ? Math.round((b.count / pctDenom) * 1000) / 10 : 0;
});
return { ...base, kind: 'rating', options: buckets };
}
if (qType === 'multiple') {
const opts = (q.options || []).map((o, i) => mapPresentationOption(o, i, isQuiz));
for (const doc of allResponses) {
const item = (doc.responses || []).find((r) => Number(r.questionIndex) === questionIndex);
if (!item || !Array.isArray(item.answer)) continue;
for (const rawId of item.answer) {
const o = findOptionForAnswer(opts, rawId);
if (o) o.count += 1;
}
}
opts.forEach((o) => {
o.percent = totalResponses > 0 ? Math.round((o.count / pctDenom) * 1000) / 10 : 0;
});
return { ...base, kind: 'multiple', options: opts };
}
const opts = (q.options || []).map((o, i) => mapPresentationOption(o, i, isQuiz));
for (const doc of allResponses) {
const item = (doc.responses || []).find((r) => Number(r.questionIndex) === questionIndex);
if (!item) continue;
const b = findOptionForAnswer(opts, item.answer);
if (b) b.count += 1;
}
opts.forEach((b) => {
b.percent = totalResponses > 0 ? Math.round((b.count / pctDenom) * 1000) / 10 : 0;
});
return { ...base, kind: 'single', options: opts };
}
module.exports = {
normAnswerVal,
participantDisplayName,
buildVisitorPollResultsSummary,
buildQuestionPresentationStats,
};

View File

@ -109,5 +109,6 @@ module.exports = {
uploadBufferToS3AndGetCloudFrontUrl,
getBufferFromS3Key,
deleteS3ObjectByKey,
cloudfrontUrlForKey,
};

View File

@ -0,0 +1,99 @@
const Form = require('../models/Form');
const BUILTIN_FEEDBACK_FORM_NAME = 'Give us your feedback';
const BUILTIN_RATINGS_FORM_NAME = 'Rate us';
/** @deprecated Renamed; kept for lookup/migration of existing documents */
const LEGACY_BUILTIN_FEEDBACK_FORM_NAME = 'EventBuzz Default Feedback';
const LEGACY_BUILTIN_RATINGS_FORM_NAME = 'EventBuzz Default Ratings';
const ALL_BUILTIN_FEEDBACK_NAMES = [BUILTIN_FEEDBACK_FORM_NAME, LEGACY_BUILTIN_FEEDBACK_FORM_NAME];
const ALL_BUILTIN_RATINGS_NAMES = [BUILTIN_RATINGS_FORM_NAME, LEGACY_BUILTIN_RATINGS_FORM_NAME];
const DEFAULT_FEEDBACK_FIELDS = [
{
label: 'Your feedback',
type: 'text',
required: true,
placeholder: 'Share your experience, suggestions, or issues…',
options: [],
},
];
const DEFAULT_RATINGS_FIELDS = [
{
label: 'How will you rate the speakers?',
type: 'stars',
required: true,
options: [],
},
{
label: 'How will you rate the quality of content?',
type: 'stars',
required: true,
options: [],
},
{
label: 'Probability to recommend to your friends and colleagues',
type: 'stars',
required: true,
options: [],
},
];
function isBuiltinVisitorFormName(name) {
const n = name != null ? String(name).trim() : '';
return [...ALL_BUILTIN_FEEDBACK_NAMES, ...ALL_BUILTIN_RATINGS_NAMES].includes(n);
}
/**
* EventBuzz built-in feedback/ratings forms (created per event on first use).
* Session `audienceFeedbackForm` / `audienceRatingsForm` null = use these, not custom portfolio forms.
*/
async function ensureBuiltinVisitorForm(event, purpose = 'feedback') {
const isRatings = purpose === 'ratings';
const name = isRatings ? BUILTIN_RATINGS_FORM_NAME : BUILTIN_FEEDBACK_FORM_NAME;
const lookupNames = isRatings ? ALL_BUILTIN_RATINGS_NAMES : ALL_BUILTIN_FEEDBACK_NAMES;
const fields = isRatings ? DEFAULT_RATINGS_FIELDS : DEFAULT_FEEDBACK_FIELDS;
const thankYouMessage = isRatings
? 'Thank you for rating this event!'
: 'Your response has been recorded.';
let form = await Form.findOne({
event: event._id,
type: 'feedback',
name: { $in: lookupNames },
}).lean();
if (form && form.name !== name) {
await Form.updateOne({ _id: form._id }, { $set: { name } });
form = { ...form, name };
}
if (!form) {
form = await Form.create({
tenantOrganization: event.organization,
event: event._id,
name,
description: '',
type: 'feedback',
fields,
settings: {
isActive: true,
showThankYou: true,
thankYouMessage,
allowMultiple: true,
},
});
form = form.toObject();
}
return form;
}
module.exports = {
BUILTIN_FEEDBACK_FORM_NAME,
BUILTIN_RATINGS_FORM_NAME,
isBuiltinVisitorFormName,
ensureBuiltinVisitorForm,
};

View File

@ -3,8 +3,9 @@ const mongoose = require('mongoose');
const { connectDB } = require('../config/database');
const BadgeBatch = require('../models/BadgeBatch');
const Event = require('../models/Event');
const Organization = require('../models/Organization');
const { resolveBadgeRecipients } = require('../utils/badgeRecipients');
const { buildBadgeZip } = require('../utils/badgeRender');
const { buildBadgeZip, plainBadgeConfig } = require('../utils/badgeRender');
const { uploadBufferToS3AndGetCloudFrontUrl } = require('../utils/s3Media');
const POLL_MS = Math.max(1000, parseInt(process.env.BADGE_BATCH_WORKER_POLL_MS || '3000', 10));
@ -29,6 +30,7 @@ async function processOne() {
if (!batch) return false;
console.log('[badgeBatchWorker] processing batch', batch._id, batch.name || '(unnamed)');
try {
const event = await Event.findById(batch.event).select('organization').lean();
if (!event) {
@ -39,8 +41,9 @@ async function processOne() {
}
const allowed = await allowedEventIdsForOrganization(event.organization);
const batchConfig = plainBadgeConfig(batch.config);
const resolved = await resolveBadgeRecipients(
batch.config,
batchConfig,
batch.event,
event.organization,
allowed,
@ -56,7 +59,15 @@ async function processOne() {
throw new Error(`Recipient count ${rows.length} exceeds limit ${MAX_BADGE_RECIPIENTS}`);
}
const { zipBuffer, itemCount } = await buildBadgeZip(rows, batch.config, batch.config.outputFormat);
const orgDoc = await Organization.findById(event.organization).select('name').lean();
const renderConfig = {
...batchConfig,
tenantOrganizationName: orgDoc?.name ? String(orgDoc.name).trim() : '',
};
if (!renderConfig.layout?.length) {
console.warn('[badgeBatchWorker] batch', batch._id, 'has no layout — badges may be empty');
}
const { zipBuffer, itemCount } = await buildBadgeZip(rows, renderConfig, batchConfig.outputFormat);
const filename = `batch-${batch._id}-${Date.now()}.zip`;
const uploaded = await uploadBufferToS3AndGetCloudFrontUrl({
@ -75,11 +86,13 @@ async function processOne() {
batch.completedAt = new Date();
batch.lastError = undefined;
await batch.save();
console.log('[badgeBatchWorker] batch', batch._id, 'ready —', itemCount, 'badge(s), zip uploaded');
} catch (e) {
batch.status = 'failed';
batch.lastError = e.message || String(e);
batch.completedAt = new Date();
await batch.save();
console.error('[badgeBatchWorker] batch', batch._id, 'failed:', batch.lastError);
}
return true;

View File

@ -23,6 +23,13 @@ async function finalizeCampaignIfDone(campaignId) {
}
camp.sentAt = new Date();
await camp.save();
console.log(
'[emailQueueWorker] campaign',
campaignId,
'finalized as',
camp.status,
`(sent=${sent} failed=${failed})`,
);
}
async function processOne() {
@ -41,16 +48,29 @@ async function processOne() {
if (!job) return false;
console.log(
'[emailQueueWorker] processing job',
job._id,
'campaign=',
job.campaign,
'to=',
job.to,
'attempt=',
job.attempts,
);
const campaign = await MessagingCampaign.findById(job.campaign);
if (!campaign) {
job.status = 'cancelled';
await job.save();
console.log('[emailQueueWorker] cancelled — campaign not found', job.campaign);
return true;
}
if (campaign.status === 'stopped') {
job.status = 'cancelled';
await job.save();
console.log('[emailQueueWorker] cancelled — campaign stopped', job.campaign);
return true;
}
@ -71,6 +91,7 @@ async function processOne() {
job.sentAt = new Date();
job.lastError = undefined;
await job.save();
console.log('[emailQueueWorker] sent to=', job.to, 'campaign=', job.campaign);
campaign.stats = campaign.stats || {};
campaign.stats.sent = (campaign.stats.sent || 0) + 1;
await campaign.save();
@ -79,13 +100,32 @@ async function processOne() {
if (result.skipped) {
job.status = 'failed';
job.lastError = 'No SMTP configured';
console.warn('[emailQueueWorker] failed — no SMTP configured, to=', job.to, 'org=', job.organization);
} else if (job.attempts >= MAX_ATTEMPTS) {
job.status = 'failed';
job.lastError = result.error || 'send failed';
console.error(
'[emailQueueWorker] failed after',
job.attempts,
'attempt(s) to=',
job.to,
':',
job.lastError,
);
} else {
job.status = 'pending';
job.lastError = result.error || 'send failed';
job.processAfter = new Date(Date.now() + 60_000 * job.attempts);
console.warn(
'[emailQueueWorker] will retry attempt',
job.attempts,
'to=',
job.to,
'at',
job.processAfter.toISOString(),
':',
job.lastError,
);
}
await job.save();
if (job.status === 'failed') {
@ -116,3 +156,5 @@ async function run() {
if (require.main === module) {
run();
}
module.exports = { processOne, run };

View File

@ -208,3 +208,5 @@ async function run() {
if (require.main === module) {
run();
}
module.exports = { processOne, run };