diff --git a/.env.example b/.env.example index b1a6fe4..2279890 100644 --- a/.env.example +++ b/.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) \ No newline at end of file +# 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 \ No newline at end of file diff --git a/public/embed/concierge-widget.js b/public/embed/concierge-widget.js index dabfa38..30ee5ad 100644 --- a/public/embed/concierge-widget.js +++ b/public/embed/concierge-widget.js @@ -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)); } }); diff --git a/scripts/drop-legacy-participant-indexes.js b/scripts/drop-legacy-participant-indexes.js new file mode 100644 index 0000000..57e0b10 --- /dev/null +++ b/scripts/drop-legacy-participant-indexes.js @@ -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); +}); diff --git a/src/app.js b/src/app.js index 6df330a..410ae43 100644 --- a/src/app.js +++ b/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() { diff --git a/src/config/constants.js b/src/config/constants.js index 4f1187a..cb727b0 100644 --- a/src/config/constants.js +++ b/src/config/constants.js @@ -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, }; diff --git a/src/config/database.js b/src/config/database.js index 64efdff..626d3df 100644 --- a/src/config/database.js +++ b/src/config/database.js @@ -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) => { diff --git a/src/controllers/badgeBatch.controller.js b/src/controllers/badgeBatch.controller.js index e1f55d7..cba40e1 100644 --- a/src/controllers/badgeBatch.controller.js +++ b/src/controllers/badgeBatch.controller.js @@ -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( diff --git a/src/controllers/campaign.controller.js b/src/controllers/campaign.controller.js index 3e0e4c9..98d5cbc 100644 --- a/src/controllers/campaign.controller.js +++ b/src/controllers/campaign.controller.js @@ -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)); diff --git a/src/controllers/event.controller.js b/src/controllers/event.controller.js index 2dc1527..55a6bc6 100644 --- a/src/controllers/event.controller.js +++ b/src/controllers/event.controller.js @@ -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)); }; diff --git a/src/controllers/eventStaff.controller.js b/src/controllers/eventStaff.controller.js index 3a6be7f..8fc3eea 100644 --- a/src/controllers/eventStaff.controller.js +++ b/src/controllers/eventStaff.controller.js @@ -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') diff --git a/src/controllers/form.controller.js b/src/controllers/form.controller.js index df038cf..c6109f5 100644 --- a/src/controllers/form.controller.js +++ b/src/controllers/form.controller.js @@ -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 = diff --git a/src/controllers/participant.controller.js b/src/controllers/participant.controller.js index 6f5d7bb..0eba7fc 100644 --- a/src/controllers/participant.controller.js +++ b/src/controllers/participant.controller.js @@ -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, diff --git a/src/controllers/publicConcierge.controller.js b/src/controllers/publicConcierge.controller.js index 06cdb3c..2895a85 100644 --- a/src/controllers/publicConcierge.controller.js +++ b/src/controllers/publicConcierge.controller.js @@ -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, }), ); diff --git a/src/controllers/publicMicrosite.controller.js b/src/controllers/publicMicrosite.controller.js new file mode 100644 index 0000000..38c12dd --- /dev/null +++ b/src/controllers/publicMicrosite.controller.js @@ -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, +}; diff --git a/src/controllers/publicQuizPoll.controller.js b/src/controllers/publicQuizPoll.controller.js new file mode 100644 index 0000000..82fab10 --- /dev/null +++ b/src/controllers/publicQuizPoll.controller.js @@ -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, +}; diff --git a/src/controllers/quizPoll.controller.js b/src/controllers/quizPoll.controller.js index eb7ffd4..093a89e 100644 --- a/src/controllers/quizPoll.controller.js +++ b/src/controllers/quizPoll.controller.js @@ -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, }; diff --git a/src/controllers/visitor.controller.js b/src/controllers/visitor.controller.js index aba25bd..d4af6f5 100644 --- a/src/controllers/visitor.controller.js +++ b/src/controllers/visitor.controller.js @@ -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, }; \ No newline at end of file diff --git a/src/controllers/visitorAuth.controller.js b/src/controllers/visitorAuth.controller.js index a3095cb..cda05f4 100644 --- a/src/controllers/visitorAuth.controller.js +++ b/src/controllers/visitorAuth.controller.js @@ -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)); diff --git a/src/models/BadgeBatch.js b/src/models/BadgeBatch.js index c16db56..a07f35c 100644 --- a/src/models/BadgeBatch.js +++ b/src/models/BadgeBatch.js @@ -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, diff --git a/src/models/ConciergeBot.js b/src/models/ConciergeBot.js index e98d90c..297d23a 100644 --- a/src/models/ConciergeBot.js +++ b/src/models/ConciergeBot.js @@ -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, diff --git a/src/models/ContentCollection.js b/src/models/ContentCollection.js index 36e751d..a1b144c 100644 --- a/src/models/ContentCollection.js +++ b/src/models/ContentCollection.js @@ -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: '' }, diff --git a/src/models/EventMicrosite.js b/src/models/EventMicrosite.js index ecdea8c..7e33e02 100644 --- a/src/models/EventMicrosite.js +++ b/src/models/EventMicrosite.js @@ -15,7 +15,6 @@ const eventMicrositeSchema = new mongoose.Schema( type: String, trim: true, lowercase: true, - sparse: true, }, enabled: { type: Boolean, default: false }, logo: String, diff --git a/src/models/Form.js b/src/models/Form.js index 5544c2f..b6df9f4 100644 --- a/src/models/Form.js +++ b/src/models/Form.js @@ -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, diff --git a/src/models/FormSubmission.js b/src/models/FormSubmission.js index 0d7608c..4a09542 100644 --- a/src/models/FormSubmission.js +++ b/src/models/FormSubmission.js @@ -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' }, diff --git a/src/models/OrganizationPaymentProfile.js b/src/models/OrganizationPaymentProfile.js index 525ea69..865dcec 100644 --- a/src/models/OrganizationPaymentProfile.js +++ b/src/models/OrganizationPaymentProfile.js @@ -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; diff --git a/src/models/QuizPoll.js b/src/models/QuizPoll.js index 3292e71..786d3c9 100644 --- a/src/models/QuizPoll.js +++ b/src/models/QuizPoll.js @@ -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); diff --git a/src/models/QuizPollResponse.js b/src/models/QuizPollResponse.js index 280150c..1bf70b9 100644 --- a/src/models/QuizPollResponse.js +++ b/src/models/QuizPollResponse.js @@ -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( diff --git a/src/models/Session.js b/src/models/Session.js index 2e0f923..c9381d4 100644 --- a/src/models/Session.js +++ b/src/models/Session.js @@ -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). */ diff --git a/src/models/Speaker.js b/src/models/Speaker.js index 574c424..dababec 100644 --- a/src/models/Speaker.js +++ b/src/models/Speaker.js @@ -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 }, diff --git a/src/routes/badgeBatch.routes.js b/src/routes/badgeBatch.routes.js index 46a067a..1ec5e8d 100644 --- a/src/routes/badgeBatch.routes.js +++ b/src/routes/badgeBatch.routes.js @@ -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 = [ diff --git a/src/routes/participant.routes.js b/src/routes/participant.routes.js index b63ade5..01785e7 100644 --- a/src/routes/participant.routes.js +++ b/src/routes/participant.routes.js @@ -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 } }, diff --git a/src/routes/publicMicrosite.routes.js b/src/routes/publicMicrosite.routes.js new file mode 100644 index 0000000..dfc2402 --- /dev/null +++ b/src/routes/publicMicrosite.routes.js @@ -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, + }, + }, +]; diff --git a/src/routes/publicQuizPoll.routes.js b/src/routes/publicQuizPoll.routes.js new file mode 100644 index 0000000..8f0392c --- /dev/null +++ b/src/routes/publicQuizPoll.routes.js @@ -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 }, + }, +]; diff --git a/src/routes/quizPoll.routes.js b/src/routes/quizPoll.routes.js index ab67654..3a65ef0 100644 --- a/src/routes/quizPoll.routes.js +++ b/src/routes/quizPoll.routes.js @@ -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 } }, diff --git a/src/routes/session.routes.js b/src/routes/session.routes.js index a493ec7..4e502a6 100644 --- a/src/routes/session.routes.js +++ b/src/routes/session.routes.js @@ -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(), diff --git a/src/routes/visitor.routes.js b/src/routes/visitor.routes.js index 79f477a..c84e0d1 100644 --- a/src/routes/visitor.routes.js +++ b/src/routes/visitor.routes.js @@ -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', diff --git a/src/services/eventAnalytics.service.js b/src/services/eventAnalytics.service.js new file mode 100644 index 0000000..ab16afe --- /dev/null +++ b/src/services/eventAnalytics.service.js @@ -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 }; diff --git a/src/utils/badgeRender.js b/src/utils/badgeRender.js index e6e0dc1..d995241 100644 --- a/src/utils/badgeRender.js +++ b/src/utils/badgeRender.js @@ -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} 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} 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, }; diff --git a/src/utils/conciergeGreeting.js b/src/utils/conciergeGreeting.js new file mode 100644 index 0000000..8584af2 --- /dev/null +++ b/src/utils/conciergeGreeting.js @@ -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, +}; diff --git a/src/utils/emailerHtml.js b/src/utils/emailerHtml.js new file mode 100644 index 0000000..be05f1a --- /dev/null +++ b/src/utils/emailerHtml.js @@ -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, '"'); +} + +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 `
${inner}
`; + } + if (blk.type === 'image' && blk.src && String(blk.src).trim()) { + return `

