Add event games functionality
This commit is contained in:
parent
843901280e
commit
237a754eb8
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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 } },
|
||||||
|
|
|
||||||
|
|
@ -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') } },
|
||||||
|
];
|
||||||
|
|
@ -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 } },
|
||||||
|
];
|
||||||
Loading…
Reference in New Issue