Add spotlight and uploader display name features

This commit is contained in:
Tanuja Pansare 2026-09-30 18:03:28 +05:30
parent f8155e1127
commit 843901280e
16 changed files with 610 additions and 5 deletions

View File

@ -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);

View File

@ -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,

View File

@ -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 {

View File

@ -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' }));
});
});

View File

@ -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,

View File

@ -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 {

View File

@ -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' }));
});
});

View File

@ -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 };

View File

@ -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',
}));
});
});

View File

@ -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 },
);

View File

@ -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 },
);

52
src/models/Spotlight.js Normal file
View File

@ -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);

View File

@ -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 },
},
];

View File

@ -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 } },
];

View File

@ -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 };

View File

@ -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();
});
});