on the way
This commit is contained in:
parent
7b1777c4ba
commit
a257817f8c
|
|
@ -33,6 +33,8 @@ AWS_SECRET_ACCESS_KEY=ZpIxby1vbCLGB7CPWZsAltZBEqkhwzgBpFOKDGj8
|
||||||
AWS_DEFAULT_REGION=ap-south-1
|
AWS_DEFAULT_REGION=ap-south-1
|
||||||
AWS_BUCKET=rapportapp
|
AWS_BUCKET=rapportapp
|
||||||
AWS_URL=https://did94hhhcgq1d.cloudfront.net
|
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)
|
# 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
|
MICROSITE_API_BASE_URL=http://localhost:3001/api
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -13,6 +13,7 @@
|
||||||
"backfill:tenant-organization": "node scripts/backfill-tenant-organization.js"
|
"backfill:tenant-organization": "node scripts/backfill-tenant-organization.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
|
||||||
"@aws-sdk/client-s3": "^3.1012.0",
|
"@aws-sdk/client-s3": "^3.1012.0",
|
||||||
"@aws-sdk/lib-storage": "^3.1012.0",
|
"@aws-sdk/lib-storage": "^3.1012.0",
|
||||||
"@hapi/hapi": "^21.3.0",
|
"@hapi/hapi": "^21.3.0",
|
||||||
|
|
@ -24,9 +25,9 @@
|
||||||
"joi": "^17.13.0",
|
"joi": "^17.13.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mongoose": "^8.0.0",
|
"mongoose": "^8.0.0",
|
||||||
"razorpay": "^2.9.6",
|
|
||||||
"otplib": "^12.0.1",
|
|
||||||
"nodemailer": "^6.9.7",
|
"nodemailer": "^6.9.7",
|
||||||
|
"otplib": "^12.0.1",
|
||||||
|
"razorpay": "^2.9.6",
|
||||||
"winston": "^3.11.0"
|
"winston": "^3.11.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Run: cd backend && npm run backfill:join-codes
|
||||||
*/
|
*/
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
@ -8,6 +8,7 @@ require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const Session = require(path.join(__dirname, '..', 'src', 'models', 'Session'));
|
const Session = require(path.join(__dirname, '..', 'src', 'models', 'Session'));
|
||||||
const Track = require(path.join(__dirname, '..', 'src', 'models', 'Track'));
|
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'));
|
const { eventBuzzCodeFor } = require(path.join(__dirname, '..', 'src', 'utils', 'eventBuzzCode'));
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
|
|
@ -36,6 +37,15 @@ async function run() {
|
||||||
}
|
}
|
||||||
console.log(`Backfilled joinCode for ${nt} track(s).`);
|
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();
|
await mongoose.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,19 @@ const WEBSITE_SECTION_TYPES = [
|
||||||
'tickets',
|
'tickets',
|
||||||
];
|
];
|
||||||
const DESIGN_TEMPLATE_TYPES = ['brochure', 'microsite', 'badge', 'emailer', 'certificate'];
|
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 = {
|
module.exports = {
|
||||||
ROLES,
|
ROLES,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,12 @@ const { success, error } = require('../utils/response');
|
||||||
const { resolveScopedOrganizationId } = require('../utils/tenantListScope');
|
const { resolveScopedOrganizationId } = require('../utils/tenantListScope');
|
||||||
const { uploadBufferToS3AndGetCloudFrontUrl } = require('../utils/s3Media');
|
const { uploadBufferToS3AndGetCloudFrontUrl } = require('../utils/s3Media');
|
||||||
const { registrationUrlForForm } = require('../utils/micrositeBuild');
|
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) {
|
function parseDataUrl(dataUrl) {
|
||||||
if (typeof dataUrl !== 'string') return null;
|
if (typeof dataUrl !== 'string') return null;
|
||||||
|
|
@ -92,34 +98,59 @@ async function assignCreatorToEventStaff(creatorUserId, orgId, eventId) {
|
||||||
await map.save();
|
await map.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = async (request, h) => {
|
function defaultAiDateRange() {
|
||||||
const { organization: organizationQuery, status, type, page = 1, limit = 20 } = request.query;
|
const start = new Date();
|
||||||
const user = request.auth.credentials.user;
|
start.setUTCDate(start.getUTCDate() + 7);
|
||||||
const scoped = resolveScopedOrganizationId(user, organizationQuery);
|
start.setUTCHours(10, 0, 0, 0);
|
||||||
if (scoped.error) {
|
const end = new Date(start.getTime());
|
||||||
return h.response(error(scoped.error.code, scoped.error.message)).code(400);
|
end.setUTCHours(18, 0, 0, 0);
|
||||||
}
|
return { start, end };
|
||||||
const filter = { organization: scoped.organizationId };
|
}
|
||||||
if (status) filter.status = status;
|
|
||||||
if (type) filter.type = type;
|
|
||||||
|
|
||||||
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
|
function mergeAiExtractedPayload(ai) {
|
||||||
const [events, total] = await Promise.all([
|
const { start: ds, end: de } = defaultAiDateRange();
|
||||||
Event.find(filter).populate('organization').sort({ startDate: -1 }).skip(skip).limit(parseInt(limit, 10)).lean(),
|
const norm = (v) => (typeof v === 'string' ? v.toLowerCase().trim() : '');
|
||||||
Event.countDocuments(filter),
|
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';
|
||||||
return h.response(success({ events, total, page: parseInt(page, 10), limit: parseInt(limit, 10) }))
|
const locType = AI_LOCATION_TYPES.includes(norm(ai.locationType)) ? norm(ai.locationType) : 'physical';
|
||||||
.header('X-Total-Count', total);
|
|
||||||
};
|
|
||||||
|
|
||||||
const create = async (request, h) => {
|
let name = typeof ai.name === 'string' ? ai.name.trim() : '';
|
||||||
const user = request.auth.credentials.user;
|
if (name.length < 3) {
|
||||||
const orgId = request.payload.organization || (user.organization?._id || user.organization);
|
const fromDesc = typeof ai.description === 'string' ? ai.description.trim().slice(0, 80) : '';
|
||||||
if (!orgId) return h.response(error('VALIDATION_ERROR', 'Organization required')).code(400);
|
name = (fromDesc || 'New event').slice(0, 100);
|
||||||
if (user.role !== 'super_admin' && (user.organization?._id?.toString() !== orgId?.toString())) {
|
if (name.length < 3) name = 'New event';
|
||||||
return h.response(error('FORBIDDEN', 'Cannot create event for this organization')).code(403);
|
|
||||||
}
|
}
|
||||||
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({
|
const event = await Event.create({
|
||||||
...rest,
|
...rest,
|
||||||
organization: orgId,
|
organization: orgId,
|
||||||
|
|
@ -131,7 +162,6 @@ const create = async (request, h) => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert base64 images to CloudFront URLs.
|
|
||||||
const eventId = event._id?.toString?.() || event._id;
|
const eventId = event._id?.toString?.() || event._id;
|
||||||
let didUpdate = false;
|
let didUpdate = false;
|
||||||
if (rest.image != null) {
|
if (rest.image != null) {
|
||||||
|
|
@ -169,6 +199,61 @@ const create = async (request, h) => {
|
||||||
console.error('assignCreatorToEventStaff', err);
|
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);
|
return h.response(success(event, 'Event created', 201)).code(201);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -355,4 +440,14 @@ const registrationLink = async (request, h) => {
|
||||||
return h.response(success({ url }));
|
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,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,30 @@ const update = async (request, h) => {
|
||||||
return h.response(success(question));
|
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 remove = async (request, h) => {
|
||||||
const question = await Question.findById(request.params.id);
|
const question = await Question.findById(request.params.id);
|
||||||
if (!question) return h.response(error('NOT_FOUND', 'Question not found')).code(404);
|
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 }));
|
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 };
|
||||||
|
|
|
||||||
|
|
@ -83,14 +83,33 @@ const leaderboard = async (request, h) => {
|
||||||
if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
|
if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
|
||||||
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
|
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([
|
const aggregated = await RewardPoint.aggregate([
|
||||||
{ $match: { event: event._id, type: 'earned' } },
|
{ $match: { event: event._id, type: { $in: ['earned', 'redeemed'] } } },
|
||||||
{ $group: { _id: '$participant', total: { $sum: '$points' } } },
|
{
|
||||||
|
$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 } },
|
{ $sort: { total: -1 } },
|
||||||
{ $limit: 100 },
|
{ $limit: lim },
|
||||||
{ $lookup: { from: 'participants', localField: '_id', foreignField: '_id', as: 'p' } },
|
{ $lookup: { from: 'participants', localField: '_id', foreignField: '_id', as: 'p' } },
|
||||||
{ $unwind: '$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 }));
|
return h.response(success({ leaderboard: aggregated }));
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
|
const mongoose = require('mongoose');
|
||||||
const Session = require('../models/Session');
|
const Session = require('../models/Session');
|
||||||
const Event = require('../models/Event');
|
const Event = require('../models/Event');
|
||||||
const Track = require('../models/Track');
|
const Track = require('../models/Track');
|
||||||
|
const Form = require('../models/Form');
|
||||||
const { success, error } = require('../utils/response');
|
const { success, error } = require('../utils/response');
|
||||||
|
|
||||||
function normalizeSessionPayload(payload, eventId) {
|
function normalizeSessionPayload(payload, eventId) {
|
||||||
|
|
@ -8,9 +10,32 @@ function normalizeSessionPayload(payload, eventId) {
|
||||||
if (!body.track || String(body.track).trim() === '') {
|
if (!body.track || String(body.track).trim() === '') {
|
||||||
delete body.track;
|
delete body.track;
|
||||||
}
|
}
|
||||||
|
for (const key of ['audienceFeedbackForm', 'audienceRatingsForm']) {
|
||||||
|
if (key in body && (body[key] === '' || body[key] === undefined)) body[key] = null;
|
||||||
|
}
|
||||||
return body;
|
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 list = async (request, h) => {
|
||||||
const { eventId } = request.params;
|
const { eventId } = request.params;
|
||||||
const event = await Event.findById(eventId);
|
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 });
|
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);
|
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 session = await Session.create({ ...body, event: eventId });
|
||||||
const populated = await Session.findById(session._id).populate('speakers moderator track').lean();
|
const populated = await Session.findById(session._id).populate('speakers moderator track').lean();
|
||||||
return h.response(success(populated, 'Session created', 201)).code(201);
|
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()) {
|
if (user.role !== 'super_admin' && event.organization?.toString() !== (user.organization?._id || user.organization)?.toString()) {
|
||||||
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
|
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;
|
const trackVal = raw.track && String(raw.track).trim() ? raw.track : null;
|
||||||
if (trackVal) {
|
if (trackVal) {
|
||||||
const tr = await Track.findOne({ _id: trackVal, event: session.event });
|
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);
|
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;
|
delete raw.track;
|
||||||
Object.assign(session, raw);
|
Object.assign(session, raw);
|
||||||
session.track = trackVal;
|
session.track = trackVal;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ const mongoose = require('mongoose');
|
||||||
const Session = require('../models/Session');
|
const Session = require('../models/Session');
|
||||||
const Track = require('../models/Track');
|
const Track = require('../models/Track');
|
||||||
const Event = require('../models/Event');
|
const Event = require('../models/Event');
|
||||||
|
const Form = require('../models/Form');
|
||||||
const EventMicrosite = require('../models/EventMicrosite');
|
const EventMicrosite = require('../models/EventMicrosite');
|
||||||
const Question = require('../models/Question');
|
const Question = require('../models/Question');
|
||||||
const RewardPoint = require('../models/RewardPoint');
|
const RewardPoint = require('../models/RewardPoint');
|
||||||
|
|
@ -12,6 +13,9 @@ const { success, error } = require('../utils/response');
|
||||||
const { sendMicrositeContactEmail } = require('../utils/mail');
|
const { sendMicrositeContactEmail } = require('../utils/mail');
|
||||||
const { syncSessionQuestionToFirebase } = require('../utils/sessionQuestionFirebaseSync');
|
const { syncSessionQuestionToFirebase } = require('../utils/sessionQuestionFirebaseSync');
|
||||||
const { participantInEvent } = require('../utils/participantMembership');
|
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. */
|
/** All event fields for JSON except website, timestamps; adds logoUrl from website.logo || image. Never expose inviteCode. */
|
||||||
function publicEventLoginPayload(doc) {
|
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);
|
const folder = normalizeFolderSlug(code);
|
||||||
|
|
@ -281,6 +295,12 @@ function publicSessionPreview(session) {
|
||||||
joinCode: session.joinCode,
|
joinCode: session.joinCode,
|
||||||
speakers,
|
speakers,
|
||||||
moderator,
|
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)) {
|
if (!session || String(session.event) !== String(eventId)) {
|
||||||
return h.response(error('NOT_FOUND', 'Session not found')).code(404);
|
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')
|
.populate('participant')
|
||||||
.sort({ createdAt: -1 })
|
.sort({ createdAt: -1 })
|
||||||
.lean();
|
.lean();
|
||||||
|
|
@ -407,9 +432,33 @@ async function createSessionQuestion(request, h) {
|
||||||
const populated = await Question.findById(doc._id).populate('participant').lean();
|
const populated = await Question.findById(doc._id).populate('participant').lean();
|
||||||
await syncSessionQuestionToFirebase(populated);
|
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) {
|
function applyAudienceVoteDoc(question, voterKey, direction) {
|
||||||
const raw = question.audienceVotes;
|
const raw = question.audienceVotes;
|
||||||
const votes = Array.isArray(raw) ? raw.map((v) => ({ key: String(v.key), dir: v.dir === 'down' ? 'down' : 'up' })) : [];
|
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 (direction === 'up') {
|
||||||
if (prev === 'up') {
|
if (prev === 'up') {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if (prev === 'down') {
|
if (prev === 'down') {
|
||||||
votes[idx].dir = 'up';
|
votes[idx].dir = 'up';
|
||||||
|
|
@ -430,7 +479,7 @@ function applyAudienceVoteDoc(question, voterKey, direction) {
|
||||||
}
|
}
|
||||||
} else if (direction === 'down') {
|
} else if (direction === 'down') {
|
||||||
if (prev === 'down') {
|
if (prev === 'down') {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if (prev === 'up') {
|
if (prev === 'up') {
|
||||||
votes[idx].dir = 'down';
|
votes[idx].dir = 'down';
|
||||||
|
|
@ -442,6 +491,7 @@ function applyAudienceVoteDoc(question, voterKey, direction) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
question.audienceVotes = votes;
|
question.audienceVotes = votes;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function audienceYourVote(question, voterKey) {
|
function audienceYourVote(question, voterKey) {
|
||||||
|
|
@ -490,13 +540,33 @@ async function voteSessionQuestion(request, h) {
|
||||||
? String(credParticipant._id)
|
? String(credParticipant._id)
|
||||||
: String(voterKey).trim();
|
: String(voterKey).trim();
|
||||||
|
|
||||||
applyAudienceVoteDoc(question, audienceKey, direction);
|
const voteChanged = applyAudienceVoteDoc(question, audienceKey, direction);
|
||||||
|
|
||||||
await question.save();
|
await question.save();
|
||||||
|
|
||||||
const populated = await Question.findById(question._id).populate('participant').lean();
|
const populated = await Question.findById(question._id).populate('participant').lean();
|
||||||
await syncSessionQuestionToFirebase(populated);
|
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);
|
const yv = audienceYourVote(question, audienceKey);
|
||||||
return h.response(
|
return h.response(
|
||||||
success({
|
success({
|
||||||
|
|
@ -504,6 +574,7 @@ async function voteSessionQuestion(request, h) {
|
||||||
up: question.upvotes ?? 0,
|
up: question.upvotes ?? 0,
|
||||||
down: question.downvotes ?? 0,
|
down: question.downvotes ?? 0,
|
||||||
yourVote: yv,
|
yourVote: yv,
|
||||||
|
...(pointsEarned > 0 ? { reward: { pointsEarned } } : {}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -544,7 +615,66 @@ async function submitVisitorQuizPollResponse(request, h) {
|
||||||
totalScore: request.payload.totalScore,
|
totalScore: request.payload.totalScore,
|
||||||
timeSpent: request.payload.timeSpent,
|
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) {
|
function normAnswerVal(ans) {
|
||||||
|
|
@ -736,6 +866,99 @@ async function putVisitorSessionBookmark(request, h) {
|
||||||
return h.response(success({ sessionIds, bookmarked, sessionId: String(sessionId) }));
|
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 */
|
/** GET …/visitor/events/{eventId}/reward-balance — attendee JWT; total points for this event only */
|
||||||
async function visitorEventRewardBalance(request, h) {
|
async function visitorEventRewardBalance(request, h) {
|
||||||
const { eventId } = request.params;
|
const { eventId } = request.params;
|
||||||
|
|
@ -753,6 +976,85 @@ async function visitorEventRewardBalance(request, h) {
|
||||||
return h.response(success({ total }));
|
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) {
|
async function submitMicrositeContact(request, h) {
|
||||||
const p = request.payload;
|
const p = request.payload;
|
||||||
if (p._gotcha) return h.response(error('VALIDATION_ERROR', 'Invalid request')).code(400);
|
if (p._gotcha) return h.response(error('VALIDATION_ERROR', 'Invalid request')).code(400);
|
||||||
|
|
@ -789,5 +1091,8 @@ module.exports = {
|
||||||
listVisitorSessionBookmarks,
|
listVisitorSessionBookmarks,
|
||||||
putVisitorSessionBookmark,
|
putVisitorSessionBookmark,
|
||||||
visitorEventRewardBalance,
|
visitorEventRewardBalance,
|
||||||
|
visitorEventRewardHistory,
|
||||||
|
visitorEventLeaderboard,
|
||||||
submitMicrositeContact,
|
submitMicrositeContact,
|
||||||
|
visitorFeedbackFormPreview,
|
||||||
};
|
};
|
||||||
|
|
@ -7,6 +7,25 @@ const VisitorAuthToken = require('../models/VisitorAuthToken');
|
||||||
const { success, error } = require('../utils/response');
|
const { success, error } = require('../utils/response');
|
||||||
const { sendVisitorOtpEmail, sendVisitorMagicLinkEmail } = require('../utils/mail');
|
const { sendVisitorOtpEmail, sendVisitorMagicLinkEmail } = require('../utils/mail');
|
||||||
const { getMembership, membershipGuestQuery } = require('../utils/participantMembership');
|
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 =
|
const ATTENDEE_SECRET =
|
||||||
process.env.ATTENDEE_JWT_SECRET || process.env.JWT_SECRET || 'default-secret-change-in-production';
|
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;
|
const orgId = event.organization;
|
||||||
let participant = await Participant.findOne({ tenantOrganization: orgId, email: em });
|
let participant = await Participant.findOne({ tenantOrganization: orgId, email: em });
|
||||||
const inEvent = participant && getMembership(participant, eventId);
|
const inEvent = participant && getMembership(participant, eventId);
|
||||||
|
let registrationRewardPoints = 0;
|
||||||
if (!inEvent) {
|
if (!inEvent) {
|
||||||
if (!allowsNewRegistration(event)) {
|
if (!allowsNewRegistration(event)) {
|
||||||
return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403);
|
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 {
|
} else {
|
||||||
participant.eventMemberships.push({
|
participant.eventMemberships.push({
|
||||||
event: eventId,
|
event: eventId,
|
||||||
|
|
@ -347,6 +368,7 @@ async function verifyOtp(request, h) {
|
||||||
if (phone) participant.phone = String(phone).trim();
|
if (phone) participant.phone = String(phone).trim();
|
||||||
if (displayName) participant.displayName = String(displayName).trim();
|
if (displayName) participant.displayName = String(displayName).trim();
|
||||||
await participant.save();
|
await participant.save();
|
||||||
|
registrationRewardPoints = await awardRegistrationIfNew(participant, eventId, event);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
participant.emailVerified = true;
|
participant.emailVerified = true;
|
||||||
|
|
@ -360,7 +382,10 @@ async function verifyOtp(request, h) {
|
||||||
const joinType = doc.joinType || 'event';
|
const joinType = doc.joinType || 'event';
|
||||||
const sid = doc.sessionId;
|
const sid = doc.sessionId;
|
||||||
const tid = doc.trackId;
|
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) {
|
async function startLink(request, h) {
|
||||||
|
|
@ -450,6 +475,7 @@ async function exchangeLink(request, h) {
|
||||||
const orgId = event.organization;
|
const orgId = event.organization;
|
||||||
let participant = await Participant.findOne({ tenantOrganization: orgId, email: em });
|
let participant = await Participant.findOne({ tenantOrganization: orgId, email: em });
|
||||||
const inEvent = participant && getMembership(participant, eventId);
|
const inEvent = participant && getMembership(participant, eventId);
|
||||||
|
let registrationRewardPoints = 0;
|
||||||
if (!inEvent) {
|
if (!inEvent) {
|
||||||
if (!allowsNewRegistration(event)) {
|
if (!allowsNewRegistration(event)) {
|
||||||
return h.response(error('REGISTRATION_CLOSED', 'Registration is closed for this event')).code(403);
|
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 {
|
} else {
|
||||||
participant.eventMemberships.push({
|
participant.eventMemberships.push({
|
||||||
event: eventId,
|
event: eventId,
|
||||||
|
|
@ -478,6 +505,7 @@ async function exchangeLink(request, h) {
|
||||||
});
|
});
|
||||||
participant.emailVerified = true;
|
participant.emailVerified = true;
|
||||||
await participant.save();
|
await participant.save();
|
||||||
|
registrationRewardPoints = await awardRegistrationIfNew(participant, eventId, event);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
participant.emailVerified = true;
|
participant.emailVerified = true;
|
||||||
|
|
@ -485,9 +513,10 @@ async function exchangeLink(request, h) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const joinType = doc.joinType || 'event';
|
const joinType = doc.joinType || 'event';
|
||||||
return h.response(
|
const base = authPayload(participant, event, eventId, joinType, doc.sessionId, doc.trackId);
|
||||||
success(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) {
|
async function guestStart(request, h) {
|
||||||
|
|
@ -542,6 +571,7 @@ async function guestStart(request, h) {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
const registrationRewardPoints = await awardRegistrationIfNew(participant, eventId, event);
|
||||||
|
|
||||||
const out = authPayload(
|
const out = authPayload(
|
||||||
participant,
|
participant,
|
||||||
|
|
@ -551,7 +581,11 @@ async function guestStart(request, h) {
|
||||||
oidStr(sessionId),
|
oidStr(sessionId),
|
||||||
oidStr(trackId)
|
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) {
|
async function registrationStatus(request, h) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
|
const { eventBuzzCodeFor } = require('../utils/eventBuzzCode');
|
||||||
|
const { rewardRulesSubSchema } = require('./rewardRulesSchema');
|
||||||
|
|
||||||
const eventSchema = new mongoose.Schema(
|
const eventSchema = new mongoose.Schema(
|
||||||
{
|
{
|
||||||
|
|
@ -32,6 +34,8 @@ const eventSchema = new mongoose.Schema(
|
||||||
virtualLink: String,
|
virtualLink: String,
|
||||||
tags: [String],
|
tags: [String],
|
||||||
image: 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: {
|
settings: {
|
||||||
registrationOpen: { type: Boolean, default: true },
|
registrationOpen: { type: Boolean, default: true },
|
||||||
maxParticipants: Number,
|
maxParticipants: Number,
|
||||||
|
|
@ -48,6 +52,8 @@ const eventSchema = new mongoose.Schema(
|
||||||
/** Optional shared code for Invite Only registration (visitor must pass inviteToken) */
|
/** Optional shared code for Invite Only registration (visitor must pass inviteToken) */
|
||||||
inviteCode: String,
|
inviteCode: String,
|
||||||
},
|
},
|
||||||
|
/** Loyalty point rules (defaults); session/quiz may override via mergeRules. */
|
||||||
|
rewardRules: rewardRulesSubSchema,
|
||||||
bannerImage: String,
|
bannerImage: String,
|
||||||
website: {
|
website: {
|
||||||
enabled: { type: Boolean, default: false },
|
enabled: { type: Boolean, default: false },
|
||||||
|
|
@ -101,4 +107,17 @@ eventSchema.index({ organization: 1 });
|
||||||
eventSchema.index({ status: 1 });
|
eventSchema.index({ status: 1 });
|
||||||
eventSchema.index({ startDate: 1 });
|
eventSchema.index({ startDate: 1 });
|
||||||
eventSchema.index({ 'website.folder': 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);
|
module.exports = mongoose.model('Event', eventSchema);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,10 @@ const questionSchema = new mongoose.Schema(
|
||||||
enum: ['pending', 'answered', 'archived'],
|
enum: ['pending', 'answered', 'archived'],
|
||||||
default: 'pending',
|
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 },
|
upvotes: { type: Number, default: 0 },
|
||||||
downvotes: { type: Number, default: 0 },
|
downvotes: { type: Number, default: 0 },
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
|
const { rewardRulesSubSchema } = require('./rewardRulesSchema');
|
||||||
|
|
||||||
const questionOptionSchema = new mongoose.Schema({
|
const questionOptionSchema = new mongoose.Schema({
|
||||||
text: String,
|
text: String,
|
||||||
|
|
@ -48,6 +49,7 @@ const quizPollSchema = new mongoose.Schema(
|
||||||
randomizeQuestions: { type: Boolean, default: false },
|
randomizeQuestions: { type: Boolean, default: false },
|
||||||
timeRestricted: { type: Boolean, default: false },
|
timeRestricted: { type: Boolean, default: false },
|
||||||
timeLimit: { type: Number, default: 0, min: 0 },
|
timeLimit: { type: Number, default: 0, min: 0 },
|
||||||
|
rewardRules: rewardRulesSubSchema,
|
||||||
},
|
},
|
||||||
deletedAt: { type: Date, default: null },
|
deletedAt: { type: Date, default: null },
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
|
const { REWARD_SOURCES } = require('../config/constants');
|
||||||
|
|
||||||
const rewardPointSchema = new mongoose.Schema(
|
const rewardPointSchema = new mongoose.Schema(
|
||||||
{
|
{
|
||||||
|
|
@ -20,9 +21,10 @@ const rewardPointSchema = new mongoose.Schema(
|
||||||
},
|
},
|
||||||
source: {
|
source: {
|
||||||
type: String,
|
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,
|
description: String,
|
||||||
expiresAt: Date,
|
expiresAt: Date,
|
||||||
metadata: { type: Map, of: mongoose.Schema.Types.Mixed },
|
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({ participant: 1, event: 1 });
|
||||||
rewardPointSchema.index({ 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);
|
module.exports = mongoose.model('RewardPoint', rewardPointSchema);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
const { eventBuzzCodeFor } = require('../utils/eventBuzzCode');
|
const { eventBuzzCodeFor } = require('../utils/eventBuzzCode');
|
||||||
|
const { rewardRulesSubSchema } = require('./rewardRulesSchema');
|
||||||
|
|
||||||
const sessionSchema = new mongoose.Schema(
|
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 },
|
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 }
|
{ timestamps: true }
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -23,6 +23,18 @@ const registrationFieldSchema = Joi.object({
|
||||||
options: Joi.array().items(fieldOptionSchema),
|
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({
|
const eventPayload = Joi.object({
|
||||||
name: Joi.string().min(3).max(100).required(),
|
name: Joi.string().min(3).max(100).required(),
|
||||||
subtitle: Joi.string().allow('', null),
|
subtitle: Joi.string().allow('', null),
|
||||||
|
|
@ -52,13 +64,28 @@ const eventPayload = Joi.object({
|
||||||
registrationBaseFields: Joi.string().valid(...REGISTRATION_BASE_FIELDS).allow('', null),
|
registrationBaseFields: Joi.string().valid(...REGISTRATION_BASE_FIELDS).allow('', null),
|
||||||
inviteCode: Joi.string().allow('', null),
|
inviteCode: Joi.string().allow('', null),
|
||||||
}),
|
}),
|
||||||
|
/** Align with Event.metadata in the model; allow extra keys from older clients. */
|
||||||
metadata: Joi.object({
|
metadata: Joi.object({
|
||||||
|
totalRevenue: Joi.number().allow(null),
|
||||||
|
rating: Joi.number().allow(null),
|
||||||
|
ratingCount: Joi.number().allow(null),
|
||||||
registrationCustomFields: Joi.array().items(registrationFieldSchema),
|
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 = [
|
module.exports = [
|
||||||
{ method: 'GET', path: '/api/events', options: { handler: controller.list } },
|
{ 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: 'POST', path: '/api/events', options: { validate: { payload: eventPayload }, handler: controller.create } },
|
||||||
{ method: 'GET', path: '/api/events/{id}', options: { handler: controller.get } },
|
{ method: 'GET', path: '/api/events/{id}', options: { handler: controller.get } },
|
||||||
{ method: 'PUT', path: '/api/events/{id}', options: { validate: { payload: eventPayload }, handler: controller.update } },
|
{ method: 'PUT', path: '/api/events/{id}', options: { validate: { payload: eventPayload }, handler: controller.update } },
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,26 @@ const answerPayload = Joi.object({
|
||||||
isOfficial: Joi.boolean(),
|
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 = [
|
module.exports = [
|
||||||
{ method: 'GET', path: '/api/events/{eventId}/questions', options: { handler: controller.list } },
|
{ 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: 'POST', path: '/api/events/{eventId}/questions', options: { validate: { payload: questionPayload }, handler: controller.create } },
|
||||||
{ method: 'GET', path: '/api/questions/{id}', options: { handler: controller.get } },
|
{ method: 'GET', path: '/api/questions/{id}', options: { handler: controller.get } },
|
||||||
{ method: 'PUT', path: '/api/questions/{id}', options: { validate: { payload: questionPayload }, handler: controller.update } },
|
{ 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: '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}/upvote', options: { handler: controller.upvote } },
|
||||||
{ method: 'POST', path: '/api/questions/{id}/answer', options: { validate: { payload: answerPayload }, handler: controller.answer } },
|
{ method: 'POST', path: '/api/questions/{id}/answer', options: { validate: { payload: answerPayload }, handler: controller.answer } },
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,18 @@ const quizQuestionSchema = Joi.object({
|
||||||
points: Joi.number(),
|
points: Joi.number(),
|
||||||
required: Joi.boolean(),
|
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({
|
const quizPollPayload = Joi.object({
|
||||||
session: Joi.string(),
|
session: Joi.string(),
|
||||||
title: Joi.string().required(),
|
title: Joi.string().required(),
|
||||||
|
|
@ -32,6 +44,7 @@ const quizPollPayload = Joi.object({
|
||||||
randomizeQuestions: Joi.boolean(),
|
randomizeQuestions: Joi.boolean(),
|
||||||
timeRestricted: Joi.boolean(),
|
timeRestricted: Joi.boolean(),
|
||||||
timeLimit: Joi.number().integer().min(0),
|
timeLimit: Joi.number().integer().min(0),
|
||||||
|
rewardRules: rewardRulesPayload,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,16 @@ module.exports = [
|
||||||
{ method: 'PUT', path: '/api/reward-points/{id}', options: { validate: { payload: rewardPayload }, handler: controller.update } },
|
{ 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: '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/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 } },
|
{ method: 'POST', path: '/api/events/{eventId}/reward-points/bulk', options: { validate: { payload: bulkPayload }, handler: controller.bulk } },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,18 @@ const Joi = require('joi');
|
||||||
const controller = require('../controllers/session.controller');
|
const controller = require('../controllers/session.controller');
|
||||||
const { SESSION_TYPES, SESSION_STATUS } = require('../config/constants');
|
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({
|
const sessionPayload = Joi.object({
|
||||||
title: Joi.string().required(),
|
title: Joi.string().required(),
|
||||||
description: Joi.string().allow('', null),
|
description: Joi.string().allow('', null),
|
||||||
|
|
@ -15,6 +27,11 @@ const sessionPayload = Joi.object({
|
||||||
capacity: Joi.number(),
|
capacity: Joi.number(),
|
||||||
status: Joi.string().valid(...SESSION_STATUS),
|
status: Joi.string().valid(...SESSION_STATUS),
|
||||||
resources: Joi.array().items(Joi.object({ name: Joi.string(), type: Joi.string(), url: Joi.string() })),
|
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 = [
|
module.exports = [
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,20 @@ module.exports = [
|
||||||
handler: controller.trackPreview,
|
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',
|
method: 'GET',
|
||||||
path: '/api/visitor/sessions/{sessionId}/questions',
|
path: '/api/visitor/sessions/{sessionId}/questions',
|
||||||
|
|
@ -309,6 +323,18 @@ module.exports = [
|
||||||
handler: controller.putVisitorSessionBookmark,
|
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',
|
method: 'GET',
|
||||||
path: '/api/visitor/events/{eventId}/reward-balance',
|
path: '/api/visitor/events/{eventId}/reward-balance',
|
||||||
|
|
@ -320,6 +346,21 @@ module.exports = [
|
||||||
handler: controller.visitorEventRewardBalance,
|
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',
|
method: 'POST',
|
||||||
path: '/api/visitor/events/{eventId}/quiz-polls/{quizPollId}/responses',
|
path: '/api/visitor/events/{eventId}/quiz-polls/{quizPollId}/responses',
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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';
|
const EVENTBUZZ_CODE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} id Mongo document id string
|
* @param {string} id Mongo document id string
|
||||||
* @param {'track' | 'session'} kind
|
* @param {'track' | 'session' | 'event'} kind
|
||||||
*/
|
*/
|
||||||
function eventBuzzCodeFor(id, kind) {
|
function eventBuzzCodeFor(id, kind) {
|
||||||
const input = `${kind}:${id}`;
|
const input = `${kind}:${id}`;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -37,6 +37,8 @@ function questionToRtdbPayload(doc) {
|
||||||
upvotes: typeof o.upvotes === 'number' ? o.upvotes : 0,
|
upvotes: typeof o.upvotes === 'number' ? o.upvotes : 0,
|
||||||
downvotes: typeof o.downvotes === 'number' ? o.downvotes : 0,
|
downvotes: typeof o.downvotes === 'number' ? o.downvotes : 0,
|
||||||
status: o.status != null ? o.status : 'pending',
|
status: o.status != null ? o.status : 'pending',
|
||||||
|
shortlisted: Boolean(o.shortlisted),
|
||||||
|
audienceListed: o.audienceListed !== false,
|
||||||
createdAt:
|
createdAt:
|
||||||
o.createdAt instanceof Date
|
o.createdAt instanceof Date
|
||||||
? o.createdAt.toISOString()
|
? o.createdAt.toISOString()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue