This commit is contained in:
Ravi Gulhane 2026-07-03 16:54:18 +05:30
parent 347005322a
commit 3d1784be39
8 changed files with 425 additions and 10 deletions

View File

@ -77,7 +77,9 @@ BEDROCK_MODEL_ID=meta.llama3-8b-instruct-v1:0
# CONCIERGE_CRAWL_DELAY_MS=200 # CONCIERGE_CRAWL_DELAY_MS=200
# CONCIERGE_CRAWL_MAX_ASSET_BYTES=15728640 # CONCIERGE_CRAWL_MAX_ASSET_BYTES=15728640
# Public API base URL injected into published microsite HTML for contact forms (e.g. https://api.example.com/api or http://localhost:3001/api) # HMAC secret for public form captcha tokens (falls back to JWT_SECRET if unset)
# FORM_CAPTCHA_SECRET=
MICROSITE_API_BASE_URL=http://localhost:3001/api MICROSITE_API_BASE_URL=http://localhost:3001/api
# Public web app (forms, registration, default microsites) # Public web app (forms, registration, default microsites)
# EVENTBUZZ_PUBLIC_URL=https://web.eventbuzz.live # EVENTBUZZ_PUBLIC_URL=https://web.eventbuzz.live

View File

@ -0,0 +1,232 @@
/**
* Copy a quiz document into another account's event.
* Run: node scripts/copy-quiz-to-account.js
*/
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const mongoose = require('mongoose');
const QuizPoll = require('../src/models/QuizPoll');
const Event = require('../src/models/Event');
const Session = require('../src/models/Session');
const User = require('../src/models/User');
const { syncQuizPollToFirebase } = require('../src/utils/quizPollFirebaseSync');
const TARGET_EMAIL = 'madhuragauri@crestindia.co.in';
/** Source quiz payload (Mongo export; ids stripped on insert). */
const SOURCE_QUIZ = {
title: 'COPD Basics',
type: 'quiz',
description: 'test',
questions: [
{
question:
'What spirometry value defines airflow limitation and confirms a COPD diagnosis after a bronchodilator is given?',
imageUrl:
'https://did94hhhcgq1d.cloudfront.net/eventbuzz/69b58b13aff81b3887b0026c/69ce3c91be56b4a3b09b2333/quiz-polls/1782468667817-Screenshot_2026-06-01_at_10.01.48_AM.png',
type: 'single',
options: [
{
text: 'FEV1/FVC greater than 0.80',
isCorrect: false,
imageUrl:
'https://did94hhhcgq1d.cloudfront.net/eventbuzz/69b58b13aff81b3887b0026c/69ce3c91be56b4a3b09b2333/quiz-polls/1782468677271-Screenshot_2026-05-21_at_7.56.07_PM.png',
},
{
text: 'FEV1/FVC less than 0.70',
isCorrect: true,
imageUrl:
'https://did94hhhcgq1d.cloudfront.net/eventbuzz/69b58b13aff81b3887b0026c/69ce3c91be56b4a3b09b2333/quiz-polls/1782468680923-Screenshot_2026-05-21_at_7.56.07_PM.png',
},
{ text: 'FVC less than 50% predicted', isCorrect: false, imageUrl: null },
{
text: 'FEV1 increases by 12%',
isCorrect: false,
imageUrl:
'https://did94hhhcgq1d.cloudfront.net/eventbuzz/69b58b13aff81b3887b0026c/69ce3c91be56b4a3b09b2333/quiz-polls/1782468683856-Screenshot_2026-05-21_at_7.48.20_PM.png',
},
],
points: 1,
required: true,
timeLimit: 30,
},
{
question:
'What structural lung change in emphysema leads to permanent air trapping and progressive shortness of breath?',
imageUrl: null,
type: 'single',
options: [
{ text: 'Reversible smooth muscle spasm', isCorrect: false, imageUrl: null },
{
text: 'Destruction of alveolar walls and loss of elastic recoil',
isCorrect: true,
imageUrl: null,
},
{ text: 'Fibrosis and narrowing of the upper airways', isCorrect: false, imageUrl: null },
{ text: 'Overproduction of mucus in the large bronchi', isCorrect: false, imageUrl: null },
],
points: 1,
required: true,
timeLimit: 30,
},
{
question: 'What is the globally recognized leading environmental cause of COPD?',
imageUrl: null,
type: 'single',
options: [
{ text: 'Alpha-1 antitrypsin deficiency', isCorrect: false, imageUrl: null },
{ text: 'Outdoor air pollution', isCorrect: false, imageUrl: null },
{ text: 'Cigarette smoking', isCorrect: true, imageUrl: null },
{ text: 'Occupational dust exposure', isCorrect: false, imageUrl: null },
],
points: 1,
required: true,
timeLimit: 30,
},
{
question:
'Which drug class is the baseline maintenance treatment for reducing daily COPD symptoms and preventing flare-ups?',
imageUrl: null,
type: 'single',
options: [
{ text: 'Short-acting beta-agonists (SABAs)', isCorrect: false, imageUrl: null },
{ text: 'Systemic corticosteroids', isCorrect: false, imageUrl: null },
{
text: 'Long-acting bronchodilators (LAMAs or LABAs)',
isCorrect: true,
imageUrl: null,
},
{ text: 'Mucolytics', isCorrect: false, imageUrl: null },
],
points: 1,
required: true,
timeLimit: 30,
},
{
question:
'What is the minimum number of hours per day that long-term oxygen therapy must be used to improve survival rates in severe resting hypoxemia?',
imageUrl: null,
type: 'single',
options: [
{ text: 'At least 5 hours per day', isCorrect: false, imageUrl: null },
{ text: 'At least 10 hours per day', isCorrect: false, imageUrl: null },
{ text: 'At least 15 hours per day', isCorrect: true, imageUrl: null },
{ text: 'Only during sleep', isCorrect: false, imageUrl: null },
],
points: 1,
required: true,
timeLimit: 30,
},
],
settings: {
status: 'active',
isLive: false,
displayMode: 'quiz_master',
startTime: null,
endTime: null,
showResults: true,
allowMultipleAttempts: false,
timeRestricted: true,
timeLimit: 150,
rewardRules: {
quizPollSubmitted: 0,
quizCorrectPerQuestion: 5,
},
liveQuestionId: null,
randomizeQuestions: false,
showCorrectAnswer: false,
},
};
async function run() {
const uri = process.env.MONGODB_URI;
if (!uri) {
console.error('MONGODB_URI is not set');
process.exit(1);
}
await mongoose.connect(uri);
const user = await User.findOne({ email: TARGET_EMAIL }).lean();
if (!user) {
console.error('User not found:', TARGET_EMAIL);
process.exit(1);
}
const event = await Event.findOne({ organization: user.organization, deletedAt: null })
.sort({ createdAt: -1 })
.lean();
if (!event) {
console.error('No event found for organization', user.organization);
process.exit(1);
}
const session = await Session.findOne({ event: event._id }).sort({ startTime: 1 }).lean();
if (!session) {
console.error('No session found for event', event._id);
process.exit(1);
}
const existing = await QuizPoll.findOne({
event: event._id,
title: SOURCE_QUIZ.title,
type: 'quiz',
deletedAt: null,
}).lean();
if (existing) {
console.log('Quiz already exists — skipping create');
console.log(
JSON.stringify(
{
quizId: String(existing._id),
eventId: String(event._id),
eventName: event.name,
sessionId: existing.session ? String(existing.session) : null,
title: existing.title,
},
null,
2,
),
);
await mongoose.disconnect();
return;
}
const payload = {
...SOURCE_QUIZ,
event: event._id,
session: session._id,
settings: {
...SOURCE_QUIZ.settings,
presentationToken: QuizPoll.generatePresentationToken(),
},
};
const item = await QuizPoll.create(payload);
await syncQuizPollToFirebase(item);
console.log('Quiz created successfully');
console.log(
JSON.stringify(
{
quizId: String(item._id),
title: item.title,
eventId: String(event._id),
eventName: event.name,
sessionId: String(session._id),
sessionTitle: session.title,
accountEmail: TARGET_EMAIL,
questionCount: item.questions?.length ?? 0,
presentationToken: item.settings?.presentationToken,
},
null,
2,
),
);
await mongoose.disconnect();
}
run().catch((err) => {
console.error(err);
process.exit(1);
});

