EventBuzz_Backend/src/controllers/visitor.controller.js

1251 lines
44 KiB
JavaScript

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) => ({
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) : '',
}));
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,
};