1july
This commit is contained in:
parent
2a5629c2ae
commit
03b1c6a7d8
106
.env.example
106
.env.example
|
|
@ -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
|
||||
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
14
src/app.js
14
src/app.js
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: '' },
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ const eventMicrositeSchema = new mongoose.Schema(
|
|||
type: String,
|
||||
trim: true,
|
||||
lowercase: true,
|
||||
sparse: true,
|
||||
},
|
||||
enabled: { type: Boolean, default: false },
|
||||
logo: String,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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 },
|
||||
},
|
||||
];
|
||||
|
|
@ -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 } },
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -109,5 +109,6 @@ module.exports = {
|
|||
uploadBufferToS3AndGetCloudFrontUrl,
|
||||
getBufferFromS3Key,
|
||||
deleteS3ObjectByKey,
|
||||
cloudfrontUrlForKey,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -208,3 +208,5 @@ async function run() {
|
|||
if (require.main === module) {
|
||||
run();
|
||||
}
|
||||
|
||||
module.exports = { processOne, run };
|
||||
|
|
|
|||
Loading…
Reference in New Issue