View File

@ -33,6 +33,7 @@ const badgeBatchRoutes = require('./routes/badgeBatch.routes');
const eventTicketRoutes = require('./routes/eventTicket.routes'); const eventTicketRoutes = require('./routes/eventTicket.routes');
const publicEventTicketRoutes = require('./routes/publicEventTicket.routes'); const publicEventTicketRoutes = require('./routes/publicEventTicket.routes');
const publicMicrositeRoutes = require('./routes/publicMicrosite.routes'); const publicMicrositeRoutes = require('./routes/publicMicrosite.routes');
const publicCaptchaRoutes = require('./routes/publicCaptcha.routes');
const publicAttendanceRoutes = require('./routes/publicAttendance.routes'); const publicAttendanceRoutes = require('./routes/publicAttendance.routes');
const attendanceRoutes = require('./routes/attendance.routes'); const attendanceRoutes = require('./routes/attendance.routes');
const checkinsRoutes = require('./routes/checkins.routes'); const checkinsRoutes = require('./routes/checkins.routes');
@ -85,6 +86,22 @@ const createServer = async () => {
const allowedOrigins = [...new Set([...originList, ...builderExtras, ...conciergeExtras])]; const allowedOrigins = [...new Set([...originList, ...builderExtras, ...conciergeExtras])];
function isPublicMicrositeApiPath(path) {
if (!path) return false;
if (path.startsWith('/api/public/')) return true;
return /^\/api\/forms\/[^/]+\/submit$/.test(path);
}
function resolveCorsForRequest(request) {
const origin = request.headers.origin;
const allowed = resolveCorsAllowOrigin(origin);
if (allowed) return allowed;
if (origin && isPublicMicrositeApiPath(request.path)) {
return String(origin).trim().replace(/\/$/, '');
}
return null;
}
/** In development, allow any http://localhost or http://127.0.0.1 port (Vite may use 5175, 5176, …). Production: whitelist only. */ /** In development, allow any http://localhost or http://127.0.0.1 port (Vite may use 5175, 5176, …). Production: whitelist only. */
function resolveCorsAllowOrigin(rawOrigin) { function resolveCorsAllowOrigin(rawOrigin) {
if (!rawOrigin) return null; if (!rawOrigin) return null;
@ -136,7 +153,7 @@ const createServer = async () => {
options: { options: {
auth: false, auth: false,
handler: (request, h) => { handler: (request, h) => {
const allowOrigin = resolveCorsAllowOrigin(request.headers.origin); const allowOrigin = resolveCorsForRequest(request);
const res = h.response().code(204); const res = h.response().code(204);
if (allowOrigin) { if (allowOrigin) {
res.header('Access-Control-Allow-Origin', allowOrigin); res.header('Access-Control-Allow-Origin', allowOrigin);
@ -228,6 +245,7 @@ const createServer = async () => {
server.route(visitorRoutes); server.route(visitorRoutes);
server.route(publicEventTicketRoutes); server.route(publicEventTicketRoutes);
server.route(publicMicrositeRoutes); server.route(publicMicrositeRoutes);
server.route(publicCaptchaRoutes);
server.route(publicAttendanceRoutes); server.route(publicAttendanceRoutes);
server.route(publicConciergeRoutes); server.route(publicConciergeRoutes);
server.route(publicQuizPollRoutes); server.route(publicQuizPollRoutes);
@ -268,7 +286,7 @@ const createServer = async () => {
server.ext('onPreResponse', (request, h) => { server.ext('onPreResponse', (request, h) => {
const response = request.response; const response = request.response;
const allowOrigin = resolveCorsAllowOrigin(request.headers.origin); const allowOrigin = resolveCorsForRequest(request);
if (response.isBoom && response.output) { if (response.isBoom && response.output) {
const statusCode = response.output.statusCode; const statusCode = response.output.statusCode;

View File

@ -12,6 +12,69 @@ const {
buildPublicEventPayload, buildPublicEventPayload,
resolveEventForPublicForm, resolveEventForPublicForm,
} = require('../utils/formPublicHelpers'); } = require('../utils/formPublicHelpers');
const { verifyCaptchaChallenge } = require('../utils/publicCaptcha');
const { getMembership } = require('../utils/participantMembership');
function throwRegError(message, code) {
const err = new Error(message);
err.code = code;
throw err;
}
function eventRegistrationStatus(event) {
const settings = event.settings || {};
if (settings.requiresApproval === true) return 'pending';
const mode = String(settings.registrationMode || 'Open').trim();
if (mode === 'Approval Required') return 'pending';
return 'confirmed';
}
async function assertCanRegisterForEvent(event) {
const settings = event.settings || {};
if (settings.registrationOpen === false) {
throwRegError('Registration is closed for this event', 'REGISTRATION_CLOSED');
}
const deadline = settings.registrationDeadline;
if (deadline && new Date(deadline).getTime() <= Date.now()) {
throwRegError('Registration deadline has passed', 'REGISTRATION_CLOSED');
}
const max = settings.maxParticipants;
if (max && max > 0) {
const count = await Participant.countDocuments({
eventMemberships: { $elemMatch: { event: event._id } },
});
if (count >= max) {
throwRegError('This event has reached maximum participants', 'REGISTRATION_FULL');
}
}
}
async function ensureEventMembershipForRegistrationForm(form, participant) {
const { ev: resolvedEvent } = await resolveEventForPublicForm(form);
if (!resolvedEvent?._id) return;
const event = await Event.findById(resolvedEvent._id).select('settings').lean();
if (!event) return;
const eventId = event._id;
if (getMembership(participant, eventId)) return;
await assertCanRegisterForEvent(event);
participant.eventMemberships = participant.eventMemberships || [];
participant.eventMemberships.push({
event: eventId,
status: eventRegistrationStatus(event),
registrationDate: new Date(),
});
await participant.save();
}
async function resolveSubmissionEventId(form) {
const directId = extractObjectIdString(form.event);
if (directId) return directId;
const { ev } = await resolveEventForPublicForm(form);
return ev?._id ? String(ev._id) : null;
}
function normalizeSubmitData(raw) { function normalizeSubmitData(raw) {
if (!raw) return {}; if (!raw) return {};
@ -118,6 +181,8 @@ async function upsertParticipantFromRegistrationForm(form, tenantOrganization, d
} }
} }
await ensureEventMembershipForRegistrationForm(form, p);
return p._id; return p._id;
} }
@ -284,8 +349,24 @@ const submit = async (request, h) => {
if (!form.settings?.isActive) return h.response(error('VALIDATION_ERROR', 'Form is not accepting submissions')).code(400); if (!form.settings?.isActive) return h.response(error('VALIDATION_ERROR', 'Form is not accepting submissions')).code(400);
const user = request.auth?.credentials?.user; const user = request.auth?.credentials?.user;
if (!user) {
if (request.payload._gotcha) {
return h.response(error('VALIDATION_ERROR', 'Invalid request')).code(400);
}
const captchaToken = request.payload.captchaToken;
if (captchaToken) {
const captchaOk = verifyCaptchaChallenge(captchaToken, request.payload.captchaAnswer);
if (!captchaOk) {
return h.response(error('VALIDATION_ERROR', 'Captcha verification failed. Please try again.')).code(400);
}
} else if (process.env.PUBLIC_FORM_CAPTCHA_REQUIRED === 'true') {
return h.response(error('VALIDATION_ERROR', 'Captcha verification failed. Please try again.')).code(400);
}
}
const tenantOrganization = form.tenantOrganization; const tenantOrganization = form.tenantOrganization;
const eventRef = form.event; const submissionEventId = await resolveSubmissionEventId(form);
if (!tenantOrganization) { if (!tenantOrganization) {
return h.response(error('VALIDATION_ERROR', 'Form is not configured for submissions (missing organization)')).code(400); return h.response(error('VALIDATION_ERROR', 'Form is not configured for submissions (missing organization)')).code(400);
} }
@ -300,9 +381,13 @@ const submit = async (request, h) => {
if ( if (
err.code === 'EMAIL_REQUIRED' || err.code === 'EMAIL_REQUIRED' ||
err.code === 'REGISTRATION_REQUIRED' || err.code === 'REGISTRATION_REQUIRED' ||
err.code === 'FORM_FIELD_CONFIG' err.code === 'FORM_FIELD_CONFIG' ||
err.code === 'REGISTRATION_CLOSED' ||
err.code === 'REGISTRATION_FULL'
) { ) {
return h.response(error('VALIDATION_ERROR', err.message || 'Validation failed')).code(400); const statusCode =
err.code === 'REGISTRATION_CLOSED' || err.code === 'REGISTRATION_FULL' ? 403 : 400;
return h.response(error(err.code, err.message || 'Validation failed')).code(statusCode);
} }
throw err; throw err;
} }
@ -311,15 +396,15 @@ const submit = async (request, h) => {
const submission = await FormSubmission.create({ const submission = await FormSubmission.create({
form: formId, form: formId,
tenantOrganization, tenantOrganization,
event: eventRef || undefined, event: submissionEventId || undefined,
user: user?._id, user: user?._id,
participant: participantId, participant: participantId,
data: dataObj, data: dataObj,
}); });
if (eventRef) { if (submissionEventId) {
const summary = await computeEventRatingsSummary(eventRef.toString()); const summary = await computeEventRatingsSummary(submissionEventId);
await Event.findByIdAndUpdate(eventRef, { await Event.findByIdAndUpdate(submissionEventId, {
$set: { $set: {
'metadata.rating': summary.average, 'metadata.rating': summary.average,
'metadata.ratingCount': summary.count, 'metadata.ratingCount': summary.count,

View File

@ -0,0 +1,11 @@
const { success } = require('../utils/response');
const { createCaptchaChallenge } = require('../utils/publicCaptcha');
const getChallenge = async (request, h) => {
const challenge = createCaptchaChallenge();
return h.response(success(challenge));
};
module.exports = {
getChallenge,
};

View File

@ -60,6 +60,9 @@ const formOrgCreatePayload = Joi.object({
const submitPayload = Joi.object({ const submitPayload = Joi.object({
participantId: Joi.string(), participantId: Joi.string(),
data: Joi.object().pattern(Joi.string(), Joi.any()), data: Joi.object().pattern(Joi.string(), Joi.any()),
captchaToken: Joi.string().allow('', null),
captchaAnswer: Joi.alternatives().try(Joi.string(), Joi.number()).allow('', null),
_gotcha: Joi.string().allow('', null),
}); });
module.exports = [ module.exports = [

View File

@ -0,0 +1,9 @@
const controller = require('../controllers/publicCaptcha.controller');
module.exports = [
{
method: 'GET',
path: '/api/public/captcha/challenge',
options: { auth: false, handler: controller.getChallenge },
},
];

View File

@ -0,0 +1,55 @@
const crypto = require('crypto');
const TTL_MS = 10 * 60 * 1000;
function captchaSecret() {
return process.env.FORM_CAPTCHA_SECRET || process.env.JWT_SECRET || 'eventbuzz-form-captcha-dev';
}
function createCaptchaChallenge() {
const a = Math.floor(Math.random() * 9) + 1;
const b = Math.floor(Math.random() * 9) + 1;
const answer = a + b;
const expires = Date.now() + TTL_MS;
const signature = crypto.createHmac('sha256', captchaSecret()).update(`${answer}:${expires}`).digest('hex');
return {
question: `${a} + ${b} = ?`,
captchaToken: `${signature}.${expires}`,
};
}
function verifyCaptchaChallenge(captchaToken, captchaAnswer) {
if (!captchaToken || captchaAnswer == null || captchaAnswer === '') {
return false;
}
const answer = parseInt(String(captchaAnswer).trim(), 10);
if (Number.isNaN(answer)) {
return false;
}
const parts = String(captchaToken).split('.');
if (parts.length !== 2) {
return false;
}
const [signature, expiresRaw] = parts;
const expires = parseInt(expiresRaw, 10);
if (!expires || Date.now() > expires) {
return false;
}
const expected = crypto.createHmac('sha256', captchaSecret()).update(`${answer}:${expires}`).digest('hex');
try {
return crypto.timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'));
} catch {
return false;
}
}
module.exports = {
createCaptchaChallenge,
verifyCaptchaChallenge,
};