From 843901280e22f0273140a329096e5fb716b335ec Mon Sep 17 00:00:00 2001 From: Tanuja Pansare Date: Wed, 30 Sep 2026 18:03:28 +0530 Subject: [PATCH] Add spotlight and uploader display name features --- src/app.js | 4 + src/controllers/photoWall.controller.js | 6 +- src/controllers/publicAiAvatar.controller.js | 9 + .../publicAiAvatar.displayName.test.js | 87 ++++++++ src/controllers/publicPhotoWall.controller.js | 1 - .../publicSocialShare.controller.js | 9 + .../publicSocialShare.displayName.test.js | 85 ++++++++ src/controllers/spotlight.controller.js | 206 ++++++++++++++++++ src/controllers/spotlight.displayName.test.js | 74 +++++++ src/models/AiAvatarOutput.js | 1 + src/models/SocialShareOutput.js | 1 + src/models/Spotlight.js | 52 +++++ src/routes/publicSpotlight.routes.js | 9 + src/routes/spotlight.routes.js | 41 ++++ src/utils/uploaderDisplayName.js | 10 + src/utils/uploaderDisplayName.test.js | 20 ++ 16 files changed, 610 insertions(+), 5 deletions(-) create mode 100644 src/controllers/publicAiAvatar.displayName.test.js create mode 100644 src/controllers/publicSocialShare.displayName.test.js create mode 100644 src/controllers/spotlight.controller.js create mode 100644 src/controllers/spotlight.displayName.test.js create mode 100644 src/models/Spotlight.js create mode 100644 src/routes/publicSpotlight.routes.js create mode 100644 src/routes/spotlight.routes.js create mode 100644 src/utils/uploaderDisplayName.js create mode 100644 src/utils/uploaderDisplayName.test.js diff --git a/src/app.js b/src/app.js index b009bcc..f3b977d 100644 --- a/src/app.js +++ b/src/app.js @@ -51,6 +51,7 @@ const conciergeBotRoutes = require('./routes/conciergeBot.routes'); const publicConciergeRoutes = require('./routes/publicConcierge.routes'); const publicQuizPollRoutes = require('./routes/publicQuizPoll.routes'); const publicPhotoWallRoutes = require('./routes/publicPhotoWall.routes'); +const publicSpotlightRoutes = require('./routes/publicSpotlight.routes'); const publicEventPhotoSearchRoutes = require('./routes/publicEventPhotoSearch.routes'); const publicPhotoIngestRoutes = require('./routes/publicPhotoIngest.routes'); const publicSocialShareRoutes = require('./routes/publicSocialShare.routes'); @@ -60,6 +61,7 @@ const publicVisitorThemeRoutes = require('./routes/publicVisitorTheme.routes'); const staticMicrositeRoutes = require('./routes/staticMicrosite.routes'); const eventPhotoRoutes = require('./routes/eventPhoto.routes'); const photoWallRoutes = require('./routes/photoWall.routes'); +const spotlightRoutes = require('./routes/spotlight.routes'); const eventPhotoSearchRoutes = require('./routes/eventPhotoSearch.routes'); const socialShareRoutes = require('./routes/socialShare.routes'); const aiAvatarRoutes = require('./routes/aiAvatar.routes'); @@ -299,6 +301,7 @@ const createServer = async () => { server.route(publicConciergeRoutes); server.route(publicQuizPollRoutes); server.route(publicPhotoWallRoutes); + server.route(publicSpotlightRoutes); server.route(publicEventPhotoSearchRoutes); server.route(publicPhotoIngestRoutes); server.route(publicSocialShareRoutes); @@ -345,6 +348,7 @@ const createServer = async () => { server.route(eventTicketingManualRoutes); server.route(eventPhotoRoutes); server.route(photoWallRoutes); + server.route(spotlightRoutes); server.route(eventPhotoSearchRoutes); server.route(socialShareRoutes); server.route(aiAvatarRoutes); diff --git a/src/controllers/photoWall.controller.js b/src/controllers/photoWall.controller.js index 15fd0b0..0cdff8a 100644 --- a/src/controllers/photoWall.controller.js +++ b/src/controllers/photoWall.controller.js @@ -148,13 +148,11 @@ function serializePhoto(doc) { photoWall: doc.photoWall, event: doc.event, organization: doc.organization, - url: resolvePhotoThumbUrl(doc.s3Key || doc.url, { w: 640, h: 640 }) || - resolvePhotoPublicUrl(doc.url) || + url: resolvePhotoPublicUrl(doc.s3Key || doc.url) || doc.url, s3Key: doc.s3Key, framedUrl: doc.framedUrl - ? resolvePhotoThumbUrl(doc.framedS3Key || doc.framedUrl, { w: 640, h: 640 }) || - resolvePhotoPublicUrl(doc.framedUrl) + ? resolvePhotoPublicUrl(doc.framedS3Key || doc.framedUrl) : null, framedS3Key: doc.framedS3Key || null, mimeType: doc.mimeType, diff --git a/src/controllers/publicAiAvatar.controller.js b/src/controllers/publicAiAvatar.controller.js index 9a38abf..4ffe134 100644 --- a/src/controllers/publicAiAvatar.controller.js +++ b/src/controllers/publicAiAvatar.controller.js @@ -14,6 +14,7 @@ const { PHOTO_KINDS, } = require('../utils/s3EventPhotos'); const { runImageHeadSwap } = require('../services/waveSpeedHeadSwap'); +const { normalizeUploaderDisplayName } = require('../utils/uploaderDisplayName'); const { EVENT_PUBLIC_SELECT, buildPublicEventPayload, @@ -160,6 +161,13 @@ const uploadPhoto = async (request, h) => { return h.response(error('VALIDATION_ERROR', 'Please select an avatar')).code(400); } + const displayName = normalizeUploaderDisplayName( + await parseStringField(request.payload || {}, 'displayName'), + ); + if (!displayName) { + return h.response(error('VALIDATION_ERROR', 'Your name is required and must be 80 characters or fewer')).code(400); + } + const template = await AiAvatarTemplate.findOne({ _id: avatarId, aiAvatar: config._id, @@ -217,6 +225,7 @@ const uploadPhoto = async (request, h) => { mimeType: file.mimeType, size: file.buffer.length, originalFileName: file.originalName, + displayName, }); try { diff --git a/src/controllers/publicAiAvatar.displayName.test.js b/src/controllers/publicAiAvatar.displayName.test.js new file mode 100644 index 0000000..7983fa1 --- /dev/null +++ b/src/controllers/publicAiAvatar.displayName.test.js @@ -0,0 +1,87 @@ +jest.mock('../models/AiAvatar', () => ({ findOne: jest.fn() })); +jest.mock('../models/AiAvatarTemplate', () => ({ findOne: jest.fn() })); +jest.mock('../models/AiAvatarOutput', () => ({ create: jest.fn() })); +jest.mock('../services/waveSpeedHeadSwap', () => ({ runImageHeadSwap: jest.fn() })); +jest.mock('../utils/s3EventPhotos', () => ({ + uploadEventPhotoToS3: jest.fn(), + getEventPhotoBufferFromS3: jest.fn(), + resolvePhotoPublicUrl: jest.fn((url) => url), + resolvePhotoThumbUrl: jest.fn(() => 'preview-url'), + PHOTO_ACTIVITIES: { aiAvatar: 'ai-avatar' }, + PHOTO_KINDS: { uploads: 'uploads', results: 'results' }, +})); + +const AiAvatar = require('../models/AiAvatar'); +const AiAvatarTemplate = require('../models/AiAvatarTemplate'); +const AiAvatarOutput = require('../models/AiAvatarOutput'); +const { runImageHeadSwap } = require('../services/waveSpeedHeadSwap'); +const { uploadEventPhotoToS3 } = require('../utils/s3EventPhotos'); +const { uploadPhoto } = require('./publicAiAvatar.controller'); + +const uploadToken = 'ai-avatar-token'; +const avatarId = '507f1f77bcf86cd799439021'; +const config = { + _id: '507f1f77bcf86cd799439022', + event: '507f1f77bcf86cd799439023', + organization: '507f1f77bcf86cd799439024', + enabled: true, + accessMode: 'open', +}; + +function makeToolkit() { + return { + response(payload) { + return { payload, code: (statusCode) => ({ payload, statusCode }) }; + }, + }; +} + +function makeRequest(displayName = ' Ada Lovelace ') { + return { + params: { uploadToken }, + auth: { credentials: {} }, + payload: { + avatarId, + displayName, + file: { + _data: Buffer.from('image'), + hapi: { filename: 'photo.jpg', headers: { 'content-type': 'image/jpeg' } }, + }, + }, + }; +} + +describe('public AI Avatar uploader name', () => { + beforeEach(() => { + jest.clearAllMocks(); + AiAvatar.findOne.mockResolvedValue(config); + AiAvatarTemplate.findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ _id: avatarId, imageUrl: 'avatar.png' }), + }); + }); + + test('rejects a missing name before uploading the photo', async () => { + const response = await uploadPhoto(makeRequest(' '), makeToolkit()); + + expect(response.statusCode).toBe(400); + expect(uploadEventPhotoToS3).not.toHaveBeenCalled(); + expect(AiAvatarOutput.create).not.toHaveBeenCalled(); + }); + + test('persists the trimmed name on the output record', async () => { + const output = { + _id: { toString: () => '507f1f77bcf86cd799439025' }, + save: jest.fn().mockResolvedValue(undefined), + }; + AiAvatarOutput.create.mockResolvedValue(output); + uploadEventPhotoToS3 + .mockResolvedValueOnce({ url: 'source-url', s3Key: 'source-key' }) + .mockResolvedValueOnce({ url: 'result-url', s3Key: 'result-key' }); + runImageHeadSwap.mockResolvedValue({ buffer: Buffer.from('result') }); + + const response = await uploadPhoto(makeRequest(), makeToolkit()); + + expect(response.statusCode).toBe(201); + expect(AiAvatarOutput.create).toHaveBeenCalledWith(expect.objectContaining({ displayName: 'Ada Lovelace' })); + }); +}); \ No newline at end of file diff --git a/src/controllers/publicPhotoWall.controller.js b/src/controllers/publicPhotoWall.controller.js index 981770d..a0dc82d 100644 --- a/src/controllers/publicPhotoWall.controller.js +++ b/src/controllers/publicPhotoWall.controller.js @@ -559,7 +559,6 @@ const getPresentWall = async (request, h) => { return { _id: p._id, url: - resolvePhotoThumbUrl(rawKey, { w: 640, h: 640 }) || resolvePhotoPublicUrl(rawKey) || rawKey, createdAt: p.createdAt, diff --git a/src/controllers/publicSocialShare.controller.js b/src/controllers/publicSocialShare.controller.js index fc484ba..5f036e4 100644 --- a/src/controllers/publicSocialShare.controller.js +++ b/src/controllers/publicSocialShare.controller.js @@ -14,6 +14,7 @@ const { PHOTO_KINDS, } = require('../utils/s3EventPhotos'); const { renderAndUploadFramedPhoto } = require('../utils/photoWallFrameRender'); +const { normalizeUploaderDisplayName } = require('../utils/uploaderDisplayName'); const { EVENT_PUBLIC_SELECT, buildPublicEventPayload, @@ -186,6 +187,13 @@ const uploadPhoto = async (request, h) => { return h.response(error('VALIDATION_ERROR', 'Please select a frame')).code(400); } + const displayName = normalizeUploaderDisplayName( + await parseStringField(request.payload || {}, 'displayName'), + ); + if (!displayName) { + return h.response(error('VALIDATION_ERROR', 'Your name is required and must be 80 characters or fewer')).code(400); + } + const frame = await SocialShareFrame.findOne({ _id: frameId, socialShare: config._id, @@ -239,6 +247,7 @@ const uploadPhoto = async (request, h) => { mimeType: file.mimeType, size: file.buffer.length, originalFileName: file.originalName, + displayName, }); try { diff --git a/src/controllers/publicSocialShare.displayName.test.js b/src/controllers/publicSocialShare.displayName.test.js new file mode 100644 index 0000000..df79b0b --- /dev/null +++ b/src/controllers/publicSocialShare.displayName.test.js @@ -0,0 +1,85 @@ +jest.mock('../models/SocialShare', () => ({ findOne: jest.fn() })); +jest.mock('../models/SocialShareFrame', () => ({ findOne: jest.fn() })); +jest.mock('../models/SocialShareOutput', () => ({ create: jest.fn() })); +jest.mock('../utils/s3EventPhotos', () => ({ + uploadEventPhotoToS3: jest.fn(), + getEventPhotoBufferFromS3: jest.fn(), + resolvePhotoPublicUrl: jest.fn((url) => url), + resolvePhotoThumbUrl: jest.fn(() => 'preview-url'), + PHOTO_ACTIVITIES: { socialShare: 'social-share' }, + PHOTO_KINDS: { uploads: 'uploads', framed: 'framed' }, +})); +jest.mock('../utils/photoWallFrameRender', () => ({ renderAndUploadFramedPhoto: jest.fn() })); + +const SocialShare = require('../models/SocialShare'); +const SocialShareFrame = require('../models/SocialShareFrame'); +const SocialShareOutput = require('../models/SocialShareOutput'); +const { uploadEventPhotoToS3 } = require('../utils/s3EventPhotos'); +const { renderAndUploadFramedPhoto } = require('../utils/photoWallFrameRender'); +const { uploadPhoto } = require('./publicSocialShare.controller'); + +const uploadToken = 'social-share-token'; +const frameId = '507f1f77bcf86cd799439011'; +const config = { + _id: '507f1f77bcf86cd799439012', + event: '507f1f77bcf86cd799439013', + organization: '507f1f77bcf86cd799439014', + enabled: true, + accessMode: 'open', +}; + +function makeToolkit() { + return { + response(payload) { + return { payload, code: (statusCode) => ({ payload, statusCode }) }; + }, + }; +} + +function makeRequest(displayName = ' Ada Lovelace ') { + return { + params: { uploadToken }, + auth: { credentials: {} }, + payload: { + frameId, + displayName, + file: { + _data: Buffer.from('image'), + hapi: { filename: 'photo.jpg', headers: { 'content-type': 'image/jpeg' } }, + }, + }, + }; +} + +describe('public Social Share uploader name', () => { + beforeEach(() => { + jest.clearAllMocks(); + SocialShare.findOne.mockResolvedValue(config); + SocialShareFrame.findOne.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ _id: frameId, frameImageUrl: 'frame.png' }), + }); + }); + + test('rejects a missing name before uploading the photo', async () => { + const response = await uploadPhoto(makeRequest(' '), makeToolkit()); + + expect(response.statusCode).toBe(400); + expect(uploadEventPhotoToS3).not.toHaveBeenCalled(); + expect(SocialShareOutput.create).not.toHaveBeenCalled(); + }); + + test('persists the trimmed name on the output record', async () => { + const output = { + _id: { toString: () => '507f1f77bcf86cd799439015' }, + save: jest.fn().mockResolvedValue(undefined), + }; + SocialShareOutput.create.mockResolvedValue(output); + uploadEventPhotoToS3.mockResolvedValue({ url: 'source-url', s3Key: 'source-key' }); + renderAndUploadFramedPhoto.mockResolvedValue({ framedUrl: 'framed-url', framedS3Key: 'framed-key' }); + + const response = await uploadPhoto(makeRequest(), makeToolkit()); + + expect(response.statusCode).toBe(201); + expect(SocialShareOutput.create).toHaveBeenCalledWith(expect.objectContaining({ displayName: 'Ada Lovelace' })); + }); +}); \ No newline at end of file diff --git a/src/controllers/spotlight.controller.js b/src/controllers/spotlight.controller.js new file mode 100644 index 0000000..5bafd9b --- /dev/null +++ b/src/controllers/spotlight.controller.js @@ -0,0 +1,206 @@ +const mongoose = require('mongoose'); +const Spotlight = require('../models/Spotlight'); +const Event = require('../models/Event'); +const EventMicrosite = require('../models/EventMicrosite'); +const PhotoWall = require('../models/PhotoWall'); +const PhotoWallPhoto = require('../models/PhotoWallPhoto'); +const SocialShare = require('../models/SocialShare'); +const SocialShareOutput = require('../models/SocialShareOutput'); +const AiAvatar = require('../models/AiAvatar'); +const AiAvatarOutput = require('../models/AiAvatarOutput'); +const QuizPoll = require('../models/QuizPoll'); +const { success, error } = require('../utils/response'); +const { + buildPublicEventPayload, + EVENT_PUBLIC_SELECT, + normalizePresenterEventHeaderDisplay, +} = require('../utils/formPublicHelpers'); +const { resolvePhotoPublicUrl, resolvePhotoThumbUrl } = require('../utils/s3EventPhotos'); + +const SOURCE_KEYS = ['socialShare', 'aiAvatar', 'quiz', 'photoWall']; + +function serialize(config, photoCount = 0) { + const value = config.toObject ? config.toObject() : config; + return { + ...value, + _id: String(value._id), + event: String(value.event), + organization: String(value.organization), + enabled: Boolean(value.enabled), + sources: Object.fromEntries(SOURCE_KEYS.map((key) => [key, value.sources?.[key] !== false])), + presentToken: value.presentToken || null, + photoCount, + }; +} + +async function getOrCreate(eventId) { + const event = await Event.findById(eventId).select('organization').lean(); + if (!event) return null; + let config = await Spotlight.findOne({ event: eventId }); + if (!config) { + config = await Spotlight.create({ + event: eventId, + organization: event.organization, + presentToken: Spotlight.generateToken(), + }); + } else if (!config.presentToken) { + config.presentToken = Spotlight.generateToken(); + await config.save(); + } + return config; +} + +async function assertAccess(eventId, user) { + if (user.role === 'super_admin') return true; + const orgId = user.organization?._id || user.organization; + const event = await Event.findById(eventId).select('organization').lean(); + return Boolean(event && event.organization?.toString() === orgId?.toString()); +} + +function imageRow(id, source, url, createdAt, displayName = null) { + const publicUrl = resolvePhotoPublicUrl(url) || url; + + return { + _id: `${source}:${id}`, + source, + url: publicUrl, + createdAt, + displayName: displayName || null, + }; +} + +async function loadPhotos(eventId, enabledSources, requestedSources = SOURCE_KEYS) { + const active = SOURCE_KEYS.filter( + (key) => enabledSources[key] && requestedSources.includes(key) + ); + const tasks = []; + if (active.includes('photoWall')) { + tasks.push((async () => { + const wall = await PhotoWall.findOne({ event: eventId, enabled: true }).select('_id').lean(); + if (!wall) return []; + const rows = await PhotoWallPhoto.find({ photoWall: wall._id, status: 'approved' }) + .sort({ createdAt: -1 }).limit(500).select('_id url framedUrl displayName createdAt').lean(); + return rows.map((row) => imageRow(row._id, 'photoWall', row.framedUrl || row.url, row.createdAt, row.displayName)); + })()); + } + if (active.includes('socialShare')) { + tasks.push((async () => { + const config = await SocialShare.findOne({ event: eventId, enabled: true }).select('_id').lean(); + if (!config) return []; + const rows = await SocialShareOutput.find({ socialShare: config._id }) + .sort({ createdAt: -1 }).limit(500).select('_id url framedUrl displayName createdAt').lean(); + return rows.map((row) => imageRow(row._id, 'socialShare', row.framedUrl || row.url, row.createdAt, row.displayName)); + })()); + } + if (active.includes('aiAvatar')) { + tasks.push((async () => { + const config = await AiAvatar.findOne({ event: eventId, enabled: true }).select('_id').lean(); + if (!config) return []; + const rows = await AiAvatarOutput.find({ aiAvatar: config._id, resultUrl: { $nin: [null, ''] } }) + .sort({ createdAt: -1 }).limit(500).select('_id resultUrl displayName createdAt').lean(); + return rows.map((row) => imageRow(row._id, 'aiAvatar', row.resultUrl, row.createdAt, row.displayName)); + })()); + } + if (active.includes('quiz')) { + tasks.push((async () => { + const quizzes = await QuizPoll.find({ event: eventId, deletedAt: null, 'settings.status': 'active' }) + .select('_id questions createdAt').lean(); + const rows = []; + for (const quiz of quizzes) { + for (const question of quiz.questions || []) { + if (question.imageUrl) rows.push(imageRow(`${quiz._id}:${question._id}:question`, 'quiz', question.imageUrl, quiz.createdAt)); + for (const option of question.options || []) { + if (option.imageUrl) rows.push(imageRow(`${quiz._id}:${question._id}:${option._id}`, 'quiz', option.imageUrl, quiz.createdAt)); + } + } + } + return rows; + })()); + } + return (await Promise.all(tasks)).flat().sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)).slice(0, 1000); +} + +const getSpotlight = async (request, h) => { + const user = request.auth.credentials?.user; + const { eventId } = request.params; + if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401); + if (!mongoose.Types.ObjectId.isValid(eventId)) return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400); + if (!(await assertAccess(eventId, user))) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + const config = await getOrCreate(eventId); + if (!config) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const sources = serialize(config).sources; + const photos = await loadPhotos(eventId, sources); + return h.response(success({ ...serialize(config, photos.length), photos })).code(200); +}; + +const updateSpotlight = async (request, h) => { + const user = request.auth.credentials?.user; + const { eventId } = request.params; + if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401); + if (!mongoose.Types.ObjectId.isValid(eventId)) return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400); + if (!(await assertAccess(eventId, user))) return h.response(error('FORBIDDEN', 'Access denied')).code(403); + const config = await getOrCreate(eventId); + if (!config) return h.response(error('NOT_FOUND', 'Event not found')).code(404); + const p = request.payload || {}; + if (typeof p.enabled === 'boolean') config.enabled = p.enabled; + if (p.sources && typeof p.sources === 'object') { + for (const key of SOURCE_KEYS) if (typeof p.sources[key] === 'boolean') config.sources[key] = p.sources[key]; + } + for (const key of ['photosPerRow', 'totalRows', 'maxPhotoWidth', 'maxPhotoHeight', 'gapPx', 'presentMarginTopPx', 'presentMarginLeftPx', 'presentMarginRightPx', 'frameCanvasWidth', 'frameCanvasHeight']) { + if (p[key] != null) config[key] = Number(p[key]); + } + if (['left', 'right', 'alternate'].includes(p.marqueeDirection)) config.marqueeDirection = p.marqueeDirection; + if (['rect', 'rounded', 'circle'].includes(p.presentPhotoShape)) config.presentPhotoShape = p.presentPhotoShape; + if (typeof p.showUploaderNameOnWall === 'boolean') config.showUploaderNameOnWall = p.showUploaderNameOnWall; + if (typeof p.frameEnabled === 'boolean') config.frameEnabled = p.frameEnabled; + if (p.frameImageUrl !== undefined) config.frameImageUrl = p.frameImageUrl ? String(p.frameImageUrl).trim() : null; + if (p.frameImageLayout && typeof p.frameImageLayout === 'object') config.frameImageLayout = p.frameImageLayout; + if (Array.isArray(p.frameLayout)) config.frameLayout = p.frameLayout; + if (p.presenterEventHeader && typeof p.presenterEventHeader === 'object') config.presenterEventHeader = p.presenterEventHeader; + if (p.regenerateTokens === true) config.presentToken = Spotlight.generateToken(); + await config.save(); + const sources = serialize(config).sources; + const photos = await loadPhotos(eventId, sources); + return h.response(success({ ...serialize(config, photos.length), photos }, 'Spotlight updated')).code(200); +}; + +const getPresent = async (request, h) => { + const config = await Spotlight.findOne({ presentToken: String(request.params.presentToken || '').trim(), enabled: true }).lean(); + if (!config) return h.response(error('NOT_FOUND', 'Spotlight not available')).code(404); + const event = await Event.findById(config.event).select(EVENT_PUBLIC_SELECT).lean(); + const microsite = event ? await EventMicrosite.findOne({ event: config.event }).select('logo').lean() : null; + const eventPublic = event ? buildPublicEventPayload(event, microsite?.logo) : null; + const sources = Object.fromEntries(SOURCE_KEYS.map((key) => [key, config.sources?.[key] !== false])); + const hasSourceFilter = Object.prototype.hasOwnProperty.call(request.query || {}, 'sources'); + const requested = hasSourceFilter + ? String(request.query.sources || '').split(',').filter((key) => SOURCE_KEYS.includes(key)) + : SOURCE_KEYS; + const photos = await loadPhotos(config.event, sources, requested); + return h.response(success({ + eventId: String(config.event), + eventName: eventPublic?.name || event?.name || 'Event', + event: eventPublic, + eventHeader: normalizePresenterEventHeaderDisplay(config.presenterEventHeader), + sources, + photosPerRow: config.photosPerRow ?? 4, + totalRows: config.totalRows ?? 3, + marqueeDirection: config.marqueeDirection || 'alternate', + maxPhotoWidth: config.maxPhotoWidth ?? 320, + maxPhotoHeight: config.maxPhotoHeight ?? 320, + gapPx: config.gapPx ?? 8, + presentMarginTopPx: config.presentMarginTopPx ?? 0, + presentMarginLeftPx: config.presentMarginLeftPx ?? 0, + presentMarginRightPx: config.presentMarginRightPx ?? 0, + presentPhotoShape: config.presentPhotoShape || 'rounded', + showUploaderNameOnWall: Boolean(config.showUploaderNameOnWall), + frameEnabled: Boolean(config.frameEnabled), + frameImageUrl: config.frameImageUrl || null, + frameCanvasWidth: config.frameCanvasWidth ?? 1080, + frameCanvasHeight: config.frameCanvasHeight ?? 1080, + frameImageLayout: config.frameImageLayout, + frameLayout: config.frameLayout || [], + photos, + })).code(200); +}; + +module.exports = { getSpotlight, updateSpotlight, getPresent }; \ No newline at end of file diff --git a/src/controllers/spotlight.displayName.test.js b/src/controllers/spotlight.displayName.test.js new file mode 100644 index 0000000..5fc635b --- /dev/null +++ b/src/controllers/spotlight.displayName.test.js @@ -0,0 +1,74 @@ +jest.mock('../models/Spotlight', () => ({ findOne: jest.fn() })); +jest.mock('../models/Event', () => ({ findById: jest.fn() })); +jest.mock('../models/EventMicrosite', () => ({ findOne: jest.fn() })); +jest.mock('../models/SocialShare', () => ({ findOne: jest.fn() })); +jest.mock('../models/SocialShareOutput', () => ({ find: jest.fn() })); +jest.mock('../models/AiAvatar', () => ({ findOne: jest.fn() })); +jest.mock('../models/AiAvatarOutput', () => ({ find: jest.fn() })); +jest.mock('../models/PhotoWall', () => ({ findOne: jest.fn() })); +jest.mock('../models/PhotoWallPhoto', () => ({ find: jest.fn() })); +jest.mock('../models/QuizPoll', () => ({ find: jest.fn() })); +jest.mock('../utils/formPublicHelpers', () => ({ + EVENT_PUBLIC_SELECT: 'publicFields', + buildPublicEventPayload: jest.fn(() => ({ name: 'Event' })), + normalizePresenterEventHeaderDisplay: jest.fn((value) => value), +})); +jest.mock('../utils/s3EventPhotos', () => ({ + resolvePhotoPublicUrl: jest.fn((url) => url), + resolvePhotoThumbUrl: jest.fn(() => null), +})); + +const Spotlight = require('../models/Spotlight'); +const Event = require('../models/Event'); +const EventMicrosite = require('../models/EventMicrosite'); +const SocialShare = require('../models/SocialShare'); +const SocialShareOutput = require('../models/SocialShareOutput'); +const { getPresent } = require('./spotlight.controller'); + +function findQuery(result) { + return { + sort: () => ({ + limit: () => ({ + select: () => ({ lean: async () => result }), + }), + }), + }; +} + +describe('Spotlight uploader names', () => { + test('returns the stored Social Share name with the public photo row', async () => { + const eventId = '507f1f77bcf86cd799439031'; + const socialShareId = '507f1f77bcf86cd799439032'; + Spotlight.findOne.mockReturnValue({ + lean: async () => ({ + event: eventId, + enabled: true, + sources: { socialShare: true, aiAvatar: false, quiz: false, photoWall: false }, + }), + }); + Event.findById.mockReturnValue({ select: () => ({ lean: async () => ({ name: 'Event' }) }) }); + EventMicrosite.findOne.mockReturnValue({ select: () => ({ lean: async () => null }) }); + SocialShare.findOne.mockReturnValue({ select: () => ({ lean: async () => ({ _id: socialShareId }) }) }); + SocialShareOutput.find.mockReturnValue(findQuery([{ + _id: '507f1f77bcf86cd799439033', + url: 'original.jpg', + framedUrl: 'framed.jpg', + displayName: 'Ada Lovelace', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }])); + + const h = { + response: (payload) => ({ payload, code: (statusCode) => ({ payload, statusCode }) }), + }; + const response = await getPresent({ + params: { presentToken: 'spotlight-token' }, + query: {}, + }, h); + + expect(response.statusCode).toBe(200); + expect(response.payload.data.photos[0]).toEqual(expect.objectContaining({ + source: 'socialShare', + displayName: 'Ada Lovelace', + })); + }); +}); \ No newline at end of file diff --git a/src/models/AiAvatarOutput.js b/src/models/AiAvatarOutput.js index 8156495..a17bf03 100644 --- a/src/models/AiAvatarOutput.js +++ b/src/models/AiAvatarOutput.js @@ -32,6 +32,7 @@ const aiAvatarOutputSchema = new mongoose.Schema( mimeType: { type: String }, size: { type: Number }, originalFileName: { type: String }, + displayName: { type: String, trim: true, maxlength: 80, default: null }, }, { timestamps: true }, ); diff --git a/src/models/SocialShareOutput.js b/src/models/SocialShareOutput.js index 24b0dc9..009ae42 100644 --- a/src/models/SocialShareOutput.js +++ b/src/models/SocialShareOutput.js @@ -30,6 +30,7 @@ const socialShareOutputSchema = new mongoose.Schema( mimeType: { type: String }, size: { type: Number }, originalFileName: { type: String }, + displayName: { type: String, trim: true, maxlength: 80, default: null }, }, { timestamps: true }, ); diff --git a/src/models/Spotlight.js b/src/models/Spotlight.js new file mode 100644 index 0000000..4d9b6b7 --- /dev/null +++ b/src/models/Spotlight.js @@ -0,0 +1,52 @@ +const mongoose = require('mongoose'); +const crypto = require('crypto'); + +const spotlightSchema = new mongoose.Schema( + { + event: { type: mongoose.Schema.Types.ObjectId, ref: 'Event', required: true }, + organization: { type: mongoose.Schema.Types.ObjectId, ref: 'Organization', required: true }, + enabled: { type: Boolean, default: false }, + sources: { + socialShare: { type: Boolean, default: true }, + aiAvatar: { type: Boolean, default: true }, + quiz: { type: Boolean, default: true }, + photoWall: { type: Boolean, default: true }, + }, + photosPerRow: { type: Number, default: 4, min: 1, max: 12 }, + totalRows: { type: Number, default: 3, min: 1, max: 8 }, + marqueeDirection: { type: String, enum: ['left', 'right', 'alternate'], default: 'alternate' }, + maxPhotoWidth: { type: Number, default: 320, min: 80, max: 1200 }, + maxPhotoHeight: { type: Number, default: 320, min: 80, max: 1200 }, + gapPx: { type: Number, default: 8, min: 0, max: 64 }, + presentMarginTopPx: { type: Number, default: 0, min: 0, max: 2000 }, + presentMarginLeftPx: { type: Number, default: 0, min: 0, max: 2000 }, + presentMarginRightPx: { type: Number, default: 0, min: 0, max: 2000 }, + presentPhotoShape: { type: String, enum: ['rect', 'rounded', 'circle'], default: 'rounded' }, + showUploaderNameOnWall: { type: Boolean, default: false }, + frameEnabled: { type: Boolean, default: false }, + frameImageUrl: { type: String, trim: true, default: null }, + frameImageLayout: { type: mongoose.Schema.Types.Mixed, default: () => ({ x: 0, y: 0, w: 100, h: 100 }) }, + frameCanvasWidth: { type: Number, default: 1080, min: 200, max: 4096 }, + frameCanvasHeight: { type: Number, default: 1080, min: 200, max: 4096 }, + frameLayout: { type: [mongoose.Schema.Types.Mixed], default: [] }, + presenterEventHeader: { + showLogo: { type: Boolean, default: false }, + showBanner: { type: Boolean, default: false }, + showTitle: { type: Boolean, default: true }, + showDateRange: { type: Boolean, default: false }, + showEventType: { type: Boolean, default: false }, + showLocationType: { type: Boolean, default: false }, + showVenue: { type: Boolean, default: false }, + }, + presentToken: { type: String, trim: true }, + }, + { timestamps: true }, +); + +spotlightSchema.index({ event: 1 }, { unique: true }); +spotlightSchema.index({ presentToken: 1 }, { unique: true, sparse: true }); +spotlightSchema.statics.generateToken = function generateToken() { + return crypto.randomBytes(32).toString('hex'); +}; + +module.exports = mongoose.model('Spotlight', spotlightSchema); \ No newline at end of file diff --git a/src/routes/publicSpotlight.routes.js b/src/routes/publicSpotlight.routes.js new file mode 100644 index 0000000..401519a --- /dev/null +++ b/src/routes/publicSpotlight.routes.js @@ -0,0 +1,9 @@ +const controller = require('../controllers/spotlight.controller'); + +module.exports = [ + { + method: 'GET', + path: '/api/public/spotlight/present/{presentToken}', + options: { auth: false, handler: controller.getPresent }, + }, +]; \ No newline at end of file diff --git a/src/routes/spotlight.routes.js b/src/routes/spotlight.routes.js new file mode 100644 index 0000000..470d83f --- /dev/null +++ b/src/routes/spotlight.routes.js @@ -0,0 +1,41 @@ +const Joi = require('joi'); +const controller = require('../controllers/spotlight.controller'); + +const sources = Joi.object({ + socialShare: Joi.boolean(), + aiAvatar: Joi.boolean(), + quiz: Joi.boolean(), + photoWall: Joi.boolean(), +}); + +const updatePayload = Joi.object({ + enabled: Joi.boolean(), + sources, + photosPerRow: Joi.number().integer().min(1).max(12), + totalRows: Joi.number().integer().min(1).max(8), + marqueeDirection: Joi.string().valid('left', 'right', 'alternate'), + maxPhotoWidth: Joi.number().integer().min(80).max(1200), + maxPhotoHeight: Joi.number().integer().min(80).max(1200), + gapPx: Joi.number().integer().min(0).max(64), + presentMarginTopPx: Joi.number().integer().min(0).max(2000), + presentMarginLeftPx: Joi.number().integer().min(0).max(2000), + presentMarginRightPx: Joi.number().integer().min(0).max(2000), + presentPhotoShape: Joi.string().valid('rect', 'rounded', 'circle'), + showUploaderNameOnWall: Joi.boolean(), + frameEnabled: Joi.boolean(), + frameImageUrl: Joi.alternatives().try(Joi.string().allow('').max(2000), Joi.valid(null)), + frameImageLayout: Joi.object().unknown(true), + frameCanvasWidth: Joi.number().integer().min(200).max(4096), + frameCanvasHeight: Joi.number().integer().min(200).max(4096), + frameLayout: Joi.array().items(Joi.object().unknown(true)).max(40), + presenterEventHeader: Joi.object({ + showLogo: Joi.boolean(), showBanner: Joi.boolean(), showTitle: Joi.boolean(), + showDateRange: Joi.boolean(), showEventType: Joi.boolean(), showLocationType: Joi.boolean(), showVenue: Joi.boolean(), + }), + regenerateTokens: Joi.boolean(), +}).min(1); + +module.exports = [ + { method: 'GET', path: '/api/events/{eventId}/spotlight', options: { auth: 'jwt', handler: controller.getSpotlight } }, + { method: 'PATCH', path: '/api/events/{eventId}/spotlight', options: { auth: 'jwt', validate: { payload: updatePayload }, handler: controller.updateSpotlight } }, +]; \ No newline at end of file diff --git a/src/utils/uploaderDisplayName.js b/src/utils/uploaderDisplayName.js new file mode 100644 index 0000000..26b8eba --- /dev/null +++ b/src/utils/uploaderDisplayName.js @@ -0,0 +1,10 @@ +const MAX_UPLOADER_DISPLAY_NAME_LENGTH = 80; + +function normalizeUploaderDisplayName(value) { + if (typeof value !== 'string') return null; + const displayName = value.trim(); + if (!displayName || displayName.length > MAX_UPLOADER_DISPLAY_NAME_LENGTH) return null; + return displayName; +} + +module.exports = { MAX_UPLOADER_DISPLAY_NAME_LENGTH, normalizeUploaderDisplayName }; \ No newline at end of file diff --git a/src/utils/uploaderDisplayName.test.js b/src/utils/uploaderDisplayName.test.js new file mode 100644 index 0000000..7d3de57 --- /dev/null +++ b/src/utils/uploaderDisplayName.test.js @@ -0,0 +1,20 @@ +const { + MAX_UPLOADER_DISPLAY_NAME_LENGTH, + normalizeUploaderDisplayName, +} = require('./uploaderDisplayName'); + +describe('normalizeUploaderDisplayName', () => { + test('trims and accepts a non-empty name', () => { + expect(normalizeUploaderDisplayName(' Ada Lovelace ')).toBe('Ada Lovelace'); + }); + + test('rejects missing and whitespace-only values', () => { + expect(normalizeUploaderDisplayName(undefined)).toBeNull(); + expect(normalizeUploaderDisplayName(' ')).toBeNull(); + }); + + test('accepts the maximum length and rejects longer values', () => { + expect(normalizeUploaderDisplayName('a'.repeat(MAX_UPLOADER_DISPLAY_NAME_LENGTH))).toHaveLength(80); + expect(normalizeUploaderDisplayName('a'.repeat(MAX_UPLOADER_DISPLAY_NAME_LENGTH + 1))).toBeNull(); + }); +}); \ No newline at end of file