1251 lines
44 KiB
JavaScript
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,
|
|
}; |