on the way

This commit is contained in:
Ravi Gulhane 2026-04-16 10:09:31 +05:30
parent 7b1777c4ba
commit a257817f8c
29 changed files with 1587 additions and 455 deletions

View File

@ -33,6 +33,8 @@ AWS_SECRET_ACCESS_KEY=ZpIxby1vbCLGB7CPWZsAltZBEqkhwzgBpFOKDGj8
AWS_DEFAULT_REGION=ap-south-1
AWS_BUCKET=rapportapp
AWS_URL=https://did94hhhcgq1d.cloudfront.net
# Amazon Bedrock (AI event generation). Model must be enabled in your AWS account for this region.
# BEDROCK_MODEL_ID=anthropic.claude-3-haiku-20240307-v1:0
# Public API base URL injected into published microsite HTML for contact forms (e.g. https://api.example.com/api or http://localhost:3001/api)
MICROSITE_API_BASE_URL=http://localhost:3001/api

947
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,7 @@
"backfill:tenant-organization": "node scripts/backfill-tenant-organization.js"
},
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
"@aws-sdk/client-s3": "^3.1012.0",
"@aws-sdk/lib-storage": "^3.1012.0",
"@hapi/hapi": "^21.3.0",
@ -24,9 +25,9 @@
"joi": "^17.13.0",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.0.0",
"razorpay": "^2.9.6",
"otplib": "^12.0.1",
"nodemailer": "^6.9.7",
"otplib": "^12.0.1",
"razorpay": "^2.9.6",
"winston": "^3.11.0"
},
"devDependencies": {

View File

@ -1,5 +1,5 @@
/**
* One-off: set joinCode on all sessions and tracks (same algorithm as eventBuzzCode util).
* One-off: set joinCode on all sessions, tracks, and events (same algorithm as eventBuzzCode util).
* Run: cd backend && npm run backfill:join-codes
*/
const path = require('path');
@ -8,6 +8,7 @@ require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const mongoose = require('mongoose');
const Session = require(path.join(__dirname, '..', 'src', 'models', 'Session'));
const Track = require(path.join(__dirname, '..', 'src', 'models', 'Track'));
const Event = require(path.join(__dirname, '..', 'src', 'models', 'Event'));
const { eventBuzzCodeFor } = require(path.join(__dirname, '..', 'src', 'utils', 'eventBuzzCode'));
async function run() {
@ -36,6 +37,15 @@ async function run() {
}
console.log(`Backfilled joinCode for ${nt} track(s).`);
const events = await Event.find({}).select('_id').lean();
let ne = 0;
for (const ev of events) {
const jc = eventBuzzCodeFor(ev._id.toString(), 'event');
await Event.updateOne({ _id: ev._id }, { $set: { joinCode: jc } });
ne += 1;
}
console.log(`Backfilled joinCode for ${ne} event(s).`);
await mongoose.disconnect();
}

View File

@ -42,7 +42,19 @@ const WEBSITE_SECTION_TYPES = [
'tickets',
];
const DESIGN_TEMPLATE_TYPES = ['brochure', 'microsite', 'badge', 'emailer', 'certificate'];
const REWARD_SOURCES = ['registration', 'attendance', 'feedback', 'quiz', 'referral', 'manual'];
const REWARD_SOURCES = [
'registration',
'attendance',
'feedback',
'quiz',
'referral',
'manual',
'question_ask',
'question_vote',
'quiz_poll',
'poll',
'rating',
];
module.exports = {
ROLES,

View File

@ -5,6 +5,12 @@ const { success, error } = require('../utils/response');
const { resolveScopedOrganizationId } = require('../utils/tenantListScope');
const { uploadBufferToS3AndGetCloudFrontUrl } = require('../utils/s3Media');
const { registrationUrlForForm } = require('../utils/micrositeBuild');
const {
extractEventFieldsFromDescription,
EVENT_TYPES: AI_EVENT_TYPES,
EVENT_STATUS: AI_EVENT_STATUS,
LOCATION_TYPES: AI_LOCATION_TYPES,
} = require('../utils/bedrockExtractEvent');
function parseDataUrl(dataUrl) {
if (typeof dataUrl !== 'string') return null;
@ -92,34 +98,59 @@ async function assignCreatorToEventStaff(creatorUserId, orgId, eventId) {
await map.save();
}
const list = async (request, h) => {
const { organization: organizationQuery, status, type, page = 1, limit = 20 } = request.query;
const user = request.auth.credentials.user;
const scoped = resolveScopedOrganizationId(user, organizationQuery);
if (scoped.error) {
return h.response(error(scoped.error.code, scoped.error.message)).code(400);
}
const filter = { organization: scoped.organizationId };
if (status) filter.status = status;
if (type) filter.type = type;
function defaultAiDateRange() {
const start = new Date();
start.setUTCDate(start.getUTCDate() + 7);
start.setUTCHours(10, 0, 0, 0);
const end = new Date(start.getTime());
end.setUTCHours(18, 0, 0, 0);
return { start, end };
}
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
const [events, total] = await Promise.all([
Event.find(filter).populate('organization').sort({ startDate: -1 }).skip(skip).limit(parseInt(limit, 10)).lean(),
Event.countDocuments(filter),
]);
return h.response(success({ events, total, page: parseInt(page, 10), limit: parseInt(limit, 10) }))
.header('X-Total-Count', total);
};
function mergeAiExtractedPayload(ai) {
const { start: ds, end: de } = defaultAiDateRange();
const norm = (v) => (typeof v === 'string' ? v.toLowerCase().trim() : '');
const type = AI_EVENT_TYPES.includes(norm(ai.type)) ? norm(ai.type) : 'conference';
const status = AI_EVENT_STATUS.includes(norm(ai.status)) ? norm(ai.status) : 'draft';
const locType = AI_LOCATION_TYPES.includes(norm(ai.locationType)) ? norm(ai.locationType) : 'physical';
const create = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = request.payload.organization || (user.organization?._id || user.organization);
if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400);
if (user.role !== 'super_admin' && (user.organization?._id?.toString() !== orgId?.toString())) {
return h.response(error('FORBIDDEN', 'Cannot create event for this organization')).code(403);
let name = typeof ai.name === 'string' ? ai.name.trim() : '';
if (name.length < 3) {
const fromDesc = typeof ai.description === 'string' ? ai.description.trim().slice(0, 80) : '';
name = (fromDesc || 'New event').slice(0, 100);
if (name.length < 3) name = 'New event';
}
const { metadata: metaIn, ...rest } = request.payload;
let startDate = ai.startDate ? new Date(ai.startDate) : ds;
let endDate = ai.endDate ? new Date(ai.endDate) : de;
if (Number.isNaN(startDate.getTime())) startDate = ds;
if (Number.isNaN(endDate.getTime())) endDate = de;
if (endDate <= startDate) {
endDate = new Date(startDate.getTime() + 8 * 60 * 60 * 1000);
}
const subtitle = typeof ai.subtitle === 'string' ? ai.subtitle : '';
const description = typeof ai.description === 'string' ? ai.description : '';
const location = typeof ai.location === 'string' ? ai.location : '';
const virtualLink = typeof ai.virtualLink === 'string' ? ai.virtualLink : '';
const tags = Array.isArray(ai.tags) ? ai.tags.filter((t) => typeof t === 'string') : [];
return {
name,
subtitle,
description,
type,
status,
startDate,
endDate,
location,
locationType: locType,
virtualLink,
tags,
};
}
async function persistEventWithSideEffects(user, orgId, rest, metaIn) {
const event = await Event.create({
...rest,
organization: orgId,
@ -131,7 +162,6 @@ const create = async (request, h) => {
},
});
// Convert base64 images to CloudFront URLs.
const eventId = event._id?.toString?.() || event._id;
let didUpdate = false;
if (rest.image != null) {
@ -169,6 +199,61 @@ const create = async (request, h) => {
console.error('assignCreatorToEventStaff', err);
}
return event;
}
const list = async (request, h) => {
const { organization: organizationQuery, status, type, page = 1, limit = 20 } = request.query;
const user = request.auth.credentials.user;
const scoped = resolveScopedOrganizationId(user, organizationQuery);
if (scoped.error) {
return h.response(error(scoped.error.code, scoped.error.message)).code(400);
}
const filter = { organization: scoped.organizationId };
if (status) filter.status = status;
if (type) filter.type = type;
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
const [events, total] = await Promise.all([
Event.find(filter).populate('organization').sort({ startDate: -1 }).skip(skip).limit(parseInt(limit, 10)).lean(),
Event.countDocuments(filter),
]);
return h.response(success({ events, total, page: parseInt(page, 10), limit: parseInt(limit, 10) }))
.header('X-Total-Count', total);
};
const create = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = request.payload.organization || (user.organization?._id || user.organization);
if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400);
if (user.role !== 'super_admin' && (user.organization?._id?.toString() !== orgId?.toString())) {
return h.response(error('FORBIDDEN', 'Cannot create event for this organization')).code(403);
}
const { metadata: metaIn, ...rest } = request.payload;
const event = await persistEventWithSideEffects(user, orgId, rest, metaIn);
return h.response(success(event, 'Event created', 201)).code(201);
};
const generateFromAi = async (request, h) => {
const user = request.auth.credentials.user;
const orgId = request.payload.organization || (user.organization?._id || user.organization);
if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400);
if (user.role !== 'super_admin' && (user.organization?._id?.toString() !== orgId?.toString())) {
return h.response(error('FORBIDDEN', 'Cannot create event for this organization')).code(403);
}
const { description } = request.payload;
let extracted;
try {
extracted = await extractEventFieldsFromDescription(description);
} catch (err) {
console.error('extractEventFieldsFromDescription', err);
const msg = err.message || 'AI extraction failed';
return h.response(error('BEDROCK_ERROR', msg)).code(502);
}
const merged = mergeAiExtractedPayload(extracted);
const event = await persistEventWithSideEffects(user, orgId, merged, undefined);
return h.response(success(event, 'Event created', 201)).code(201);
};
@ -355,4 +440,14 @@ const registrationLink = async (request, h) => {
return h.response(success({ url }));
};
module.exports = { list, create, get, update, remove, analytics, duplicate, registrationLink };
module.exports = {
list,
create,
generateFromAi,
get,
update,
remove,
analytics,
duplicate,
registrationLink,
};

View File

@ -66,6 +66,30 @@ const update = async (request, h) => {
return h.response(success(question));
};
const PATCH_KEYS = ['question', 'isAnonymous', 'shortlisted', 'audienceListed', 'status'];
const patch = async (request, h) => {
const question = await Question.findById(request.params.id);
if (!question) return h.response(error('NOT_FOUND', 'Question not found')).code(404);
const event = await Event.findById(question.event);
const user = request.auth.credentials.user;
if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const payload = request.payload || {};
for (const key of PATCH_KEYS) {
if (Object.prototype.hasOwnProperty.call(payload, key)) {
question[key] = payload[key];
}
}
await question.save();
if (question.session) {
const lean = await Question.findById(question._id).populate('participant').lean();
await syncSessionQuestionToFirebase(lean);
}
return h.response(success(question));
};
const remove = async (request, h) => {
const question = await Question.findById(request.params.id);
if (!question) return h.response(error('NOT_FOUND', 'Question not found')).code(404);
@ -127,4 +151,4 @@ const sessionQuestions = async (request, h) => {
return h.response(success({ questions }));
};
module.exports = { list, create, get, update, remove, upvote, answer, sessionQuestions };
module.exports = { list, create, get, update, patch, remove, upvote, answer, sessionQuestions };

View File

@ -83,14 +83,33 @@ const leaderboard = async (request, h) => {
if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const lim = Math.min(500, Math.max(1, parseInt(request.query.limit, 10) || 100));
const aggregated = await RewardPoint.aggregate([
{ $match: { event: event._id, type: 'earned' } },
{ $group: { _id: '$participant', total: { $sum: '$points' } } },
{ $match: { event: event._id, 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'] }, earned: 1, redeemed: 1 } },
{ $match: { total: { $gt: 0 } } },
{ $sort: { total: -1 } },
{ $limit: 100 },
{ $limit: lim },
{ $lookup: { from: 'participants', localField: '_id', foreignField: '_id', as: 'p' } },
{ $unwind: '$p' },
{ $project: { participantId: '$_id', total: 1, firstName: '$p.firstName', lastName: '$p.lastName', email: '$p.email' } },
{
$project: {
participantId: '$_id',
total: 1,
earned: 1,
redeemed: 1,
firstName: '$p.firstName',
lastName: '$p.lastName',
email: '$p.email',
},
},
]);
return h.response(success({ leaderboard: aggregated }));
};

View File

@ -1,6 +1,8 @@
const mongoose = require('mongoose');
const Session = require('../models/Session');
const Event = require('../models/Event');
const Track = require('../models/Track');
const Form = require('../models/Form');
const { success, error } = require('../utils/response');
function normalizeSessionPayload(payload, eventId) {
@ -8,9 +10,32 @@ function normalizeSessionPayload(payload, eventId) {
if (!body.track || String(body.track).trim() === '') {
delete body.track;
}
for (const key of ['audienceFeedbackForm', 'audienceRatingsForm']) {
if (key in body && (body[key] === '' || body[key] === undefined)) body[key] = null;
}
return body;
}
async function assertFeedbackFormForEvent(formId, eventId) {
if (formId == null || formId === '') return null;
if (!mongoose.Types.ObjectId.isValid(String(formId))) {
throw Object.assign(new Error('Invalid form id'), { code: 400 });
}
const [event, form] = await Promise.all([
Event.findById(eventId).select('organization').lean(),
Form.findById(String(formId)).select('tenantOrganization type').lean(),
]);
if (!event) throw Object.assign(new Error('Event not found'), { code: 404 });
if (!form) throw Object.assign(new Error('Form not found'), { code: 400 });
if (form.type !== 'feedback') {
throw Object.assign(new Error('Only feedback-type forms can be linked here'), { code: 400 });
}
if (String(form.tenantOrganization) !== String(event.organization)) {
throw Object.assign(new Error('Form does not belong to this event organization'), { code: 400 });
}
return String(formId);
}
const list = async (request, h) => {
const { eventId } = request.params;
const event = await Event.findById(eventId);
@ -36,6 +61,17 @@ const create = async (request, h) => {
const tr = await Track.findOne({ _id: body.track, event: eventId });
if (!tr) return h.response(error('VALIDATION_ERROR', 'Track does not belong to this event')).code(400);
}
try {
if (body.audienceFeedbackForm !== undefined && body.audienceFeedbackForm !== null) {
body.audienceFeedbackForm = await assertFeedbackFormForEvent(body.audienceFeedbackForm, eventId);
}
if (body.audienceRatingsForm !== undefined && body.audienceRatingsForm !== null) {
body.audienceRatingsForm = await assertFeedbackFormForEvent(body.audienceRatingsForm, eventId);
}
} catch (err) {
const code = err.code === 404 ? 404 : 400;
return h.response(error('VALIDATION_ERROR', err.message || 'Invalid form')).code(code);
}
const session = await Session.create({ ...body, event: eventId });
const populated = await Session.findById(session._id).populate('speakers moderator track').lean();
return h.response(success(populated, 'Session created', 201)).code(201);
@ -60,12 +96,23 @@ const update = async (request, h) => {
if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const raw = { ...request.payload };
const raw = normalizeSessionPayload({ ...request.payload }, String(session.event));
const trackVal = raw.track && String(raw.track).trim() ? raw.track : null;
if (trackVal) {
const tr = await Track.findOne({ _id: trackVal, event: session.event });
if (!tr) return h.response(error('VALIDATION_ERROR', 'Track does not belong to this event')).code(400);
}
try {
if (raw.audienceFeedbackForm !== undefined) {
raw.audienceFeedbackForm = await assertFeedbackFormForEvent(raw.audienceFeedbackForm, String(session.event));
}
if (raw.audienceRatingsForm !== undefined) {
raw.audienceRatingsForm = await assertFeedbackFormForEvent(raw.audienceRatingsForm, String(session.event));
}
} catch (err) {
const code = err.code === 404 ? 404 : 400;
return h.response(error('VALIDATION_ERROR', err.message || 'Invalid form')).code(code);
}
delete raw.track;
Object.assign(session, raw);
session.track = trackVal;

View File

@ -2,6 +2,7 @@ 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');
@ -12,6 +13,9 @@ 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');
/** All event fields for JSON except website, timestamps; adds logoUrl from website.logo || image. Never expose inviteCode. */
function publicEventLoginPayload(doc) {
@ -94,6 +98,16 @@ async function resolveCode(request, h) {
}),
);
}
const eventByJoin = await Event.findOne({ joinCode: sessionCode }).lean();
if (eventByJoin) {
return h.response(
success({
type: 'event',
eventId: eventByJoin._id.toString(),
}),
);
}
}
const folder = normalizeFolderSlug(code);
@ -281,6 +295,12 @@ function publicSessionPreview(session) {
joinCode: session.joinCode,
speakers,
moderator,
audienceFeedbackEnabled: session.audienceFeedbackEnabled !== false,
audienceRatingsEnabled: session.audienceRatingsEnabled !== false,
audienceFeedbackForm:
session.audienceFeedbackForm != null ? String(session.audienceFeedbackForm) : null,
audienceRatingsForm:
session.audienceRatingsForm != null ? String(session.audienceRatingsForm) : null,
};
}
@ -367,7 +387,12 @@ async function listSessionQuestions(request, h) {
if (!session || String(session.event) !== String(eventId)) {
return h.response(error('NOT_FOUND', 'Session not found')).code(404);
}
const rows = await Question.find({ session: sessionId, event: eventId })
const rows = await Question.find({
session: sessionId,
event: eventId,
audienceListed: { $ne: false },
status: { $ne: 'archived' },
})
.populate('participant')
.sort({ createdAt: -1 })
.lean();
@ -407,9 +432,33 @@ async function createSessionQuestion(request, h) {
const populated = await Question.findById(doc._id).populate('participant').lean();
await syncSessionQuestionToFirebase(populated);
return h.response(success({ question: publicVisitorQuestion(populated) }, 'Question submitted', 201)).code(201);
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' })) : [];
@ -418,7 +467,7 @@ function applyAudienceVoteDoc(question, voterKey, direction) {
if (direction === 'up') {
if (prev === 'up') {
return;
return false;
}
if (prev === 'down') {
votes[idx].dir = 'up';
@ -430,7 +479,7 @@ function applyAudienceVoteDoc(question, voterKey, direction) {
}
} else if (direction === 'down') {
if (prev === 'down') {
return;
return false;
}
if (prev === 'up') {
votes[idx].dir = 'down';
@ -442,6 +491,7 @@ function applyAudienceVoteDoc(question, voterKey, direction) {
}
}
question.audienceVotes = votes;
return true;
}
function audienceYourVote(question, voterKey) {
@ -490,13 +540,33 @@ async function voteSessionQuestion(request, h) {
? String(credParticipant._id)
: String(voterKey).trim();
applyAudienceVoteDoc(question, audienceKey, direction);
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({
@ -504,6 +574,7 @@ async function voteSessionQuestion(request, h) {
up: question.upvotes ?? 0,
down: question.downvotes ?? 0,
yourVote: yv,
...(pointsEarned > 0 ? { reward: { pointsEarned } } : {}),
}),
);
}
@ -544,7 +615,66 @@ async function submitVisitorQuizPollResponse(request, h) {
totalScore: request.payload.totalScore,
timeSpent: request.payload.timeSpent,
});
return h.response(success(doc, 'Response submitted', 201)).code(201);
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 responseId = String(doc._id);
const typeLower = String(quizPoll.type || '').toLowerCase();
let pointsEarned = 0;
if (typeLower === 'quiz') {
const arSubmit = await awardIfNew({
participantId: participant._id,
eventId,
points: rules.quizPollSubmitted,
source: 'quiz_poll',
sourceId: `quiz_poll_submit:${quizPollId}:${responseId}`,
description: 'Quiz submission',
metadata: { ruleSnapshot: rules, quizPollId: String(quizPollId) },
});
pointsEarned += arSubmit.pointsAwarded || 0;
const perCorrect = rules.quizCorrectPerQuestion;
if (perCorrect > 0) {
const questions = quizPoll.questions || [];
for (let i = 0; i < questions.length; i++) {
const item = (request.payload.responses || []).find((r) => Number(r.questionIndex) === i);
if (!isObjectiveQuestionCorrect(questions[i], item)) continue;
const arCor = await awardIfNew({
participantId: participant._id,
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: participant._id,
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;
}
const responsePayload = {
...(doc.toObject ? doc.toObject() : doc),
...(pointsEarned > 0 ? { reward: { pointsEarned } } : {}),
};
return h.response(success(responsePayload, 'Response submitted', 201)).code(201);
}
function normAnswerVal(ans) {
@ -736,6 +866,99 @@ async function putVisitorSessionBookmark(request, h) {
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;
@ -753,6 +976,85 @@ async function visitorEventRewardBalance(request, h) {
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). */
async function visitorFeedbackFormPreview(request, h) {
const { eventId, formId } = request.params;
if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(formId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400);
}
const [event, form] = await Promise.all([
Event.findById(eventId).select('organization').lean(),
Form.findById(formId).select('tenantOrganization type name description fields settings').lean(),
]);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!form) return h.response(error('NOT_FOUND', 'Form not found')).code(404);
if (form.type !== 'feedback') {
return h.response(error('VALIDATION_ERROR', 'Form is not a feedback form')).code(400);
}
if (String(form.tenantOrganization) !== String(event.organization)) {
return h.response(error('FORBIDDEN', 'Form is not available for this event')).code(403);
}
if (!form.settings?.isActive) {
return h.response(error('VALIDATION_ERROR', 'Form is not accepting responses')).code(400);
}
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 h.response(
success({
_id: String(form._id),
name: form.name,
description: form.description || '',
fields,
thankYouMessage: form.settings?.thankYouMessage || '',
showThankYou: form.settings?.showThankYou !== false,
}),
);
}
async function submitMicrositeContact(request, h) {
const p = request.payload;
if (p._gotcha) return h.response(error('VALIDATION_ERROR', 'Invalid request')).code(400);
@ -789,5 +1091,8 @@ module.exports = {
listVisitorSessionBookmarks,
putVisitorSessionBookmark,
visitorEventRewardBalance,
visitorEventRewardHistory,
visitorEventLeaderboard,
submitMicrositeContact,
visitorFeedbackFormPreview,
};

View File

@ -7,6 +7,25 @@ const VisitorAuthToken = require('../models/VisitorAuthToken');
const { success, error } = require('../utils/response');
const { sendVisitorOtpEmail, sendVisitorMagicLinkEmail } = require('../utils/mail');
const { getMembership, membershipGuestQuery } = require('../utils/participantMembership');
const { mergeRules } = require('../utils/rewardRulesResolve');
const { awardIfNew } = require('../services/rewardAward');
/** @returns {number} loyalty points actually awarded (0 if duplicate / disabled) */
async function awardRegistrationIfNew(participant, eventId, eventLean) {
const rules = mergeRules(eventLean.rewardRules || {}, {}, {});
const pts = rules.registrationComplete;
if (!participant?._id) return 0;
const r = await awardIfNew({
participantId: participant._id,
eventId,
points: pts,
source: 'registration',
sourceId: `registration:${participant._id}:${eventId}`,
description: 'Event registration',
metadata: { ruleSnapshot: rules },
});
return r.pointsAwarded || 0;
}
const ATTENDEE_SECRET =
process.env.ATTENDEE_JWT_SECRET || process.env.JWT_SECRET || 'default-secret-change-in-production';
@ -309,6 +328,7 @@ async function verifyOtp(request, h) {
const orgId = event.organization;
let participant = await Participant.findOne({ tenantOrganization: orgId, email: em });
const inEvent = participant && getMembership(participant, eventId);
let registrationRewardPoints = 0;
if (!inEvent) {
if (!allowsNewRegistration(event)) {
return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403);
@ -335,6 +355,7 @@ async function verifyOtp(request, h) {
},
],
});
registrationRewardPoints = await awardRegistrationIfNew(participant, eventId, event);
} else {
participant.eventMemberships.push({
event: eventId,
@ -347,6 +368,7 @@ async function verifyOtp(request, h) {
if (phone) participant.phone = String(phone).trim();
if (displayName) participant.displayName = String(displayName).trim();
await participant.save();
registrationRewardPoints = await awardRegistrationIfNew(participant, eventId, event);
}
} else {
participant.emailVerified = true;
@ -360,7 +382,10 @@ async function verifyOtp(request, h) {
const joinType = doc.joinType || 'event';
const sid = doc.sessionId;
const tid = doc.trackId;
return h.response(success(authPayload(participant, event, eventId, joinType, sid, tid)));
const base = authPayload(participant, event, eventId, joinType, sid, tid);
const payload =
registrationRewardPoints > 0 ? { ...base, reward: { pointsEarned: registrationRewardPoints } } : base;
return h.response(success(payload));
}
async function startLink(request, h) {
@ -450,6 +475,7 @@ async function exchangeLink(request, h) {
const orgId = event.organization;
let participant = await Participant.findOne({ tenantOrganization: orgId, email: em });
const inEvent = participant && getMembership(participant, eventId);
let registrationRewardPoints = 0;
if (!inEvent) {
if (!allowsNewRegistration(event)) {
return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403);
@ -470,6 +496,7 @@ async function exchangeLink(request, h) {
},
],
});
registrationRewardPoints = await awardRegistrationIfNew(participant, eventId, event);
} else {
participant.eventMemberships.push({
event: eventId,
@ -478,6 +505,7 @@ async function exchangeLink(request, h) {
});
participant.emailVerified = true;
await participant.save();
registrationRewardPoints = await awardRegistrationIfNew(participant, eventId, event);
}
} else {
participant.emailVerified = true;
@ -485,9 +513,10 @@ async function exchangeLink(request, h) {
}
const joinType = doc.joinType || 'event';
return h.response(
success(authPayload(participant, event, eventId, joinType, doc.sessionId, doc.trackId))
);
const base = authPayload(participant, event, eventId, joinType, doc.sessionId, doc.trackId);
const payload =
registrationRewardPoints > 0 ? { ...base, reward: { pointsEarned: registrationRewardPoints } } : base;
return h.response(success(payload));
}
async function guestStart(request, h) {
@ -542,6 +571,7 @@ async function guestStart(request, h) {
},
],
});
const registrationRewardPoints = await awardRegistrationIfNew(participant, eventId, event);
const out = authPayload(
participant,
@ -551,7 +581,11 @@ async function guestStart(request, h) {
oidStr(sessionId),
oidStr(trackId)
);
return h.response(success({ ...out, guestToken: newGuestToken }));
const payload =
registrationRewardPoints > 0
? { ...out, guestToken: newGuestToken, reward: { pointsEarned: registrationRewardPoints } }
: { ...out, guestToken: newGuestToken };
return h.response(success(payload));
}
async function registrationStatus(request, h) {

View File

@ -1,4 +1,6 @@
const mongoose = require('mongoose');
const { eventBuzzCodeFor } = require('../utils/eventBuzzCode');
const { rewardRulesSubSchema } = require('./rewardRulesSchema');
const eventSchema = new mongoose.Schema(
{
@ -32,6 +34,8 @@ const eventSchema = new mongoose.Schema(
virtualLink: String,
tags: [String],
image: String,
/** Public join code for `/code/{code}` — same alphabet as session/track; resolves after session/track lookup. */
joinCode: { type: String, trim: true, uppercase: true, unique: true, sparse: true },
settings: {
registrationOpen: { type: Boolean, default: true },
maxParticipants: Number,
@ -48,6 +52,8 @@ const eventSchema = new mongoose.Schema(
/** Optional shared code for Invite Only registration (visitor must pass inviteToken) */
inviteCode: String,
},
/** Loyalty point rules (defaults); session/quiz may override via mergeRules. */
rewardRules: rewardRulesSubSchema,
bannerImage: String,
website: {
enabled: { type: Boolean, default: false },
@ -101,4 +107,17 @@ eventSchema.index({ organization: 1 });
eventSchema.index({ status: 1 });
eventSchema.index({ startDate: 1 });
eventSchema.index({ 'website.folder': 1 });
eventSchema.pre('save', function assignEventJoinCode(next) {
try {
const missing = !this.joinCode || !String(this.joinCode).trim();
if (missing && this._id) {
this.joinCode = eventBuzzCodeFor(this._id.toString(), 'event');
}
next();
} catch (err) {
next(err);
}
});
module.exports = mongoose.model('Event', eventSchema);

View File

@ -24,6 +24,10 @@ const questionSchema = new mongoose.Schema(
enum: ['pending', 'answered', 'archived'],
default: 'pending',
},
/** Moderator: highlighted for Q&A / speaker prep */
shortlisted: { type: Boolean, default: false },
/** When false, hidden from attendee question feeds (RTDB + visitor API). */
audienceListed: { type: Boolean, default: true },
upvotes: { type: Number, default: 0 },
downvotes: { type: Number, default: 0 },
/**

View File

@ -1,4 +1,5 @@
const mongoose = require('mongoose');
const { rewardRulesSubSchema } = require('./rewardRulesSchema');
const questionOptionSchema = new mongoose.Schema({
text: String,
@ -48,6 +49,7 @@ const quizPollSchema = new mongoose.Schema(
randomizeQuestions: { type: Boolean, default: false },
timeRestricted: { type: Boolean, default: false },
timeLimit: { type: Number, default: 0, min: 0 },
rewardRules: rewardRulesSubSchema,
},
deletedAt: { type: Date, default: null },
},

View File

@ -1,4 +1,5 @@
const mongoose = require('mongoose');
const { REWARD_SOURCES } = require('../config/constants');
const rewardPointSchema = new mongoose.Schema(
{
@ -20,9 +21,10 @@ const rewardPointSchema = new mongoose.Schema(
},
source: {
type: String,
enum: ['registration', 'attendance', 'feedback', 'quiz', 'referral', 'manual'],
enum: REWARD_SOURCES,
},
sourceId: mongoose.Schema.Types.Mixed,
/** Stable id for idempotent system awards (string). */
sourceId: { type: String, trim: true },
description: String,
expiresAt: Date,
metadata: { type: Map, of: mongoose.Schema.Types.Mixed },
@ -33,4 +35,13 @@ const rewardPointSchema = new mongoose.Schema(
rewardPointSchema.index({ participant: 1, event: 1 });
rewardPointSchema.index({ event: 1 });
rewardPointSchema.index(
{ participant: 1, event: 1, source: 1, sourceId: 1 },
{
unique: true,
partialFilterExpression: {
sourceId: { $type: 'string', $exists: true },
},
}
);
module.exports = mongoose.model('RewardPoint', rewardPointSchema);

View File

@ -1,5 +1,6 @@
const mongoose = require('mongoose');
const { eventBuzzCodeFor } = require('../utils/eventBuzzCode');
const { rewardRulesSubSchema } = require('./rewardRulesSchema');
const sessionSchema = new mongoose.Schema(
{
@ -36,6 +37,15 @@ const sessionSchema = new mongoose.Schema(
},
],
joinCode: { type: String, trim: true, uppercase: true, unique: true, sparse: true },
rewardRules: rewardRulesSubSchema,
/** When true (default), session page can surface feedback links if the event allows feedback */
audienceFeedbackEnabled: { type: Boolean, default: true },
/** When true (default), session page can surface ratings if the event has ratings enabled */
audienceRatingsEnabled: { type: Boolean, default: true },
/** Optional Form (type feedback) for custom feedback; null = default EventBuzz feedback UI */
audienceFeedbackForm: { type: mongoose.Schema.Types.ObjectId, ref: 'Form' },
/** Optional Form (type feedback) for custom ratings flow; null = default EventBuzz ratings UI */
audienceRatingsForm: { type: mongoose.Schema.Types.ObjectId, ref: 'Form' },
},
{ timestamps: true }
);

View File

@ -0,0 +1,19 @@
const mongoose = require('mongoose');
/** Shared shape: event → session → quiz/poll overrides via mergeRules (quiz wins). */
const rewardRulesSubSchema = new mongoose.Schema(
{
registrationComplete: Number,
questionAsk: Number,
questionUpvote: Number,
questionDownvote: Number,
quizPollSubmitted: Number,
quizCorrectPerQuestion: Number,
pollParticipation: Number,
feedbackFormSubmitted: Number,
ratingSubmitted: Number,
},
{ _id: false }
);
module.exports = { rewardRulesSubSchema };

View File

@ -23,6 +23,18 @@ const registrationFieldSchema = Joi.object({
options: Joi.array().items(fieldOptionSchema),
});
const rewardRulesPayload = Joi.object({
registrationComplete: Joi.number().min(0).allow(null),
questionAsk: Joi.number().min(0).allow(null),
questionUpvote: Joi.number().min(0).allow(null),
questionDownvote: Joi.number().min(0).allow(null),
quizPollSubmitted: Joi.number().min(0).allow(null),
quizCorrectPerQuestion: Joi.number().min(0).allow(null),
pollParticipation: Joi.number().min(0).allow(null),
feedbackFormSubmitted: Joi.number().min(0).allow(null),
ratingSubmitted: Joi.number().min(0).allow(null),
}).allow(null);
const eventPayload = Joi.object({
name: Joi.string().min(3).max(100).required(),
subtitle: Joi.string().allow('', null),
@ -52,13 +64,28 @@ const eventPayload = Joi.object({
registrationBaseFields: Joi.string().valid(...REGISTRATION_BASE_FIELDS).allow('', null),
inviteCode: Joi.string().allow('', null),
}),
/** Align with Event.metadata in the model; allow extra keys from older clients. */
metadata: Joi.object({
totalRevenue: Joi.number().allow(null),
rating: Joi.number().allow(null),
ratingCount: Joi.number().allow(null),
registrationCustomFields: Joi.array().items(registrationFieldSchema),
}),
}).unknown(true),
rewardRules: rewardRulesPayload,
});
const generateFromAiPayload = Joi.object({
description: Joi.string().min(10).max(8000).required(),
organization: Joi.string().allow('', null),
});
module.exports = [
{ method: 'GET', path: '/api/events', options: { handler: controller.list } },
{
method: 'POST',
path: '/api/events/generate-from-ai',
options: { validate: { payload: generateFromAiPayload }, handler: controller.generateFromAi },
},
{ method: 'POST', path: '/api/events', options: { validate: { payload: eventPayload }, handler: controller.create } },
{ method: 'GET', path: '/api/events/{id}', options: { handler: controller.get } },
{ method: 'PUT', path: '/api/events/{id}', options: { validate: { payload: eventPayload }, handler: controller.update } },

View File

@ -13,11 +13,26 @@ const answerPayload = Joi.object({
isOfficial: Joi.boolean(),
});
const questionPatchPayload = Joi.object({
question: Joi.string().trim().min(1),
isAnonymous: Joi.boolean(),
shortlisted: Joi.boolean(),
audienceListed: Joi.boolean(),
status: Joi.string().valid('pending', 'answered', 'archived'),
})
.min(1)
.messages({ 'object.min': 'At least one field is required' });
module.exports = [
{ method: 'GET', path: '/api/events/{eventId}/questions', options: { handler: controller.list } },
{ method: 'POST', path: '/api/events/{eventId}/questions', options: { validate: { payload: questionPayload }, handler: controller.create } },
{ method: 'GET', path: '/api/questions/{id}', options: { handler: controller.get } },
{ method: 'PUT', path: '/api/questions/{id}', options: { validate: { payload: questionPayload }, handler: controller.update } },
{
method: 'PATCH',
path: '/api/questions/{id}',
options: { validate: { payload: questionPatchPayload }, handler: controller.patch },
},
{ method: 'DELETE', path: '/api/questions/{id}', options: { handler: controller.remove } },
{ method: 'POST', path: '/api/questions/{id}/upvote', options: { handler: controller.upvote } },
{ method: 'POST', path: '/api/questions/{id}/answer', options: { validate: { payload: answerPayload }, handler: controller.answer } },

View File

@ -15,6 +15,18 @@ const quizQuestionSchema = Joi.object({
points: Joi.number(),
required: Joi.boolean(),
});
const rewardRulesPayload = Joi.object({
registrationComplete: Joi.number().min(0).allow(null),
questionAsk: Joi.number().min(0).allow(null),
questionUpvote: Joi.number().min(0).allow(null),
questionDownvote: Joi.number().min(0).allow(null),
quizPollSubmitted: Joi.number().min(0).allow(null),
quizCorrectPerQuestion: Joi.number().min(0).allow(null),
pollParticipation: Joi.number().min(0).allow(null),
feedbackFormSubmitted: Joi.number().min(0).allow(null),
ratingSubmitted: Joi.number().min(0).allow(null),
}).allow(null);
const quizPollPayload = Joi.object({
session: Joi.string(),
title: Joi.string().required(),
@ -32,6 +44,7 @@ const quizPollPayload = Joi.object({
randomizeQuestions: Joi.boolean(),
timeRestricted: Joi.boolean(),
timeLimit: Joi.number().integer().min(0),
rewardRules: rewardRulesPayload,
}),
});

View File

@ -23,6 +23,16 @@ module.exports = [
{ method: 'PUT', path: '/api/reward-points/{id}', options: { validate: { payload: rewardPayload }, handler: controller.update } },
{ method: 'DELETE', path: '/api/reward-points/{id}', options: { handler: controller.remove } },
{ method: 'GET', path: '/api/participants/{participantId}/reward-points', options: { handler: controller.byParticipant } },
{ method: 'GET', path: '/api/events/{eventId}/leaderboard', options: { handler: controller.leaderboard } },
{
method: 'GET',
path: '/api/events/{eventId}/leaderboard',
options: {
validate: {
params: Joi.object({ eventId: Joi.string().required() }),
query: Joi.object({ limit: Joi.number().integer().min(1).max(500) }),
},
handler: controller.leaderboard,
},
},
{ method: 'POST', path: '/api/events/{eventId}/reward-points/bulk', options: { validate: { payload: bulkPayload }, handler: controller.bulk } },
];

View File

@ -2,6 +2,18 @@ const Joi = require('joi');
const controller = require('../controllers/session.controller');
const { SESSION_TYPES, SESSION_STATUS } = require('../config/constants');
const rewardRulesPayload = Joi.object({
registrationComplete: Joi.number().min(0).allow(null),
questionAsk: Joi.number().min(0).allow(null),
questionUpvote: Joi.number().min(0).allow(null),
questionDownvote: Joi.number().min(0).allow(null),
quizPollSubmitted: Joi.number().min(0).allow(null),
quizCorrectPerQuestion: Joi.number().min(0).allow(null),
pollParticipation: Joi.number().min(0).allow(null),
feedbackFormSubmitted: Joi.number().min(0).allow(null),
ratingSubmitted: Joi.number().min(0).allow(null),
}).allow(null);
const sessionPayload = Joi.object({
title: Joi.string().required(),
description: Joi.string().allow('', null),
@ -15,6 +27,11 @@ const sessionPayload = Joi.object({
capacity: Joi.number(),
status: Joi.string().valid(...SESSION_STATUS),
resources: Joi.array().items(Joi.object({ name: Joi.string(), type: Joi.string(), url: Joi.string() })),
rewardRules: rewardRulesPayload,
audienceFeedbackEnabled: Joi.boolean(),
audienceRatingsEnabled: Joi.boolean(),
audienceFeedbackForm: Joi.string().hex().length(24).allow(null, ''),
audienceRatingsForm: Joi.string().hex().length(24).allow(null, ''),
});
module.exports = [

View File

@ -158,6 +158,20 @@ module.exports = [
handler: controller.trackPreview,
},
},
{
method: 'GET',
path: '/api/visitor/events/{eventId}/feedback-forms/{formId}',
options: {
auth: false,
validate: {
params: Joi.object({
eventId: Joi.string().required(),
formId: Joi.string().required(),
}),
},
handler: controller.visitorFeedbackFormPreview,
},
},
{
method: 'GET',
path: '/api/visitor/sessions/{sessionId}/questions',
@ -309,6 +323,18 @@ module.exports = [
handler: controller.putVisitorSessionBookmark,
},
},
{
method: 'GET',
path: '/api/visitor/events/{eventId}/leaderboard',
options: {
auth: { mode: 'optional', strategy: 'jwt-attendee' },
validate: {
params: Joi.object({ eventId: Joi.string().required() }),
query: Joi.object({ limit: Joi.number().integer().min(5).max(100) }),
},
handler: controller.visitorEventLeaderboard,
},
},
{
method: 'GET',
path: '/api/visitor/events/{eventId}/reward-balance',
@ -320,6 +346,21 @@ module.exports = [
handler: controller.visitorEventRewardBalance,
},
},
{
method: 'GET',
path: '/api/visitor/events/{eventId}/reward-history',
options: {
auth: 'jwt-attendee',
validate: {
params: Joi.object({ eventId: Joi.string().required() }),
query: Joi.object({
page: Joi.number().integer().min(1),
limit: Joi.number().integer().min(1).max(100),
}),
},
handler: controller.visitorEventRewardHistory,
},
},
{
method: 'POST',
path: '/api/visitor/events/{eventId}/quiz-polls/{quizPollId}/responses',

View File

@ -0,0 +1,66 @@
const mongoose = require('mongoose');
const RewardPoint = require('../models/RewardPoint');
const { REWARD_SOURCES } = require('../config/constants');
function asMetadataMap(meta) {
if (!meta || typeof meta !== 'object') return undefined;
const entries = Object.entries(meta).filter(([, v]) => v !== undefined);
if (!entries.length) return undefined;
return new Map(entries);
}
/**
* Create an earned row when sourceId is new; duplicate key → no-op.
* @param {object} opts
* @param {string|mongoose.Types.ObjectId} opts.participantId
* @param {string|mongoose.Types.ObjectId} opts.eventId
* @param {number} opts.points
* @param {string} opts.source — must be in REWARD_SOURCES
* @param {string} opts.sourceId
* @param {string} [opts.description]
* @param {Record<string, unknown>} [opts.metadata]
*/
async function awardIfNew(opts) {
const {
participantId,
eventId,
points,
source,
sourceId,
description,
metadata: metaPlain,
} = opts;
const pts = Number(points);
if (!Number.isFinite(pts) || pts <= 0) {
return { awarded: false, reason: 'no_points', pointsAwarded: 0 };
}
if (!source || !REWARD_SOURCES.includes(source)) {
return { awarded: false, reason: 'bad_source', pointsAwarded: 0 };
}
const sid = sourceId != null ? String(sourceId).trim() : '';
if (!sid) {
return { awarded: false, reason: 'missing_sourceId', pointsAwarded: 0 };
}
const rounded = Math.round(pts);
try {
const doc = await RewardPoint.create({
participant: participantId,
event: eventId,
points: rounded,
type: 'earned',
source,
sourceId: sid,
description,
metadata: asMetadataMap(metaPlain),
});
return { awarded: true, doc, pointsAwarded: rounded };
} catch (err) {
if (err && err.code === 11000) {
return { awarded: false, duplicate: true, pointsAwarded: 0 };
}
throw err;
}
}
module.exports = { awardIfNew };

View File

@ -0,0 +1,78 @@
const { BedrockRuntimeClient, InvokeModelCommand } = require('@aws-sdk/client-bedrock-runtime');
const EVENT_TYPES = ['conference', 'workshop', 'summit', 'cme', 'webinar'];
const EVENT_STATUS = ['draft', 'upcoming', 'live', 'completed'];
const LOCATION_TYPES = ['physical', 'virtual', 'hybrid'];
function extractJsonObject(text) {
if (!text || typeof text !== 'string') return null;
const trimmed = text.trim();
const fence = /^```(?:json)?\s*([\s\S]*?)```$/im.exec(trimmed);
const body = fence ? fence[1].trim() : trimmed;
const start = body.indexOf('{');
const end = body.lastIndexOf('}');
if (start === -1 || end <= start) return null;
try {
return JSON.parse(body.slice(start, end + 1));
} catch {
return null;
}
}
/**
* @param {string} userDescription
* @returns {Promise<Record<string, unknown>>}
*/
async function extractEventFieldsFromDescription(userDescription) {
const region = process.env.AWS_DEFAULT_REGION || process.env.AWS_REGION || 'ap-south-1';
const modelId =
process.env.BEDROCK_MODEL_ID || 'anthropic.claude-3-haiku-20240307-v1:0';
const systemPrompt = `You extract structured event data from free-form text. Reply with ONLY a single JSON object, no markdown, no explanation.
Keys (use null for unknown optional fields):
- name: string, required, at least 3 characters, event title
- subtitle: string or null, short tagline
- description: string or null, longer details
- type: one of ${JSON.stringify(EVENT_TYPES)}
- status: one of ${JSON.stringify(EVENT_STATUS)} or omit (server defaults to draft)
- startDate: ISO8601 date-time string (UTC) for event start
- endDate: ISO 8601 date-time string (UTC) for event end, must be after startDate
- location: string or null, venue or city
- locationType: one of ${JSON.stringify(LOCATION_TYPES)}
- virtualLink: string or null, URL if virtual/hybrid
- tags: string array or omit.
Infer reasonable dates only if the user mentions dates; otherwise use null for startDate and endDate so defaults apply.`;
const client = new BedrockRuntimeClient({ region });
const body = JSON.stringify({
anthropic_version: 'bedrock-2023-05-31',
max_tokens: 2048,
messages: [
{
role: 'user',
content: [{ type: 'text', text: `${systemPrompt}\n\nUser description:\n${userDescription}` }],
},
],
});
const out = await client.send(
new InvokeModelCommand({
modelId,
contentType: 'application/json',
accept: 'application/json',
body: Buffer.from(body, 'utf8'),
}),
);
const raw = new TextDecoder().decode(out.body);
const decoded = JSON.parse(raw);
const text = decoded.content?.[0]?.text;
const parsed = extractJsonObject(text || '');
if (!parsed || typeof parsed !== 'object') {
throw new Error('Model did not return valid JSON');
}
return parsed;
}
module.exports = { extractEventFieldsFromDescription, EVENT_TYPES, EVENT_STATUS, LOCATION_TYPES };

View File

@ -1,9 +1,9 @@
/** Deterministic 10-char A–Z / 0–9 code for a track or session id (stable across opens). Mirrors frontend eventBuzzCode.ts. */
/** Deterministic 10-char A–Z / 0–9 code for a track, session, or event id (stable across opens). */
const EVENTBUZZ_CODE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
/**
* @param {string} id Mongo document id string
* @param {'track' | 'session'} kind
* @param {'track' | 'session' | 'event'} kind
*/
function eventBuzzCodeFor(id, kind) {
const input = `${kind}:${id}`;

View File

@ -0,0 +1,65 @@
function normAnswerVal(ans) {
if (ans == null) return '';
if (typeof ans === 'object' && ans !== null && typeof ans.toString === 'function') {
return String(ans.toString());
}
return String(ans);
}
function optionIdStr(o) {
if (!o) return '';
return o._id != null ? String(o._id) : '';
}
function correctIdSet(q) {
const ids = new Set();
for (const o of q.options || []) {
if (o && o.isCorrect && optionIdStr(o)) ids.add(optionIdStr(o));
}
return ids;
}
/**
* @param {object} q question subdoc from QuizPoll
* @param {{ answer?: unknown }} answerItem from visitor payload
* @returns {boolean}
*/
function isObjectiveQuestionCorrect(q, answerItem) {
if (!answerItem) return false;
const qType = String(q.type || 'single').toLowerCase();
if (qType === 'text' || qType === 'rating') return false;
const correct = correctIdSet(q);
if (correct.size === 0) return false;
if (qType === 'multiple') {
const raw = answerItem.answer;
if (!Array.isArray(raw)) return false;
const ans = new Set(raw.map((x) => normAnswerVal(x)));
if (ans.size !== correct.size) return false;
for (const id of correct) {
if (!ans.has(id)) return false;
}
return true;
}
const a = normAnswerVal(answerItem.answer);
return correct.has(a);
}
/**
* @param {{ questions?: object[] }} quizPoll lean
* @param {Array<{ questionIndex?: number, answer?: unknown }>} responses
*/
function countQuizCorrectAnswers(quizPoll, responses) {
const questions = quizPoll.questions || [];
const list = Array.isArray(responses) ? responses : [];
let n = 0;
for (const item of list) {
const idx = Number(item.questionIndex);
if (!Number.isInteger(idx) || idx < 0 || idx >= questions.length) continue;
if (isObjectiveQuestionCorrect(questions[idx], item)) n += 1;
}
return n;
}
module.exports = { countQuizCorrectAnswers, isObjectiveQuestionCorrect, normAnswerVal };

View File

@ -0,0 +1,39 @@
/**
* Merge order: quiz/poll settings → session → event (first defined wins, including 0).
* @param {object} eventRules
* @param {object} sessionRules
* @param {object} quizPollRules
*/
const REWARD_RULE_KEYS = [
'registrationComplete',
'questionAsk',
'questionUpvote',
'questionDownvote',
'quizPollSubmitted',
'quizCorrectPerQuestion',
'pollParticipation',
'feedbackFormSubmitted',
'ratingSubmitted',
];
function firstDefined(...vals) {
for (const v of vals) {
if (v !== undefined && v !== null) return Number(v);
}
return undefined;
}
function mergeRules(eventRules = {}, sessionRules = {}, quizPollRules = {}) {
const out = {};
const ev = eventRules && typeof eventRules === 'object' ? eventRules : {};
const sess = sessionRules && typeof sessionRules === 'object' ? sessionRules : {};
const quiz = quizPollRules && typeof quizPollRules === 'object' ? quizPollRules : {};
for (const key of REWARD_RULE_KEYS) {
const raw = firstDefined(quiz[key], sess[key], ev[key]);
const n = raw === undefined || Number.isNaN(raw) ? 0 : Math.max(0, raw);
out[key] = n;
}
return out;
}
module.exports = { REWARD_RULE_KEYS, mergeRules };

View File

@ -37,6 +37,8 @@ function questionToRtdbPayload(doc) {
upvotes: typeof o.upvotes === 'number' ? o.upvotes : 0,
downvotes: typeof o.downvotes === 'number' ? o.downvotes : 0,
status: o.status != null ? o.status : 'pending',
shortlisted: Boolean(o.shortlisted),
audienceListed: o.audienceListed !== false,
createdAt:
o.createdAt instanceof Date
? o.createdAt.toISOString()