const mongoose = require('mongoose'); const Session = require('../models/Session'); const Track = require('../models/Track'); const Event = require('../models/Event'); const Form = require('../models/Form'); const EventMicrosite = require('../models/EventMicrosite'); const Question = require('../models/Question'); const RewardPoint = require('../models/RewardPoint'); const QuizPoll = require('../models/QuizPoll'); const QuizPollResponse = require('../models/QuizPollResponse'); const SessionBookmark = require('../models/SessionBookmark'); const { success, error } = require('../utils/response'); const { sendMicrositeContactEmail } = require('../utils/mail'); const { syncSessionQuestionToFirebase } = require('../utils/sessionQuestionFirebaseSync'); const { participantInEvent } = require('../utils/participantMembership'); 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) { if (!doc) return null; const { website, createdAt, updatedAt, _id, organization, settings, ...rest } = doc; const logoUrl = (doc.image && String(doc.image).trim()) || (website?.logo && String(website.logo).trim()) || ''; let safeSettings = settings; if (settings && typeof settings === 'object' && !Array.isArray(settings)) { safeSettings = { ...settings }; delete safeSettings.inviteCode; } const out = { _id: _id != null ? String(_id) : _id, organization: organization != null ? String(organization) : organization, ...rest, ...(safeSettings !== undefined ? { settings: safeSettings } : {}), logoUrl, }; if (Object.prototype.hasOwnProperty.call(out, '__v')) delete out.__v; return out; } function normalizeFolderSlug(input) { return String(input || '') .trim() .toLowerCase() .replace(/[^a-z0-9-]/g, ''); } function normalizeJoinCode(input) { return String(input || '').trim().toUpperCase(); } async function resolveCode(request, h) { const raw = request.payload?.code; const code = String(raw ?? '').trim(); if (!code) { return h.response(error('VALIDATION_ERROR', 'Code is required')).code(400); } const sessionCode = normalizeJoinCode(code); if (sessionCode.length > 0) { const session = await Session.findOne({ joinCode: sessionCode }).lean(); if (session) { const event = await Event.findById(session.event).lean(); if (!event) { return h.response(error('NOT_FOUND', 'Invalid code')).code(404); } return h.response( success({ type: 'session', sessionId: session._id.toString(), eventId: session.event.toString(), }), ); } const track = await Track.findOne({ joinCode: sessionCode }).lean(); if (track) { const ev = await Event.findById(track.event).lean(); if (!ev) { return h.response(error('NOT_FOUND', 'Invalid code')).code(404); } return h.response( success({ type: 'track', trackId: track._id.toString(), eventId: track.event.toString(), }), ); } const eventByJoin = await Event.findOne({ joinCode: sessionCode }).lean(); if (eventByJoin) { return h.response( success({ type: 'event', eventId: eventByJoin._id.toString(), }), ); } } const folder = normalizeFolderSlug(code); if (!folder) { return h.response(error('NOT_FOUND', 'Invalid code')).code(404); } const microsite = await EventMicrosite.findOne({ publishFolder: folder }).select('event').lean(); if (microsite && microsite.event) { return h.response( success({ type: 'event', eventId: microsite.event.toString(), micrositeId: microsite._id.toString(), }), ); } const event = await Event.findOne({ 'website.folder': folder }).lean(); if (!event) { return h.response(error('NOT_FOUND', 'Invalid code')).code(404); } return h.response( success({ type: 'event', eventId: event._id.toString(), }), ); } async function loginPreview(request, h) { const { eventId } = request.params; if (!eventId || !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 ratings = await computeEventRatingsSummary(eventId); return h.response( success({ ...publicEventLoginPayload(event), ratings, }), ); } /** GET …/visitor/events/{eventId}/tracks — public; tracks for agenda links */ async function listVisitorEventTracks(request, h) { const { eventId } = request.params; if (!eventId || !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 tracks = await Track.find({ event: eventId }).sort({ name: 1 }).lean(); return h.response(success({ tracks: tracks.map((t) => publicTrackPreview(t)) })); } /** GET …/visitor/events/{eventId}/agenda — public; all non-cancelled sessions for schedule UI */ async function listVisitorEventAgenda(request, h) { const { eventId } = request.params; if (!eventId || !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 sessions = await Session.find({ event: eventId, status: { $ne: 'cancelled' }, }) .populate({ path: 'speakers', populate: { path: 'organization', select: 'name' }, }) .populate({ path: 'moderator', populate: { path: 'organization', select: 'name' }, }) .populate('track') .sort({ startTime: 1 }) .lean(); return h.response(success({ sessions: sessions.map((s) => publicSessionPreview(s)) })); } /** GET …/visitor/events/{eventId}/quiz-polls — public; session-scoped items for deep links */ async function listVisitorEventQuizPolls(request, h) { const { eventId } = request.params; if (!eventId || !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 items = await QuizPoll.find({ event: eventId, deletedAt: null, session: { $exists: true, $ne: null }, $or: [{ 'settings.status': { $exists: false } }, { 'settings.status': 'active' }], }) .select('title type session') .sort({ title: 1 }) .lean(); const quizPolls = items .filter((q) => q.session) .map((q) => ({ _id: String(q._id), title: q.title != null ? q.title : '', type: q.type != null ? q.type : 'poll', session: String(q.session), })); return h.response(success({ quizPolls })); } function publicSpeaker(doc) { if (!doc || typeof doc !== 'object' || doc._id == null) return null; const isPrivate = doc.profileVisibility === 'private'; const expertise = Array.isArray(doc.expertise) ? doc.expertise.map((e) => String(e || '').trim()).filter(Boolean) : []; const bioRaw = doc.bio != null ? String(doc.bio).trim() : ''; const out = { _id: String(doc._id), firstName: doc.firstName, lastName: doc.lastName, title: doc.title, photo: doc.photo, speakerRole: doc.speakerRole, ...(bioRaw ? { bio: bioRaw } : {}), ...(expertise.length ? { expertise } : {}), }; if (!isPrivate) { const orgFromRef = doc.organization && typeof doc.organization === 'object' && doc.organization.name != null ? String(doc.organization.name).trim() : ''; const orgFromField = doc.organizationName != null ? String(doc.organizationName).trim() : ''; const organizationName = orgFromField || orgFromRef; if (organizationName) { out.organizationName = organizationName; } const sl = doc.socialLinks && typeof doc.socialLinks === 'object' ? doc.socialLinks : {}; const socialLinks = {}; for (const key of ['linkedin', 'twitter', 'website']) { const v = sl[key]; if (v != null && String(v).trim()) { socialLinks[key] = String(v).trim(); } } if (Object.keys(socialLinks).length) { out.socialLinks = socialLinks; } } return out; } function publicSessionPreview(session) { const speakers = (session.speakers || []).map(publicSpeaker).filter(Boolean); const moderator = publicSpeaker(session.moderator); let track = null; if (session.track) { const t = session.track; if (t && typeof t === 'object' && t._id) { track = { _id: String(t._id), name: t.name, hall: t.hall, trackType: t.trackType, }; } } return { _id: String(session._id), event: String(session.event), track, title: session.title, description: session.description, type: session.type, startTime: session.startTime, endTime: session.endTime, location: session.location, capacity: session.capacity, status: session.status, joinCode: session.joinCode, speakers, 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, }; } /** Public session details for Eventbuzz; session must belong to event_id query. */ async function sessionPreview(request, h) { const { sessionId } = request.params; const eventId = request.query.event_id; if (!eventId || !mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(sessionId)) { return h.response(error('VALIDATION_ERROR', 'Invalid event or session id')).code(400); } const session = await Session.findById(sessionId) .populate({ path: 'speakers', populate: { path: 'organization', select: 'name' }, }) .populate({ path: 'moderator', populate: { path: 'organization', select: 'name' }, }) .populate('track') .lean(); if (!session || String(session.event) !== String(eventId)) { return h.response(error('NOT_FOUND', 'Session not found')).code(404); } return h.response(success(publicSessionPreview(session))); } function publicTrackPreview(track) { return { _id: String(track._id), event: String(track.event), name: track.name, hall: track.hall, trackType: track.trackType, status: track.status, joinCode: track.joinCode, }; } /** Public track details; track must belong to event_id query. */ async function trackPreview(request, h) { const { trackId } = request.params; const eventId = request.query.event_id; if (!eventId || !mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(trackId)) { return h.response(error('VALIDATION_ERROR', 'Invalid event or track id')).code(400); } const track = await Track.findById(trackId).lean(); if (!track || String(track.event) !== String(eventId)) { return h.response(error('NOT_FOUND', 'Track not found')).code(404); } return h.response(success(publicTrackPreview(track))); } function visitorQuestionAuthor(q) { if (q.isAnonymous) return 'Anonymous'; const p = q.participant; if (!p || typeof p !== 'object') return 'Anonymous'; if (p.displayName && String(p.displayName).trim()) return String(p.displayName).trim(); const n = [p.firstName, p.lastName].filter(Boolean).join(' ').trim(); if (n) return n; if (p.email && String(p.email).trim()) return String(p.email).split('@')[0] || 'Guest'; 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), author: visitorQuestionAuthor(q), text: q.question, up: q.upvotes ?? 0, down: q.downvotes ?? 0, createdAt: q.createdAt, }; } /** GET …/visitor/sessions/{id}/questions?event_id= */ async function listSessionQuestions(request, h) { const { sessionId } = request.params; const eventId = request.query.event_id; if (!mongoose.Types.ObjectId.isValid(sessionId) || !mongoose.Types.ObjectId.isValid(eventId)) { return h.response(error('VALIDATION_ERROR', 'Invalid event or session id')).code(400); } const session = await Session.findById(sessionId).lean(); 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, audienceListed: { $ne: false }, status: { $ne: 'archived' }, }) .populate('participant') .sort({ createdAt: -1 }) .lean(); return h.response(success({ questions: rows.map(publicVisitorQuestion) })); } /** POST …/visitor/sessions/{id}/questions?event_id= (optional jwt-attendee) */ async function createSessionQuestion(request, h) { const { sessionId } = request.params; const eventId = request.query.event_id; const { question: text, isAnonymous } = request.payload; if (!mongoose.Types.ObjectId.isValid(sessionId) || !mongoose.Types.ObjectId.isValid(eventId)) { return h.response(error('VALIDATION_ERROR', 'Invalid event or session id')).code(400); } const session = await Session.findById(sessionId).lean(); 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; if (auth.isAuthenticated && auth.credentials?.participant && !isAnonymous) { const p = auth.credentials.participant; if (participantInEvent(p, eventId)) { participantId = p._id; } } const doc = await Question.create({ event: eventId, session: sessionId, participant: participantId, question: text, isAnonymous: Boolean(isAnonymous), }); const populated = await Question.findById(doc._id).populate('participant').lean(); await syncSessionQuestionToFirebase(populated); let pointsEarned = 0; if (participantId) { const [eventLean, sessionLean] = await Promise.all([ Event.findById(eventId).select('rewardRules').lean(), Session.findById(sessionId).select('rewardRules').lean(), ]); const rules = mergeRules(eventLean?.rewardRules || {}, sessionLean?.rewardRules || {}, {}); const ar = await awardIfNew({ participantId, eventId, points: rules.questionAsk, source: 'question_ask', sourceId: `question_ask:${doc._id}`, description: 'Asked a question', metadata: { ruleSnapshot: rules, sessionId: String(sessionId) }, }); pointsEarned = ar.pointsAwarded || 0; } const body = { question: publicVisitorQuestion(populated), ...(pointsEarned > 0 ? { reward: { pointsEarned } } : {}), }; return h.response(success(body, 'Question submitted', 201)).code(201); } /** @returns {boolean} whether the vote state changed */ function applyAudienceVoteDoc(question, voterKey, direction) { const raw = question.audienceVotes; const votes = Array.isArray(raw) ? raw.map((v) => ({ key: String(v.key), dir: v.dir === 'down' ? 'down' : 'up' })) : []; const idx = votes.findIndex((v) => v.key === voterKey); const prev = idx >= 0 ? votes[idx].dir : null; if (direction === 'up') { if (prev === 'up') { return false; } if (prev === 'down') { votes[idx].dir = 'up'; question.upvotes = (question.upvotes || 0) + 1; question.downvotes = Math.max(0, (question.downvotes || 0) - 1); } else { votes.push({ key: voterKey, dir: 'up' }); question.upvotes = (question.upvotes || 0) + 1; } } else if (direction === 'down') { if (prev === 'down') { return false; } if (prev === 'up') { votes[idx].dir = 'down'; question.downvotes = (question.downvotes || 0) + 1; question.upvotes = Math.max(0, (question.upvotes || 0) - 1); } else { votes.push({ key: voterKey, dir: 'down' }); question.downvotes = (question.downvotes || 0) + 1; } } question.audienceVotes = votes; return true; } function audienceYourVote(question, voterKey) { const v = (question.audienceVotes || []).find((x) => String(x.key) === voterKey); return v ? v.dir : null; } /** POST …/visitor/sessions/{sessionId}/questions/{questionId}/vote?event_id= */ async function voteSessionQuestion(request, h) { const { sessionId, questionId } = request.params; const eventId = request.query.event_id; const { voterKey, direction } = request.payload; if ( !mongoose.Types.ObjectId.isValid(sessionId) || !mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(questionId) ) { return h.response(error('VALIDATION_ERROR', 'Invalid ids')).code(400); } const session = await Session.findById(sessionId).lean(); 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, session: sessionId, event: eventId, }); if (!question) { return h.response(error('NOT_FOUND', 'Question not found')).code(404); } const auth = request.auth; let credParticipant = null; if (auth && auth.isAuthenticated && auth.credentials && auth.credentials.participant) { const p = auth.credentials.participant; if (participantInEvent(p, eventId)) { credParticipant = p; } } const audienceKey = credParticipant ? String(credParticipant._id) : String(voterKey).trim(); const voteChanged = applyAudienceVoteDoc(question, audienceKey, direction); await question.save(); const populated = await Question.findById(question._id).populate('participant').lean(); await syncSessionQuestionToFirebase(populated); let pointsEarned = 0; if (credParticipant && voteChanged) { const [eventLean, sessionLean] = await Promise.all([ Event.findById(eventId).select('rewardRules').lean(), Session.findById(sessionId).select('rewardRules').lean(), ]); const rules = mergeRules(eventLean?.rewardRules || {}, sessionLean?.rewardRules || {}, {}); const pts = direction === 'up' ? rules.questionUpvote : rules.questionDownvote; const ar = await awardIfNew({ participantId: credParticipant._id, eventId, points: pts, source: 'question_vote', sourceId: `question_vote:${question._id}:${credParticipant._id}:${direction}`, description: direction === 'up' ? 'Upvoted a question' : 'Downvoted a question', metadata: { direction, ruleSnapshot: rules, sessionId: String(sessionId) }, }); pointsEarned = ar.pointsAwarded || 0; } const yv = audienceYourVote(question, audienceKey); return h.response( success({ questionId: String(question._id), up: question.upvotes ?? 0, down: question.downvotes ?? 0, yourVote: yv, ...(pointsEarned > 0 ? { reward: { pointsEarned } } : {}), }), ); } 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; 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 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, participant: participant._id, }).lean(); if (existing) { return h.response(error('CONFLICT', 'You have already submitted a response')).code(409); } } const doc = await QuizPollResponse.create({ quizPoll: quizPollId, participant: participant._id, 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) { const s = await Session.findById(quizPoll.session).select('rewardRules').lean(); if (s) sessionRules = s.rewardRules || {}; } const quizRules = (quizPoll.settings && quizPoll.settings.rewardRules) || {}; const rules = mergeRules(eventLean?.rewardRules || {}, sessionRules, quizRules); const typeLower = String(quizPoll.type || '').toLowerCase(); let pointsEarned = 0; if (typeLower === 'quiz') { 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 || []; 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, eventId, points: perCorrect, source: 'quiz_poll', sourceId: `quiz_correct:${quizPollId}:${responseId}:q:${i}`, description: 'Correct quiz answer', metadata: { quizPollId: String(quizPollId), questionIndex: i }, }); pointsEarned += arCor.pointsAwarded || 0; } } } else if (typeLower === 'poll' || typeLower === 'survey') { const arPoll = await awardIfNew({ participantId, eventId, points: rules.pollParticipation, source: 'poll', sourceId: `poll_submit:${quizPollId}:${responseId}`, description: typeLower === 'survey' ? 'Survey submission' : 'Poll participation', metadata: { ruleSnapshot: rules, quizPollId: String(quizPollId) }, }); pointsEarned += arPoll.pointsAwarded || 0; } return pointsEarned; } /** GET …/visitor/events/{eventId}/quiz-polls/{quizPollId}/results-summary — poll/survey aggregates; attendee JWT */ async function getVisitorQuizPollResultsSummary(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 settings = quizPoll.settings || {}; if (settings.showResults === false) { return h.response(error('FORBIDDEN', 'Results are not available for this activity')).code(403); } const t = String(quizPoll.type || '').toLowerCase(); if (t !== 'poll' && t !== 'survey') { return h.response(error('BAD_REQUEST', 'Aggregated results are only available for polls and surveys')).code(400); } const all = await QuizPollResponse.find({ quizPoll: quizPollId }).lean(); const questions = buildVisitorPollResultsSummary(quizPoll, all); return h.response(success({ totalSubmissions: all.length, questions })); } /** GET …/visitor/events/{eventId}/session-bookmarks — attendee JWT */ async function listVisitorSessionBookmarks(request, h) { const { eventId } = request.params; const participant = request.auth.credentials.participant; if (!mongoose.Types.ObjectId.isValid(eventId)) { return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400); } if (!participantInEvent(participant, eventId)) { return h.response(error('FORBIDDEN', 'Event mismatch')).code(403); } const rows = await SessionBookmark.find({ participant: participant._id, event: eventId }) .select('session') .lean(); const sessionIds = rows.map((r) => String(r.session)); return h.response(success({ sessionIds })); } /** PUT …/visitor/events/{eventId}/session-bookmarks — attendee JWT; body { sessionId, bookmarked } */ async function putVisitorSessionBookmark(request, h) { const { eventId } = request.params; const { sessionId, bookmarked } = request.payload; const participant = request.auth.credentials.participant; if ( !mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(sessionId) ) { return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400); } if (!participantInEvent(participant, eventId)) { return h.response(error('FORBIDDEN', 'Event mismatch')).code(403); } const session = await Session.findById(sessionId).select('event').lean(); if (!session || String(session.event) !== String(eventId)) { return h.response(error('NOT_FOUND', 'Session not found')).code(404); } if (bookmarked) { await SessionBookmark.findOneAndUpdate( { participant: participant._id, session: sessionId }, { $set: { event: eventId, participant: participant._id, session: sessionId, }, }, { upsert: true, new: true }, ); } else { await SessionBookmark.deleteOne({ participant: participant._id, session: sessionId }); } const rows = await SessionBookmark.find({ participant: participant._id, event: eventId }) .select('session') .lean(); const sessionIds = rows.map((r) => String(r.session)); return h.response(success({ sessionIds, bookmarked, sessionId: String(sessionId) })); } async function participantLeaderboardSelf(eventId, participantId) { const eid = new mongoose.Types.ObjectId(eventId); const pid = new mongoose.Types.ObjectId(participantId); const mineAgg = await RewardPoint.aggregate([ { $match: { event: eid, participant: pid, type: { $in: ['earned', 'redeemed'] } } }, { $group: { _id: null, earned: { $sum: { $cond: [{ $eq: ['$type', 'earned'] }, '$points', 0] } }, redeemed: { $sum: { $cond: [{ $eq: ['$type', 'redeemed'] }, '$points', 0] } }, }, }, { $project: { total: { $subtract: ['$earned', '$redeemed'] } } }, ]); const total = mineAgg[0]?.total ?? 0; if (total <= 0) return { total: 0, rank: null }; const higherAgg = await RewardPoint.aggregate([ { $match: { event: eid, type: { $in: ['earned', 'redeemed'] } } }, { $group: { _id: '$participant', earned: { $sum: { $cond: [{ $eq: ['$type', 'earned'] }, '$points', 0] } }, redeemed: { $sum: { $cond: [{ $eq: ['$type', 'redeemed'] }, '$points', 0] } }, }, }, { $project: { net: { $subtract: ['$earned', '$redeemed'] } } }, { $match: { net: { $gt: total } } }, { $count: 'n' }, ]); const rank = (higherAgg[0]?.n ?? 0) + 1; return { total, rank }; } /** GET …/visitor/events/{eventId}/leaderboard — public; optional JWT adds `your` rank + total */ async function visitorEventLeaderboard(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).select('_id').lean(); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); const limit = Math.min(100, Math.max(5, parseInt(request.query.limit, 10) || 50)); const eid = event._id; const aggregated = await RewardPoint.aggregate([ { $match: { event: eid, type: { $in: ['earned', 'redeemed'] } } }, { $group: { _id: '$participant', earned: { $sum: { $cond: [{ $eq: ['$type', 'earned'] }, '$points', 0] } }, redeemed: { $sum: { $cond: [{ $eq: ['$type', 'redeemed'] }, '$points', 0] } }, }, }, { $project: { participantId: '$_id', 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', displayName: '$p.displayName', }, }, ]); const leaderboard = aggregated.map((row, idx) => { const dn = [row.firstName, row.lastName].filter(Boolean).join(' ').trim(); const label = dn || (row.displayName && String(row.displayName).trim()) || 'Participant'; return { rank: idx + 1, participantId: String(row.participantId), total: row.total, displayName: label, }; }); let your = null; const auth = request.auth; if (auth && auth.isAuthenticated && auth.credentials && auth.credentials.participant) { const p = auth.credentials.participant; if (participantInEvent(p, eventId)) { your = await participantLeaderboardSelf(eventId, p._id); } } return h.response(success({ leaderboard, your })); } /** GET …/visitor/events/{eventId}/reward-balance — attendee JWT; total points for this event only */ async function visitorEventRewardBalance(request, h) { const { eventId } = request.params; const participant = request.auth.credentials.participant; if (!mongoose.Types.ObjectId.isValid(eventId)) { return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400); } if (!participantInEvent(participant, eventId)) { return h.response(error('FORBIDDEN', 'Event mismatch')).code(403); } const points = await RewardPoint.find({ participant: participant._id, event: eventId }).lean(); const earned = points.filter((p) => p.type === 'earned').reduce((s, p) => s + (p.points || 0), 0); const redeemed = points.filter((p) => p.type === 'redeemed').reduce((s, p) => s + (p.points || 0), 0); const total = earned - redeemed; return h.response(success({ total })); } /** GET …/visitor/events/{eventId}/reward-history — attendee JWT; paginated ledger for this event */ async function visitorEventRewardHistory(request, h) { const { eventId } = request.params; const participant = request.auth.credentials.participant; if (!mongoose.Types.ObjectId.isValid(eventId)) { return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400); } if (!participantInEvent(participant, eventId)) { return h.response(error('FORBIDDEN', 'Event mismatch')).code(403); } const page = Math.max(1, parseInt(request.query.page, 10) || 1); const limit = Math.min(100, Math.max(1, parseInt(request.query.limit, 10) || 20)); const skip = (page - 1) * limit; const [items, total] = await Promise.all([ RewardPoint.find({ participant: participant._id, event: eventId }) .sort({ createdAt: -1 }) .skip(skip) .limit(limit) .lean(), RewardPoint.countDocuments({ participant: participant._id, event: eventId }), ]); const rows = items.map((p) => { let metaObj; const m = p.metadata; if (m instanceof Map) metaObj = Object.fromEntries(m); else if (m && typeof m === 'object') metaObj = { ...m }; return { _id: String(p._id), points: p.points, type: p.type, source: p.source, description: p.description, createdAt: p.createdAt, metadata: metaObj, }; }); return h.response(success({ items: rows, total, page, limit })); } /** GET …/visitor/events/{eventId}/feedback-forms/{formId} — public schema for rendering (org must match event). */ function visitorFeedbackFormPayload(form) { const fields = (form.fields || []).map((f) => { const remarks = f.remarks != null ? String(f.remarks).trim() : ''; return { label: f.label, type: f.type, required: !!f.required, options: Array.isArray(f.options) ? f.options.map((o) => String(o)) : [], placeholder: f.placeholder ? String(f.placeholder) : '', ...(remarks ? { remarks } : {}), }; }); 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({ hasResponse: true, answeredQuestionIndices, totalScore: doc.totalScore, }), ); } async function submitMicrositeContact(request, h) { const p = request.payload; if (p._gotcha) return h.response(error('VALIDATION_ERROR', 'Invalid request')).code(400); const event = await Event.findById(p.eventId).select('name').lean(); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); const sent = await sendMicrositeContactEmail({ to: p.notifyEmail, eventName: event.name, visitorEmail: p.email, firstName: p.firstName, lastName: p.lastName, message: p.message, }); if (!sent) { return h.response(error('SERVER_ERROR', 'Could not send message. Try again later.')).code(500); } return h.response(success({ ok: true }, 'Message sent', 201)).code(201); } module.exports = { resolveCode, loginPreview, listVisitorEventTracks, listVisitorEventAgenda, listVisitorEventQuizPolls, sessionPreview, trackPreview, listSessionQuestions, createSessionQuestion, voteSessionQuestion, submitVisitorQuizPollResponse, getVisitorQuizPollMyResponse, getVisitorQuizPollResultsSummary, listVisitorSessionBookmarks, putVisitorSessionBookmark, visitorEventRewardBalance, visitorEventRewardHistory, visitorEventLeaderboard, submitMicrositeContact, visitorFeedbackFormPreview, visitorFeedbackFormResolve, };