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_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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
||||
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 } },
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
/**
|
||||
* @param {string} id Mongo document id string
|
||||
* @param {'track' | 'session'} kind
|
||||
* @param {'track' | 'session' | 'event'} kind
|
||||
*/
|
||||
function eventBuzzCodeFor(id, kind) {
|
||||
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,
|
||||
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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue