From 237a754eb8b6d55d5c51c9feeb5f68924c6daaa5 Mon Sep 17 00:00:00 2001 From: Tanuja Pansare Date: Thu, 1 Oct 2026 17:55:43 +0530 Subject: [PATCH] Add event games functionality --- src/app.js | 4 + src/controllers/event.controller.js | 39 +++++ src/controllers/eventGames.controller.js | 187 +++++++++++++++++++++++ src/models/Event.js | 5 + src/models/EventGameQuiz.js | 22 +++ src/models/EventGameQuizResult.js | 14 ++ src/models/EventMatchUp.js | 27 ++++ src/routes/event.routes.js | 16 ++ src/routes/eventGames.routes.js | 18 +++ src/routes/publicEventGames.routes.js | 13 ++ 10 files changed, 345 insertions(+) create mode 100644 src/controllers/eventGames.controller.js create mode 100644 src/models/EventGameQuiz.js create mode 100644 src/models/EventGameQuizResult.js create mode 100644 src/models/EventMatchUp.js create mode 100644 src/routes/eventGames.routes.js create mode 100644 src/routes/publicEventGames.routes.js diff --git a/src/app.js b/src/app.js index f3b977d..ac39934 100644 --- a/src/app.js +++ b/src/app.js @@ -66,6 +66,8 @@ const eventPhotoSearchRoutes = require('./routes/eventPhotoSearch.routes'); const socialShareRoutes = require('./routes/socialShare.routes'); const aiAvatarRoutes = require('./routes/aiAvatar.routes'); const engagementRoutes = require('./routes/engagement.routes'); +const eventGamesRoutes = require('./routes/eventGames.routes'); +const publicEventGamesRoutes = require('./routes/publicEventGames.routes'); 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). */ @@ -307,6 +309,7 @@ const createServer = async () => { server.route(publicSocialShareRoutes); server.route(publicAiAvatarRoutes); server.route(publicEngagementRoutes); + server.route(publicEventGamesRoutes); server.route(publicVisitorThemeRoutes); server.route(authRoutes); @@ -353,6 +356,7 @@ const createServer = async () => { server.route(socialShareRoutes); server.route(aiAvatarRoutes); server.route(engagementRoutes); + server.route(eventGamesRoutes); server.route(conciergeBotRoutes); server.route(attendanceRoutes); server.route(checkinsRoutes); diff --git a/src/controllers/event.controller.js b/src/controllers/event.controller.js index 1a7e43a..93c81cc 100644 --- a/src/controllers/event.controller.js +++ b/src/controllers/event.controller.js @@ -20,6 +20,16 @@ const { buildEventAnalytics } = require('../services/eventAnalytics.service'); const { assertModulePermission, userOrgId, buildFormAccessFilter } = require('../services/acl.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) { if (typeof dataUrl !== 'string') return null; const trimmed = dataUrl.trim(); @@ -629,6 +639,34 @@ const update = async (request, h) => { 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 event = await Event.findById(request.params.id); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); @@ -746,6 +784,7 @@ module.exports = { generateFromAi, get, update, + updateActivityModules, remove, analytics, duplicate, diff --git a/src/controllers/eventGames.controller.js b/src/controllers/eventGames.controller.js new file mode 100644 index 0000000..4f7d8ef --- /dev/null +++ b/src/controllers/eventGames.controller.js @@ -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, +}; diff --git a/src/models/Event.js b/src/models/Event.js index 3fda0a2..f60da99 100644 --- a/src/models/Event.js +++ b/src/models/Event.js @@ -64,6 +64,11 @@ const eventSchema = new mongoose.Schema( registrationBaseFields: String, /** Optional shared code for Invite Only registration (visitor must pass inviteToken) */ 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 diff --git a/src/models/EventGameQuiz.js b/src/models/EventGameQuiz.js new file mode 100644 index 0000000..7f80156 --- /dev/null +++ b/src/models/EventGameQuiz.js @@ -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); diff --git a/src/models/EventGameQuizResult.js b/src/models/EventGameQuizResult.js new file mode 100644 index 0000000..c61bdee --- /dev/null +++ b/src/models/EventGameQuizResult.js @@ -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); diff --git a/src/models/EventMatchUp.js b/src/models/EventMatchUp.js new file mode 100644 index 0000000..3832b6b --- /dev/null +++ b/src/models/EventMatchUp.js @@ -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); diff --git a/src/routes/event.routes.js b/src/routes/event.routes.js index 0809deb..1c73a2b 100644 --- a/src/routes/event.routes.js +++ b/src/routes/event.routes.js @@ -130,6 +130,22 @@ module.exports = [ }, { method: 'POST', path: '/api/events', options: { validate: { payload: eventPayload }, handler: controller.create } }, { 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: 'DELETE', path: '/api/events/{id}', options: { handler: controller.remove } }, { method: 'GET', path: '/api/events/{id}/analytics', options: { handler: controller.analytics } }, diff --git a/src/routes/eventGames.routes.js b/src/routes/eventGames.routes.js new file mode 100644 index 0000000..29283f0 --- /dev/null +++ b/src/routes/eventGames.routes.js @@ -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') } }, +]; diff --git a/src/routes/publicEventGames.routes.js b/src/routes/publicEventGames.routes.js new file mode 100644 index 0000000..eecb9d0 --- /dev/null +++ b/src/routes/publicEventGames.routes.js @@ -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 } }, +];