`; + } + 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 `

${label}

`; + } + if (blk.type === 'divider') return `
`; + return ''; + }) + .join('\n'); + + const preHidden = previewText + ? `
${escapeHtml(previewText)}
` + : ''; + + return ` + +${preHidden} + +
+ +${headerImg ? `` : ''} + + + +
+${previewText ? `

${escapeHtml(previewText)}

` : ''} +
${blocksHtml}
+

${escapeHtml(footerText)}

+
+
+`; +} + +module.exports = { + buildEmailerLinkTargets, + buildEmailerHtmlFromContent, + resolveCtaHref, +}; diff --git a/src/utils/eventAgendaData.js b/src/utils/eventAgendaData.js index 01c5685..457d22a 100644 --- a/src/utils/eventAgendaData.js +++ b/src/utils/eventAgendaData.js @@ -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), diff --git a/src/utils/formPublicHelpers.js b/src/utils/formPublicHelpers.js new file mode 100644 index 0000000..425efd0 --- /dev/null +++ b/src/utils/formPublicHelpers.js @@ -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, +}; diff --git a/src/utils/mail.js b/src/utils/mail.js index 961d6df..5de22a5 100644 --- a/src/utils/mail.js +++ b/src/utils/mail.js @@ -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 }; } } diff --git a/src/utils/micrositeBuild.js b/src/utils/micrositeBuild.js index d63a336..758963a 100644 --- a/src/utils/micrositeBuild.js +++ b/src/utils/micrositeBuild.js @@ -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, }; diff --git a/src/utils/queueWorkerKick.js b/src/utils/queueWorkerKick.js new file mode 100644 index 0000000..4e13136 --- /dev/null +++ b/src/utils/queueWorkerKick.js @@ -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, +}; diff --git a/src/utils/quizPollFirebaseSync.js b/src/utils/quizPollFirebaseSync.js index dac25a4..ce2a751 100644 --- a/src/utils/quizPollFirebaseSync.js +++ b/src/utils/quizPollFirebaseSync.js @@ -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; } diff --git a/src/utils/quizPollResultsSummary.js b/src/utils/quizPollResultsSummary.js new file mode 100644 index 0000000..96ab288 --- /dev/null +++ b/src/utils/quizPollResultsSummary.js @@ -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, +}; diff --git a/src/utils/s3Media.js b/src/utils/s3Media.js index 1f4d364..5515003 100644 --- a/src/utils/s3Media.js +++ b/src/utils/s3Media.js @@ -109,5 +109,6 @@ module.exports = { uploadBufferToS3AndGetCloudFrontUrl, getBufferFromS3Key, deleteS3ObjectByKey, + cloudfrontUrlForKey, }; diff --git a/src/utils/visitorBuiltinForms.js b/src/utils/visitorBuiltinForms.js new file mode 100644 index 0000000..88f36b5 --- /dev/null +++ b/src/utils/visitorBuiltinForms.js @@ -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, +}; diff --git a/src/workers/badgeBatchWorker.js b/src/workers/badgeBatchWorker.js index 134e38b..d192d35 100644 --- a/src/workers/badgeBatchWorker.js +++ b/src/workers/badgeBatchWorker.js @@ -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; diff --git a/src/workers/emailQueueWorker.js b/src/workers/emailQueueWorker.js index a2d48f2..f2f82f1 100644 --- a/src/workers/emailQueueWorker.js +++ b/src/workers/emailQueueWorker.js @@ -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 }; diff --git a/src/workers/whatsappQueueWorker.js b/src/workers/whatsappQueueWorker.js index 92fd03e..6438689 100644 --- a/src/workers/whatsappQueueWorker.js +++ b/src/workers/whatsappQueueWorker.js @@ -208,3 +208,5 @@ async function run() { if (require.main === module) { run(); } + +module.exports = { processOne, run };