Add event games functionality

This commit is contained in:
Tanuja Pansare 2026-10-01 17:55:43 +05:30
parent 843901280e
commit 237a754eb8
10 changed files with 345 additions and 0 deletions

View File

@ -66,6 +66,8 @@ const eventPhotoSearchRoutes = require('./routes/eventPhotoSearch.routes');
const socialShareRoutes = require('./routes/socialShare.routes'); const socialShareRoutes = require('./routes/socialShare.routes');
const aiAvatarRoutes = require('./routes/aiAvatar.routes'); const aiAvatarRoutes = require('./routes/aiAvatar.routes');
const engagementRoutes = require('./routes/engagement.routes'); const engagementRoutes = require('./routes/engagement.routes');
const eventGamesRoutes = require('./routes/eventGames.routes');
const publicEventGamesRoutes = require('./routes/publicEventGames.routes');
const createServer = async () => { const createServer = async () => {
/** Production: set CORS_ORIGIN (or FRONTEND_URL) to every origin that loads the SPA, e.g. https://console.example.com (no trailing slash). */ /** Production: set CORS_ORIGIN (or FRONTEND_URL) to every origin that loads the SPA, e.g. https://console.example.com (no trailing slash). */
@ -307,6 +309,7 @@ const createServer = async () => {
server.route(publicSocialShareRoutes); server.route(publicSocialShareRoutes);
server.route(publicAiAvatarRoutes); server.route(publicAiAvatarRoutes);
server.route(publicEngagementRoutes); server.route(publicEngagementRoutes);
server.route(publicEventGamesRoutes);
server.route(publicVisitorThemeRoutes); server.route(publicVisitorThemeRoutes);
server.route(authRoutes); server.route(authRoutes);
@ -353,6 +356,7 @@ const createServer = async () => {
server.route(socialShareRoutes); server.route(socialShareRoutes);
server.route(aiAvatarRoutes); server.route(aiAvatarRoutes);
server.route(engagementRoutes); server.route(engagementRoutes);
server.route(eventGamesRoutes);
server.route(conciergeBotRoutes); server.route(conciergeBotRoutes);
server.route(attendanceRoutes); server.route(attendanceRoutes);
server.route(checkinsRoutes); server.route(checkinsRoutes);

View File

@ -20,6 +20,16 @@ const { buildEventAnalytics } = require('../services/eventAnalytics.service');
const { assertModulePermission, userOrgId, buildFormAccessFilter } = require('../services/acl.service'); const { assertModulePermission, userOrgId, buildFormAccessFilter } = require('../services/acl.service');
const { resolveAccessibleEventIds, canAccessEvent } = require('../services/eventScope.service'); const { resolveAccessibleEventIds, canAccessEvent } = require('../services/eventScope.service');
const ACTIVITY_MODULE_KEYS = ['photoSearch', 'socialShare', 'aiAvatar', 'photoWall', 'spotlight', 'matchUp', 'quizGame'];
const ACTIVITY_MODULE_PERMISSIONS = {
socialShare: 'social_share',
aiAvatar: 'ai_avatar',
photoWall: 'photo_wall',
spotlight: 'photo_wall',
matchUp: 'content_management',
quizGame: 'content_management',
};
function parseDataUrl(dataUrl) { function parseDataUrl(dataUrl) {
if (typeof dataUrl !== 'string') return null; if (typeof dataUrl !== 'string') return null;
const trimmed = dataUrl.trim(); const trimmed = dataUrl.trim();
@ -629,6 +639,34 @@ const update = async (request, h) => {
return h.response(success(event)); return h.response(success(event));
}; };
const updateActivityModules = async (request, h) => {
const event = await Event.findById(request.params.id);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const user = request.auth.credentials.user;
const orgId = userOrgId(user);
const eventOrgId = event.organization?._id || event.organization;
if (user.role !== 'super_admin' && String(eventOrgId) !== String(orgId)) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const requested = [...new Set(request.payload.activityModules || [])];
const activityModules = ACTIVITY_MODULE_KEYS.filter((key) => requested.includes(key));
const current = [...new Set(event.settings?.activityModules || [])].filter((key) => ACTIVITY_MODULE_KEYS.includes(key));
const changed = new Set([...current, ...activityModules].filter((key) => current.includes(key) !== activityModules.includes(key)));
for (const moduleKey of changed) {
const permissionKey = ACTIVITY_MODULE_PERMISSIONS[moduleKey];
if (permissionKey) {
await assertModulePermission(user, orgId, permissionKey, 'write');
}
}
event.set('settings.activityModules', activityModules);
await event.save();
return h.response(success({ activityModules }, 'Activity modules updated')).code(200);
};
const remove = async (request, h) => { const remove = async (request, h) => {
const event = await Event.findById(request.params.id); const event = await Event.findById(request.params.id);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
@ -746,6 +784,7 @@ module.exports = {
generateFromAi, generateFromAi,
get, get,
update, update,
updateActivityModules,
remove, remove,
analytics, analytics,
duplicate, duplicate,

View File

@ -0,0 +1,187 @@
const mongoose = require('mongoose');
const Event = require('../models/Event');
const EventMatchUp = require('../models/EventMatchUp');
const EventGameQuiz = require('../models/EventGameQuiz');
const EventGameQuizResult = require('../models/EventGameQuizResult');
const { success, error } = require('../utils/response');
const { assertModulePermission, userOrgId } = require('../services/acl.service');
const GAME_META = {
matchUp: { Model: EventMatchUp, permission: 'content_management' },
quizGame: { Model: EventGameQuiz, permission: 'content_management' },
};
function baseUrl() {
return (process.env.EVENTBUZZ_PUBLIC_URL || 'http://localhost:5174').replace(/\/$/, '');
}
async function getEvent(eventId) {
if (!mongoose.Types.ObjectId.isValid(eventId)) return null;
return Event.findById(eventId).select('organization').lean();
}
function allowed(user, event) {
return user?.role === 'super_admin' || String(event?.organization) === String(user?.organization?._id || user?.organization);
}
function sanitizeMatchUp(doc, publicView = false) {
const value = doc.toObject ? doc.toObject() : doc;
const groups = (value.groups || []).map((group) => ({
_id: String(group._id), title: publicView ? '' : group.title, description: publicView ? '' : (group.description || ''),
items: (group.items || []).map((item) => ({ _id: String(item._id), text: item.text, imageUrl: item.imageUrl || '' })),
}));
if (publicView) return { name: value.name, backgroundImage: value.backgroundImage, gridSize: value.gridSize, publicToken: value.publicToken, groups };
return { ...value, _id: String(value._id), event: String(value.event), organization: String(value.organization), groups };
}
function sanitizeQuiz(doc, publicView = false) {
const value = doc.toObject ? doc.toObject() : doc;
const questions = (value.questions || []).map((q) => ({ _id: String(q._id), question: q.question, options: q.options || [], ...(publicView ? {} : { correctIndex: q.correctIndex, points: 5 }) }));
return { ...value, _id: String(value._id), event: String(value.event), organization: String(value.organization), questions, ...(publicView ? { publicToken: value.publicToken } : {}) };
}
async function getOrCreate(game, eventId) {
const event = await getEvent(eventId);
if (!event) return null;
const { Model } = GAME_META[game];
let doc = await Model.findOne({ event: eventId });
if (!doc) doc = await Model.create({ event: eventId, organization: event.organization, publicToken: Model.generateToken() });
if (game === 'quizGame' && doc.name === 'Know Your Cricket') { doc.name = 'Quiz'; await doc.save(); }
if (!doc.publicToken) { doc.publicToken = Model.generateToken(); await doc.save(); }
return doc;
}
async function manageGet(game, request, h) {
const event = await getEvent(request.params.eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const user = request.auth.credentials.user;
if (!allowed(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
await assertModulePermission(user, userOrgId(user), GAME_META[game].permission, 'read');
const doc = await getOrCreate(game, request.params.eventId);
return h.response(success(game === 'matchUp' ? sanitizeMatchUp(doc) : sanitizeQuiz(doc)));
}
function validateMatchUp(payload) {
const size = Number(payload.gridSize);
if (![3, 4, 5].includes(size)) return 'Grid size must be 3, 4, or 5';
if (!Array.isArray(payload.groups) || payload.groups.length !== size) return `Add exactly ${size} groups`;
if (payload.groups.some((g) => !g?.title || !Array.isArray(g.items) || g.items.length !== size || g.items.some((i) => !i?.text))) return `Each group must contain ${size} items and a title`;
const itemTexts = payload.groups.flatMap((g) => g.items.map((item) => String(item.text).trim().toLowerCase()));
if (new Set(itemTexts).size !== itemTexts.length) return 'Every Match Up tile must be unique';
return null;
}
function validateQuiz(payload) {
if (!Array.isArray(payload.questions) || payload.questions.length < 1 || payload.questions.length > 50) return 'Add between 1 and 50 questions';
if (payload.questions.some((q) => !q?.question || !Array.isArray(q.options) || q.options.length < 2 || q.correctIndex < 0 || q.correctIndex >= q.options.length)) return 'Every question needs text, two options, and a valid answer';
return null;
}
async function manageUpdate(game, request, h) {
const event = await getEvent(request.params.eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const user = request.auth.credentials.user;
if (!allowed(user, event)) return h.response(error('FORBIDDEN', 'Access denied')).code(403);
await assertModulePermission(user, userOrgId(user), GAME_META[game].permission, 'write');
const doc = await getOrCreate(game, request.params.eventId);
const payload = request.payload || {};
const invalid = game === 'matchUp' ? validateMatchUp(payload) : validateQuiz(payload);
if (invalid) return h.response(error('VALIDATION_ERROR', invalid)).code(400);
doc.name = String(payload.name || doc.name || (game === 'matchUp' ? 'Match Up' : 'Quiz')).trim().slice(0, 120);
doc.backgroundImage = String(payload.backgroundImage || '').trim().slice(0, 2000);
doc.enabled = payload.enabled !== false;
if (game === 'matchUp') {
doc.gridSize = Number(payload.gridSize);
doc.groups = payload.groups.map((group) => ({ ...group, description: String(group.description || '').trim().slice(0, 500) }));
} else {
doc.questions = payload.questions.map((question) => ({ ...question, points: 5 }));
}
await doc.save();
return h.response(success(game === 'matchUp' ? sanitizeMatchUp(doc) : sanitizeQuiz(doc), 'Game saved'));
}
async function publicMatchUp(request, h) {
const doc = await EventMatchUp.findOne({ publicToken: request.params.token, enabled: true });
if (!doc) return h.response(error('NOT_FOUND', 'Match Up is not available')).code(404);
return h.response(success(sanitizeMatchUp(doc, true)));
}
async function guessMatchUp(request, h) {
const doc = await EventMatchUp.findOne({ publicToken: request.params.token, enabled: true });
if (!doc) return h.response(error('NOT_FOUND', 'Match Up is not available')).code(404);
const itemIds = Array.isArray(request.payload?.itemIds) ? request.payload.itemIds.map(String) : [];
if (itemIds.length !== doc.gridSize || new Set(itemIds).size !== itemIds.length) {
return h.response(error('VALIDATION_ERROR', `Select exactly ${doc.gridSize} unique tiles`)).code(400);
}
const matched = doc.groups.find((group) => {
const groupIds = new Set(group.items.map((item) => String(item._id)));
return itemIds.every((id) => groupIds.has(id));
});
if (!matched) return h.response(success({ correct: false, group: null }));
return h.response(success({
correct: true,
group: {
_id: String(matched._id),
title: matched.title,
description: matched.description || '',
itemIds: matched.items.map((item) => String(item._id)),
},
}));
}
async function completeMatchUp(request, h) {
const doc = await EventMatchUp.findOne({ publicToken: request.params.token, enabled: true });
if (!doc) return h.response(error('NOT_FOUND', 'Match Up is not available')).code(404);
const submitted = request.payload?.groups;
if (!Array.isArray(submitted) || submitted.length !== doc.groups.length) return h.response(error('VALIDATION_ERROR', 'Invalid groups')).code(400);
const validGroups = doc.groups.map((g) => new Set(g.items.map((i) => String(i._id))));
const valid = submitted.every((ids) => Array.isArray(ids) && ids.length === doc.gridSize && validGroups.some((group) => ids.length === group.size && ids.every((id) => group.has(String(id)))));
if (!valid) return h.response(error('VALIDATION_ERROR', 'Groups do not match')).code(400);
return h.response(success({ completed: true, groups: doc.groups.length }));
}
async function publicQuiz(request, h) {
const doc = await EventGameQuiz.findOne({ publicToken: request.params.token, enabled: true });
if (!doc) return h.response(error('NOT_FOUND', 'Quiz is not available')).code(404);
return h.response(success(sanitizeQuiz(doc, true)));
}
async function submitQuiz(request, h) {
const doc = await EventGameQuiz.findOne({ publicToken: request.params.token, enabled: true });
if (!doc) return h.response(error('NOT_FOUND', 'Quiz is not available')).code(404);
const answers = Array.isArray(request.payload?.answers) ? request.payload.answers : [];
const correct = doc.questions.reduce((sum, q, index) => sum + (Number(answers[index]) === q.correctIndex ? 1 : 0), 0);
const score = correct * 5;
const displayName = String(request.payload?.displayName || 'Anonymous').trim().slice(0, 80) || 'Anonymous';
const result = await EventGameQuizResult.create({ event: doc.event, quiz: doc._id, displayName, score, total: doc.questions.length * 5, correct, timeSpent: Math.max(0, Number(request.payload?.timeSpent) || 0) });
return h.response(success({ result: { displayName, score, total: result.total, correct, timeSpent: result.timeSpent }, leaderboard: await leaderboard(doc._id) }, 'Quiz submitted'));
}
async function answerQuiz(request, h) {
const doc = await EventGameQuiz.findOne({ publicToken: request.params.token, enabled: true });
if (!doc) return h.response(error('NOT_FOUND', 'Quiz is not available')).code(404);
const questionIndex = Number(request.payload?.questionIndex);
const answerIndex = Number(request.payload?.answerIndex);
const question = doc.questions[questionIndex];
if (!question || answerIndex < 0 || answerIndex >= question.options.length) {
return h.response(error('VALIDATION_ERROR', 'Invalid quiz answer')).code(400);
}
return h.response(success({ correct: answerIndex === question.correctIndex, correctIndex: question.correctIndex, points: answerIndex === question.correctIndex ? 5 : 0 }));
}
async function leaderboard(quizId) {
return EventGameQuizResult.find({ quiz: quizId }).sort({ score: -1, timeSpent: 1, createdAt: 1 }).limit(100).select('displayName score total correct timeSpent createdAt').lean();
}
async function publicLeaderboard(request, h) {
const doc = await EventGameQuiz.findOne({ publicToken: request.params.token, enabled: true }).select('_id');
if (!doc) return h.response(error('NOT_FOUND', 'Quiz is not available')).code(404);
return h.response(success({ leaderboard: await leaderboard(doc._id) }));
}
module.exports = {
manageGet: (game) => (request, h) => manageGet(game, request, h),
manageUpdate: (game) => (request, h) => manageUpdate(game, request, h),
publicMatchUp, guessMatchUp, completeMatchUp, publicQuiz, answerQuiz, submitQuiz, publicLeaderboard,
baseUrl,
};

View File

@ -64,6 +64,11 @@ const eventSchema = new mongoose.Schema(
registrationBaseFields: String, registrationBaseFields: String,
/** 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,
/** Dashboard feature cards explicitly enabled for this event. */
activityModules: [{
type: String,
enum: ['photoSearch', 'socialShare', 'aiAvatar', 'photoWall', 'spotlight', 'matchUp', 'quizGame'],
}],
}, },
/** /**
* Participant logistics policy for the concierge. When a flag is true, the * Participant logistics policy for the concierge. When a flag is true, the

View File

@ -0,0 +1,22 @@
const mongoose = require('mongoose');
const crypto = require('crypto');
const questionSchema = new mongoose.Schema({
question: { type: String, required: true, trim: true },
options: { type: [String], required: true },
correctIndex: { type: Number, required: true, min: 0 },
points: { type: Number, default: 5, min: 5, max: 5 },
}, { _id: true });
const schema = new mongoose.Schema({
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true, unique: true },
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true },
name: { type: String, default: 'Quiz', trim: true },
backgroundImage: { type: String, default: '', trim: true },
questions: { type: [questionSchema], default: [] },
enabled: { type: Boolean, default: false },
publicToken: { type: String, unique: true, sparse: true, index: true },
}, { timestamps: true });
schema.statics.generateToken = () => crypto.randomBytes(24).toString('hex');
module.exports = mongoose.model('EventGameQuiz', schema);

View File

@ -0,0 +1,14 @@
const mongoose = require('mongoose');
const schema = new mongoose.Schema({
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true, index: true },
quiz: { type: mongoose.Schema.Types.ObjectId, ref: 'EventGameQuiz', required: true, index: true },
displayName: { type: String, required: true, trim: true, maxlength: 80 },
score: { type: Number, required: true, min: 0 },
total: { type: Number, required: true, min: 0 },
correct: { type: Number, required: true, min: 0 },
timeSpent: { type: Number, default: 0, min: 0 },
}, { timestamps: true });
schema.index({ quiz: 1, score: -1, timeSpent: 1, createdAt: 1 });
module.exports = mongoose.model('EventGameQuizResult', schema);

View File

@ -0,0 +1,27 @@
const mongoose = require('mongoose');
const crypto = require('crypto');
const itemSchema = new mongoose.Schema({
text: { type: String, required: true, trim: true },
imageUrl: { type: String, trim: true, default: '' },
}, { _id: true });
const groupSchema = new mongoose.Schema({
title: { type: String, required: true, trim: true },
description: { type: String, default: '', trim: true },
items: { type: [itemSchema], required: true },
}, { _id: true });
const schema = new mongoose.Schema({
event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true, unique: true },
organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true },
name: { type: String, default: 'Match Up', trim: true },
backgroundImage: { type: String, default: '', trim: true },
gridSize: { type: Number, enum: [3, 4, 5], default: 4 },
groups: { type: [groupSchema], default: [] },
enabled: { type: Boolean, default: false },
publicToken: { type: String, unique: true, sparse: true, index: true },
}, { timestamps: true });
schema.statics.generateToken = () => crypto.randomBytes(24).toString('hex');
module.exports = mongoose.model('EventMatchUp', schema);

View File

@ -130,6 +130,22 @@ module.exports = [
}, },
{ 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: 'PATCH',
path: '/api/events/{id}/activity-modules',
options: {
validate: {
params: Joi.object({ id: Joi.string().hex().length(24).required() }),
payload: Joi.object({
activityModules: Joi.array()
.items(Joi.string().valid('photoSearch', 'socialShare', 'aiAvatar', 'photoWall', 'spotlight', 'matchUp', 'quizGame'))
.max(7)
.required(),
}).required(),
},
handler: controller.updateActivityModules,
},
},
{ method: 'PUT', path: '/api/events/{id}', options: { validate: { payload: eventUpdatePayload }, handler: controller.update } }, { method: 'PUT', path: '/api/events/{id}', options: { validate: { payload: eventUpdatePayload }, handler: controller.update } },
{ method: 'DELETE', path: '/api/events/{id}', options: { handler: controller.remove } }, { method: 'DELETE', path: '/api/events/{id}', options: { handler: controller.remove } },
{ method: 'GET', path: '/api/events/{id}/analytics', options: { handler: controller.analytics } }, { method: 'GET', path: '/api/events/{id}/analytics', options: { handler: controller.analytics } },

View File

@ -0,0 +1,18 @@
const Joi = require('joi');
const controller = require('../controllers/eventGames.controller');
const item = Joi.object({ text: Joi.string().min(1).max(200).required(), imageUrl: Joi.string().allow('').max(2000) });
const group = Joi.object({ title: Joi.string().min(1).max(120).required(), description: Joi.string().allow('').max(500), items: Joi.array().items(item).required() });
const matchUpPayload = Joi.object({
name: Joi.string().min(1).max(120).required(), backgroundImage: Joi.string().allow('').max(2000),
enabled: Joi.boolean(), gridSize: Joi.number().valid(3, 4, 5).required(), groups: Joi.array().items(group).required(),
});
const question = Joi.object({ question: Joi.string().min(1).max(1000).required(), options: Joi.array().items(Joi.string().min(1).max(300)).min(2).max(8).required(), correctIndex: Joi.number().integer().min(0).required(), points: Joi.number().integer().valid(5) });
const quizPayload = Joi.object({ name: Joi.string().min(1).max(120).required(), backgroundImage: Joi.string().allow('').max(2000), enabled: Joi.boolean(), questions: Joi.array().items(question).min(1).max(50).required() });
module.exports = [
{ method: 'GET', path: '/api/events/{eventId}/match-up', options: { validate: { params: Joi.object({ eventId: Joi.string().hex().length(24).required() }) }, handler: controller.manageGet('matchUp') } },
{ method: 'PATCH', path: '/api/events/{eventId}/match-up', options: { validate: { params: Joi.object({ eventId: Joi.string().hex().length(24).required() }), payload: matchUpPayload }, handler: controller.manageUpdate('matchUp') } },
{ method: 'GET', path: '/api/events/{eventId}/game-quiz', options: { validate: { params: Joi.object({ eventId: Joi.string().hex().length(24).required() }) }, handler: controller.manageGet('quizGame') } },
{ method: 'PATCH', path: '/api/events/{eventId}/game-quiz', options: { validate: { params: Joi.object({ eventId: Joi.string().hex().length(24).required() }), payload: quizPayload }, handler: controller.manageUpdate('quizGame') } },
];

View File

@ -0,0 +1,13 @@
const Joi = require('joi');
const controller = require('../controllers/eventGames.controller');
const token = Joi.object({ token: Joi.string().min(20).max(100).required() });
module.exports = [
{ method: 'GET', path: '/api/public/games/match-up/{token}', options: { auth: false, validate: { params: token }, handler: controller.publicMatchUp } },
{ method: 'POST', path: '/api/public/games/match-up/{token}/guess', options: { auth: false, validate: { params: token, payload: Joi.object({ itemIds: Joi.array().items(Joi.string()).required() }) }, handler: controller.guessMatchUp } },
{ method: 'POST', path: '/api/public/games/match-up/{token}/complete', options: { auth: false, validate: { params: token, payload: Joi.object({ groups: Joi.array().items(Joi.array().items(Joi.string())).required() }) }, handler: controller.completeMatchUp } },
{ method: 'GET', path: '/api/public/games/quiz/{token}', options: { auth: false, validate: { params: token }, handler: controller.publicQuiz } },
{ method: 'POST', path: '/api/public/games/quiz/{token}/answer', options: { auth: false, validate: { params: token, payload: Joi.object({ questionIndex: Joi.number().integer().min(0).required(), answerIndex: Joi.number().integer().min(0).required() }) }, handler: controller.answerQuiz } },
{ method: 'POST', path: '/api/public/games/quiz/{token}/submit', options: { auth: false, validate: { params: token, payload: Joi.object({ displayName: Joi.string().allow('').max(80), answers: Joi.array().items(Joi.number().integer().min(-1)), timeSpent: Joi.number().min(0).max(86400) }) }, handler: controller.submitQuiz } },
{ method: 'GET', path: '/api/public/games/quiz/{token}/leaderboard', options: { auth: false, validate: { params: token }, handler: controller.publicLeaderboard } },
];