Add reception WhatsApp config, chatbot event rebinding, and APIs for AI avatar, social share, photo search, and visitor theme.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ravi Gulhane 2026-08-17 09:50:51 +05:30
parent d77d6f7cd1
commit b86cb5e992
46 changed files with 4219 additions and 80 deletions

View File

@ -51,9 +51,10 @@ AWS_SECRET_ACCESS_KEY=ZpIxby1vbCLGB7CPWZsAltZBEqkhwzgBpFOKDGj8
AWS_DEFAULT_REGION=ap-south-1 AWS_DEFAULT_REGION=ap-south-1
AWS_BUCKET=rapportapp AWS_BUCKET=rapportapp
AWS_URL=https://did94hhhcgq1d.cloudfront.net AWS_URL=https://did94hhhcgq1d.cloudfront.net
# Event photos / photo-wall bucket + CDN (separate from AWS_URL media CDN) # Event photos bucket + CDN. Keys: {eventId}/{activity}/{kind}/{filename}
# (photo-wall|photo-search|ai-avatar|social-share)/(originals|framed|gallery|search-tmp|uploads|results)
AWS_EVENT_PICTURES_BUCKET=eventbuzz-pictures AWS_EVENT_PICTURES_BUCKET=eventbuzz-pictures
AWS_URL_PICTURES=https://d155t9or8i84n8.cloudfront.net AWS_URL_PICTURES=https://pictures.eventbuzz.live
# Participant check-in QR PNGs: s3://{AWS_BUCKET}/eventbuzz/check-in-qr/participant-{id}.png # Participant check-in QR PNGs: s3://{AWS_BUCKET}/eventbuzz/check-in-qr/participant-{id}.png
# Served via AWS_URL (CloudFront). Created on first use (campaigns, public QR route, concierge). # Served via AWS_URL (CloudFront). Created on first use (campaigns, public QR route, concierge).
# Amazon Bedrock (AI event quick-create, concierge food/places summaries). Uses Converse API. # Amazon Bedrock (AI event quick-create, concierge food/places summaries). Uses Converse API.
@ -221,3 +222,5 @@ GOOGLE_APPLICATION_CREDENTIALS=./eventbuzz-live-firebase-adminsdk-fbsvc-bb3dcfd6
# One-time fee (INR) org pays per event to unlock manual UPI checkout — recorded under Settings → Billing → invoices. # One-time fee (INR) org pays per event to unlock manual UPI checkout — recorded under Settings → Billing → invoices.
# EVENT_TICKETING_MANUAL_ENABLEMENT_FEE_INR=500 # EVENT_TICKETING_MANUAL_ENABLEMENT_FEE_INR=500
# WaveSpeed AI (AI Avatar head swap)
WAVESPEED_API_KEY=

View File

@ -30,6 +30,7 @@ const platformAdminRoutes = require('./routes/platformAdmin.routes');
const adminRequestRoutes = require('./routes/adminRequest.routes'); const adminRequestRoutes = require('./routes/adminRequest.routes');
const eventStaffRoutes = require('./routes/eventStaff.routes'); const eventStaffRoutes = require('./routes/eventStaff.routes');
const websiteSettingsRoutes = require('./routes/websiteSettings.routes'); const websiteSettingsRoutes = require('./routes/websiteSettings.routes');
const visitorThemeRoutes = require('./routes/visitorTheme.routes');
const eventMicrositeRoutes = require('./routes/eventMicrosite.routes'); const eventMicrositeRoutes = require('./routes/eventMicrosite.routes');
const visitorRoutes = require('./routes/visitor.routes'); const visitorRoutes = require('./routes/visitor.routes');
const whatsappTemplateRoutes = require('./routes/whatsappTemplate.routes'); const whatsappTemplateRoutes = require('./routes/whatsappTemplate.routes');
@ -49,9 +50,17 @@ const conciergeBotRoutes = require('./routes/conciergeBot.routes');
const publicConciergeRoutes = require('./routes/publicConcierge.routes'); const publicConciergeRoutes = require('./routes/publicConcierge.routes');
const publicQuizPollRoutes = require('./routes/publicQuizPoll.routes'); const publicQuizPollRoutes = require('./routes/publicQuizPoll.routes');
const publicPhotoWallRoutes = require('./routes/publicPhotoWall.routes'); const publicPhotoWallRoutes = require('./routes/publicPhotoWall.routes');
const publicEventPhotoSearchRoutes = require('./routes/publicEventPhotoSearch.routes');
const publicPhotoIngestRoutes = require('./routes/publicPhotoIngest.routes');
const publicSocialShareRoutes = require('./routes/publicSocialShare.routes');
const publicAiAvatarRoutes = require('./routes/publicAiAvatar.routes');
const publicVisitorThemeRoutes = require('./routes/publicVisitorTheme.routes');
const staticMicrositeRoutes = require('./routes/staticMicrosite.routes'); const staticMicrositeRoutes = require('./routes/staticMicrosite.routes');
const eventPhotoRoutes = require('./routes/eventPhoto.routes'); const eventPhotoRoutes = require('./routes/eventPhoto.routes');
const photoWallRoutes = require('./routes/photoWall.routes'); const photoWallRoutes = require('./routes/photoWall.routes');
const eventPhotoSearchRoutes = require('./routes/eventPhotoSearch.routes');
const socialShareRoutes = require('./routes/socialShare.routes');
const aiAvatarRoutes = require('./routes/aiAvatar.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). */
@ -287,6 +296,11 @@ const createServer = async () => {
server.route(publicConciergeRoutes); server.route(publicConciergeRoutes);
server.route(publicQuizPollRoutes); server.route(publicQuizPollRoutes);
server.route(publicPhotoWallRoutes); server.route(publicPhotoWallRoutes);
server.route(publicEventPhotoSearchRoutes);
server.route(publicPhotoIngestRoutes);
server.route(publicSocialShareRoutes);
server.route(publicAiAvatarRoutes);
server.route(publicVisitorThemeRoutes);
server.route(authRoutes); server.route(authRoutes);
server.route(eventRoutes); server.route(eventRoutes);
@ -316,6 +330,7 @@ const createServer = async () => {
server.route(adminRequestRoutes); server.route(adminRequestRoutes);
server.route(eventStaffRoutes); server.route(eventStaffRoutes);
server.route(websiteSettingsRoutes); server.route(websiteSettingsRoutes);
server.route(visitorThemeRoutes);
server.route(eventMicrositeRoutes); server.route(eventMicrositeRoutes);
server.route(staticMicrositeRoutes); server.route(staticMicrositeRoutes);
server.route(whatsappTemplateRoutes); server.route(whatsappTemplateRoutes);
@ -325,6 +340,9 @@ const createServer = async () => {
server.route(eventTicketingManualRoutes); server.route(eventTicketingManualRoutes);
server.route(eventPhotoRoutes); server.route(eventPhotoRoutes);
server.route(photoWallRoutes); server.route(photoWallRoutes);
server.route(eventPhotoSearchRoutes);
server.route(socialShareRoutes);
server.route(aiAvatarRoutes);
server.route(conciergeBotRoutes); server.route(conciergeBotRoutes);
server.route(attendanceRoutes); server.route(attendanceRoutes);
server.route(checkinsRoutes); server.route(checkinsRoutes);

View File

@ -87,6 +87,20 @@ const ACL_MODULE_DEFS = {
rowLevel: false, rowLevel: false,
planFeatures: [], planFeatures: [],
}, },
social_share: {
key: 'social_share',
label: 'Social Share',
category: 'event',
rowLevel: false,
planFeatures: [],
},
ai_avatar: {
key: 'ai_avatar',
label: 'AI Avatar',
category: 'event',
rowLevel: false,
planFeatures: [],
},
}; };
for (const key of COMBINED_PLAN_KEYS) { for (const key of COMBINED_PLAN_KEYS) {
@ -114,6 +128,8 @@ const ORDERED_ACL_MODULE_KEYS = [
'exhibitors', 'exhibitors',
'content_management', 'content_management',
'photo_wall', 'photo_wall',
'social_share',
'ai_avatar',
'email_outreach_campaigns', 'email_outreach_campaigns',
'custom_badge_and_print_file_creation', 'custom_badge_and_print_file_creation',
'custom_template_for_wa_messaging', 'custom_template_for_wa_messaging',
@ -128,6 +144,8 @@ const ORDERED_ACL_MODULE_KEYS = [
...COMBINED_PLAN_KEYS.filter((k) => ![ ...COMBINED_PLAN_KEYS.filter((k) => ![
'events', 'tracks', 'sessions', 'forms', 'web_check_in', 'attendance', 'participants', 'agenda_builder', 'events', 'tracks', 'sessions', 'forms', 'web_check_in', 'attendance', 'participants', 'agenda_builder',
'multi_track_agenda', 'speaker_management', 'exhibitors', 'content_management', 'photo_wall', 'multi_track_agenda', 'speaker_management', 'exhibitors', 'content_management', 'photo_wall',
'social_share',
'ai_avatar',
'email_outreach_campaigns', 'custom_badge_and_print_file_creation', 'email_outreach_campaigns', 'custom_badge_and_print_file_creation',
'custom_template_for_wa_messaging', 'live_polls', 'multiple_quiz_modes', 'custom_template_for_wa_messaging', 'live_polls', 'multiple_quiz_modes',
'qna_with_moderation', 'ticketing_free_and_paid', 'payment_gateway_integration', 'qna_with_moderation', 'ticketing_free_and_paid', 'payment_gateway_integration',

View File

@ -26,6 +26,8 @@ const CORE_EXPLICIT = [
'participants', 'participants',
'exhibitors', 'exhibitors',
'photo_wall', 'photo_wall',
'social_share',
'ai_avatar',
'user_management', 'user_management',
'settings', 'settings',
'billing', 'billing',
@ -42,6 +44,8 @@ const SYSTEM_ROLE_PERMISSIONS = {
perm('participants', ['read', 'write', 'delete'], 'all'), perm('participants', ['read', 'write', 'delete'], 'all'),
perm('exhibitors', ['read', 'write', 'delete'], 'all'), perm('exhibitors', ['read', 'write', 'delete'], 'all'),
perm('photo_wall', ['read', 'write', 'delete'], 'all'), perm('photo_wall', ['read', 'write', 'delete'], 'all'),
perm('social_share', ['read', 'write', 'delete'], 'all'),
perm('ai_avatar', ['read', 'write', 'delete'], 'all'),
perm('user_management', ['read', 'write', 'delete'], 'all'), perm('user_management', ['read', 'write', 'delete'], 'all'),
perm('settings', ['read', 'write', 'delete'], 'all'), perm('settings', ['read', 'write', 'delete'], 'all'),
perm('billing', ['read', 'write'], 'all'), perm('billing', ['read', 'write'], 'all'),
@ -59,6 +63,8 @@ const SYSTEM_ROLE_PERMISSIONS = {
perm('participants', ['read', 'write', 'delete'], 'all'), perm('participants', ['read', 'write', 'delete'], 'all'),
perm('exhibitors', ['read', 'write', 'delete'], 'all'), perm('exhibitors', ['read', 'write', 'delete'], 'all'),
perm('photo_wall', ['read', 'write', 'delete'], 'all'), perm('photo_wall', ['read', 'write', 'delete'], 'all'),
perm('social_share', ['read', 'write', 'delete'], 'all'),
perm('ai_avatar', ['read', 'write', 'delete'], 'all'),
perm('user_management', ['read', 'write'], 'all'), perm('user_management', ['read', 'write'], 'all'),
perm('settings', ['read', 'write'], 'all'), perm('settings', ['read', 'write'], 'all'),
perm('billing', ['read', 'write'], 'all'), perm('billing', ['read', 'write'], 'all'),
@ -76,6 +82,8 @@ const SYSTEM_ROLE_PERMISSIONS = {
perm('participants', ['read', 'write'], 'all'), perm('participants', ['read', 'write'], 'all'),
perm('exhibitors', ['read', 'write'], 'assigned'), perm('exhibitors', ['read', 'write'], 'assigned'),
perm('photo_wall', ['read', 'write'], 'assigned'), perm('photo_wall', ['read', 'write'], 'assigned'),
perm('social_share', ['read', 'write'], 'assigned'),
perm('ai_avatar', ['read', 'write'], 'assigned'),
...allPlanModules(['read', 'write'], 'all').filter( ...allPlanModules(['read', 'write'], 'all').filter(
(p) => !CORE_EXPLICIT.includes(p.module), (p) => !CORE_EXPLICIT.includes(p.module),
), ),
@ -90,6 +98,8 @@ const SYSTEM_ROLE_PERMISSIONS = {
perm('participants', ['read'], 'all'), perm('participants', ['read'], 'all'),
perm('exhibitors', ['read'], 'assigned'), perm('exhibitors', ['read'], 'assigned'),
perm('photo_wall', ['read'], 'assigned'), perm('photo_wall', ['read'], 'assigned'),
perm('social_share', ['read'], 'assigned'),
perm('ai_avatar', ['read'], 'assigned'),
...allPlanModules(['read'], 'all').filter( ...allPlanModules(['read'], 'all').filter(
(p) => !CORE_EXPLICIT.includes(p.module), (p) => !CORE_EXPLICIT.includes(p.module),
), ),

View File

@ -0,0 +1,274 @@
const mongoose = require('mongoose');
const AiAvatar = require('../models/AiAvatar');
const AiAvatarTemplate = require('../models/AiAvatarTemplate');
const Event = require('../models/Event');
const { success, error } = require('../utils/response');
const { normalizeEventHeaderDisplay } = require('../utils/formPublicHelpers');
async function assertEventOrgAccess(eventId, user) {
if (user.role === 'super_admin') return true;
const oid = user.organization?._id || user.organization;
const event = await Event.findById(eventId).select('organization').lean();
if (!event) return false;
return event.organization?.toString() === oid?.toString();
}
function ensureUploadToken(doc) {
if (!doc.uploadToken) {
doc.uploadToken = AiAvatar.generateToken();
return true;
}
return false;
}
async function getOrCreateConfig(eventId) {
const event = await Event.findById(eventId).select('organization').lean();
if (!event) return null;
let config = await AiAvatar.findOne({ event: eventId });
if (!config) {
config = await AiAvatar.create({
event: eventId,
organization: event.organization,
enabled: false,
uploadToken: AiAvatar.generateToken(),
});
} else if (ensureUploadToken(config)) {
await config.save();
}
return config;
}
function serializeAvatar(avatar) {
return {
_id: avatar._id,
aiAvatar: avatar.aiAvatar,
event: avatar.event,
name: avatar.name || 'Avatar',
enabled: avatar.enabled !== false,
sortOrder: avatar.sortOrder ?? 0,
imageUrl: avatar.imageUrl || null,
createdAt: avatar.createdAt,
updatedAt: avatar.updatedAt,
};
}
function serializeConfig(config, avatars = []) {
return {
_id: config._id,
event: config.event,
organization: config.organization,
enabled: Boolean(config.enabled),
accessMode: config.accessMode || 'open',
allowDownload: config.allowDownload !== false,
sharePlatforms: {
linkedin: Boolean(config.sharePlatforms?.linkedin),
facebook: Boolean(config.sharePlatforms?.facebook),
instagram: Boolean(config.sharePlatforms?.instagram),
snapchat: Boolean(config.sharePlatforms?.snapchat),
x: Boolean(config.sharePlatforms?.x),
whatsapp: Boolean(config.sharePlatforms?.whatsapp),
},
eventHeader: normalizeEventHeaderDisplay(config.eventHeader),
uploadToken: config.uploadToken || null,
avatars: avatars.map(serializeAvatar),
avatarCount: avatars.length,
createdAt: config.createdAt,
updatedAt: config.updatedAt,
};
}
async function loadAvatars(aiAvatarId) {
return AiAvatarTemplate.find({ aiAvatar: aiAvatarId }).sort({ sortOrder: 1, createdAt: 1 });
}
const getAiAvatar = 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 assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const config = await getOrCreateConfig(eventId);
if (!config) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const avatars = await loadAvatars(config._id);
return h.response(success(serializeConfig(config, avatars))).code(200);
};
const updateAiAvatar = 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 assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const config = await getOrCreateConfig(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.enabled) ensureUploadToken(config);
}
if (p.accessMode === 'open' || p.accessMode === 'login') config.accessMode = p.accessMode;
if (typeof p.allowDownload === 'boolean') config.allowDownload = p.allowDownload;
if (p.sharePlatforms != null && typeof p.sharePlatforms === 'object') {
const sp = p.sharePlatforms;
config.sharePlatforms = {
linkedin: Boolean(sp.linkedin),
facebook: Boolean(sp.facebook),
instagram: Boolean(sp.instagram),
snapchat: Boolean(sp.snapchat),
x: Boolean(sp.x),
whatsapp: Boolean(sp.whatsapp),
};
}
if (p.eventHeader != null && typeof p.eventHeader === 'object') {
config.eventHeader = normalizeEventHeaderDisplay({
...(config.eventHeader?.toObject?.() || config.eventHeader || {}),
...p.eventHeader,
});
}
if (p.regenerateTokens === true) {
config.uploadToken = AiAvatar.generateToken();
}
await config.save();
const avatars = await loadAvatars(config._id);
return h.response(success(serializeConfig(config, avatars), 'AI Avatar updated')).code(200);
};
const ensureTokensHandler = 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 assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const config = await getOrCreateConfig(eventId);
if (!config) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (ensureUploadToken(config)) await config.save();
const avatars = await loadAvatars(config._id);
return h.response(success(serializeConfig(config, avatars))).code(200);
};
const listAvatars = 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 assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const config = await getOrCreateConfig(eventId);
if (!config) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const avatars = await loadAvatars(config._id);
return h.response(success({ avatars: avatars.map(serializeAvatar) })).code(200);
};
const createAvatar = 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 assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const config = await getOrCreateConfig(eventId);
if (!config) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const p = request.payload || {};
const count = await AiAvatarTemplate.countDocuments({ aiAvatar: config._id });
const avatar = await AiAvatarTemplate.create({
aiAvatar: config._id,
event: eventId,
name: String(p.name || `Avatar ${count + 1}`).trim() || `Avatar ${count + 1}`,
enabled: p.enabled !== false,
sortOrder: typeof p.sortOrder === 'number' ? p.sortOrder : count,
imageUrl: p.imageUrl || null,
});
return h.response(success(serializeAvatar(avatar), 'Avatar created')).code(201);
};
const updateAvatar = async (request, h) => {
const user = request.auth.credentials?.user;
const { eventId, avatarId } = request.params;
if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401);
if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(avatarId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400);
}
if (!(await assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const config = await AiAvatar.findOne({ event: eventId });
if (!config) return h.response(error('NOT_FOUND', 'AI Avatar not found')).code(404);
const avatar = await AiAvatarTemplate.findOne({ _id: avatarId, aiAvatar: config._id });
if (!avatar) return h.response(error('NOT_FOUND', 'Avatar not found')).code(404);
const p = request.payload || {};
if (p.name != null) avatar.name = String(p.name).trim() || avatar.name;
if (typeof p.enabled === 'boolean') avatar.enabled = p.enabled;
if (typeof p.sortOrder === 'number') avatar.sortOrder = p.sortOrder;
if (p.imageUrl !== undefined) {
avatar.imageUrl = p.imageUrl ? String(p.imageUrl).trim() : null;
}
await avatar.save();
return h.response(success(serializeAvatar(avatar), 'Avatar updated')).code(200);
};
const deleteAvatar = async (request, h) => {
const user = request.auth.credentials?.user;
const { eventId, avatarId } = request.params;
if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401);
if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(avatarId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400);
}
if (!(await assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const config = await AiAvatar.findOne({ event: eventId });
if (!config) return h.response(error('NOT_FOUND', 'AI Avatar not found')).code(404);
const deleted = await AiAvatarTemplate.findOneAndDelete({ _id: avatarId, aiAvatar: config._id });
if (!deleted) return h.response(error('NOT_FOUND', 'Avatar not found')).code(404);
return h.response(success(null, 'Avatar deleted')).code(200);
};
module.exports = {
getAiAvatar,
updateAiAvatar,
ensureTokensHandler,
listAvatars,
createAvatar,
updateAvatar,
deleteAvatar,
getOrCreateConfig,
serializeConfig,
serializeAvatar,
};

View File

@ -6,6 +6,12 @@ const ChatSubscriber = require('../models/ChatSubscriber');
const Event = require('../models/Event'); const Event = require('../models/Event');
const { success, error } = require('../utils/response'); const { success, error } = require('../utils/response');
const { getOrgWhatsAppCredentials, assertPhoneNumberOnWaba } = require('../utils/metaWhatsApp'); const { getOrgWhatsAppCredentials, assertPhoneNumberOnWaba } = require('../utils/metaWhatsApp');
const {
getOrgWhatsAppDisplayDigits,
buildWaMeUrl,
generateReceptionQrPng,
receptionPrefill,
} = require('../utils/receptionWhatsApp');
const { resolveScopedOrganizationId } = require('../utils/tenantListScope'); const { resolveScopedOrganizationId } = require('../utils/tenantListScope');
const { const {
seedVenueFaqs, seedVenueFaqs,
@ -13,11 +19,149 @@ const {
normalizeFaqsPayload, normalizeFaqsPayload,
} = require('../utils/conciergeVenueFaqs'); } = require('../utils/conciergeVenueFaqs');
function normalizeReceptionPayload(p) {
if (!p || typeof p !== 'object') return null;
const drips = Array.isArray(p.drips)
? p.drips.map((d) => {
const trigger = ['after_first_inbound', 'after_match', 'absolute'].includes(d.trigger)
? d.trigger
: 'after_first_inbound';
const mediaType = ['none', 'image', 'video'].includes(d.mediaType) ? d.mediaType : 'none';
let templateId = null;
if (d.templateId && mongoose.Types.ObjectId.isValid(String(d.templateId))) {
templateId = d.templateId;
}
const delayMinutes = Number(d.delayMinutes);
return {
trigger,
delayMinutes: Number.isFinite(delayMinutes) ? Math.max(0, delayMinutes) : 0,
sendAt: d.sendAt ? new Date(d.sendAt) : null,
body: String(d.body || ''),
mediaType,
mediaUrl: String(d.mediaUrl || '').trim(),
templateId,
};
})
: [];
const audience = p.dripAudience === 'all_inbound' ? 'all_inbound' : 'matched';
return {
enabled: Boolean(p.enabled),
prefillText: String(p.prefillText != null ? p.prefillText : 'Hi').trim() || 'Hi',
welcomeMessage: String(p.welcomeMessage != null ? p.welcomeMessage : ''),
unmatchedBadgePrompt: String(p.unmatchedBadgePrompt != null ? p.unmatchedBadgePrompt : ''),
staffHandoffMessage: String(p.staffHandoffMessage != null ? p.staffHandoffMessage : ''),
dripAudience: audience,
drips,
};
}
async function assertSingleReceptionBot(organizationId, botId) {
const clash = await ConciergeBot.findOne({
organization: organizationId,
'reception.enabled': true,
_id: { $ne: botId },
}).select('_id name');
if (!clash) return null;
return `Reception WhatsApp is already enabled on another chatbot for this organisation (${clash.name || clash._id}). Disable it there first — one WhatsApp number can only serve one reception bot.`;
}
function enqueueConciergeIngest(data) { function enqueueConciergeIngest(data) {
const { enqueueConciergeIngest: enqueue } = require('../services/conciergeQueue'); const { enqueueConciergeIngest: enqueue } = require('../services/conciergeQueue');
return enqueue(data); return enqueue(data);
} }
const KNOWLEDGE_SYNC_TYPES = [
{
flag: 'includeEventDetails',
type: 'event',
displayName: 'Event programme & links',
stamp: 'lastEventSnapshotAt',
},
{
flag: 'includeFoodRecommendations',
type: 'food',
displayName: 'Food Recommendations',
stamp: 'lastFoodSnapshotAt',
},
{
flag: 'includePlacesRecommendations',
type: 'places',
displayName: 'Tourist Places Recommendation',
stamp: 'lastPlacesSnapshotAt',
},
];
async function clearEventKnowledge(eventId) {
if (!eventId) return;
await ContextChunk.deleteMany({ eventId });
await KnowledgeSource.deleteMany({ eventId });
}
async function queueEnabledKnowledgeSyncs(bot) {
const eventId = bot.event._id || bot.event;
bot.knowledge = bot.knowledge || {};
for (const row of KNOWLEDGE_SYNC_TYPES) {
if (bot.knowledge[row.flag] === false) continue;
let source = await KnowledgeSource.findOne({ eventId, type: row.type });
if (!source) {
source = await KnowledgeSource.create({
eventId,
type: row.type,
sourcePath: String(eventId),
displayName: row.displayName,
status: 'PENDING',
});
} else {
source.status = 'PENDING';
source.errorMessage = null;
source.displayName = row.displayName;
await source.save();
}
await enqueueConciergeIngest({
sourceId: String(source._id),
botId: String(bot._id),
eventId: String(eventId),
type: row.type,
});
bot.knowledge[row.stamp] = new Date();
}
}
async function rebindChatSessionsToEvent(botId, newEventId) {
const db = mongoose.connection?.db;
if (!db) return;
const botOid = new mongoose.Types.ObjectId(String(botId));
const eventOid = new mongoose.Types.ObjectId(String(newEventId));
const identityUnset = {
participant: '',
identityVerifiedAt: '',
identityMethod: '',
authState: '',
authEmail: '',
otpHash: '',
otpExpiresAt: '',
pendingIntent: '',
pendingMessage: '',
pendingEntities: '',
};
await db.collection('webchatsessions').updateMany(
{ botId: botOid },
{ $set: { eventId: eventOid }, $unset: identityUnset },
);
await db.collection('whatsappchatsessions').updateMany(
{ botId: botOid },
{
$set: {
eventId: eventOid,
receptionState: 'IDENTIFYING',
matchedAt: null,
matchScore: null,
},
$unset: identityUnset,
},
);
}
function userCanAccessOrganization(user, organizationId) { function userCanAccessOrganization(user, organizationId) {
if (user.role === 'super_admin') return true; if (user.role === 'super_admin') return true;
const scoped = resolveScopedOrganizationId(user, null); const scoped = resolveScopedOrganizationId(user, null);
@ -265,9 +409,92 @@ const update = async (request, h) => {
if (bot.enabled && bot.status !== 'ARCHIVED') bot.status = 'ACTIVE'; if (bot.enabled && bot.status !== 'ARCHIVED') bot.status = 'ACTIVE';
if (!bot.enabled && bot.status === 'ACTIVE') bot.status = 'DRAFT'; if (!bot.enabled && bot.status === 'ACTIVE') bot.status = 'DRAFT';
} }
if (p.reception != null) {
const next = normalizeReceptionPayload(p.reception);
if (next.enabled) {
const clashMsg = await assertSingleReceptionBot(bot.organization, bot._id);
if (clashMsg) {
return h.response(error('VALIDATION_ERROR', clashMsg)).code(400);
}
}
bot.reception = { ...(bot.reception?.toObject?.() || bot.reception || {}), ...next };
}
let eventRebound = false;
let previousEventId = null;
if (p.eventId != null && String(p.eventId).trim()) {
const nextEventId = String(p.eventId).trim();
if (!mongoose.Types.ObjectId.isValid(nextEventId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400);
}
const currentEventId = String(bot.event._id || bot.event);
if (nextEventId !== currentEventId) {
const event = await Event.findById(nextEventId).lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!userCanAccessOrganization(request.auth.credentials.user, event.organization)) {
return h.response(error('FORBIDDEN', 'Event not in your organisation')).code(403);
}
if (String(event.organization) !== String(bot.organization)) {
return h
.response(error('VALIDATION_ERROR', 'The event must belong to the same organisation as this chatbot.'))
.code(400);
}
const clash = await ConciergeBot.findOne({ event: nextEventId, _id: { $ne: bot._id } }).select('_id name');
if (clash) {
return h
.response(
error(
'VALIDATION_ERROR',
`This event already has a chatbot${clash.name ? ` (${clash.name})` : ''}.`,
),
)
.code(400);
}
previousEventId = currentEventId;
bot.event = nextEventId;
eventRebound = true;
}
}
try {
await bot.save(); await bot.save();
} catch (e) {
if (e && e.code === 11000) {
if (e.keyPattern && e.keyPattern.event != null) {
return h.response(error('VALIDATION_ERROR', 'This event already has a chatbot.')).code(400);
}
if (e.keyPattern && e.keyPattern.organization != null) {
return h
.response(
error(
'VALIDATION_ERROR',
'Reception WhatsApp is already enabled on another chatbot for this organisation.',
),
)
.code(400);
}
}
throw e;
}
if (eventRebound && previousEventId) {
await clearEventKnowledge(previousEventId);
await clearEventKnowledge(bot.event._id || bot.event);
bot.knowledge = bot.knowledge || {};
bot.knowledge.lastEventSnapshotAt = undefined;
bot.knowledge.lastFoodSnapshotAt = undefined;
bot.knowledge.lastPlacesSnapshotAt = undefined;
await queueEnabledKnowledgeSyncs(bot);
bot.lastKnowledgeClearedAt = new Date();
await bot.save();
try {
await rebindChatSessionsToEvent(bot._id, bot.event._id || bot.event);
} catch (sessionErr) {
console.warn('[concierge] rebind sessions after event change:', sessionErr.message);
}
}
const populated = await ConciergeBot.findById(bot._id).populate('event', 'name startDate endDate status').lean(); const populated = await ConciergeBot.findById(bot._id).populate('event', 'name startDate endDate status').lean();
return h.response(success({ bot: populated })); return h.response(success({ bot: populated, eventChanged: eventRebound }));
}; };
const remove = async (request, h) => { const remove = async (request, h) => {
@ -810,12 +1037,64 @@ const syncPlacesKnowledge = async (request, h) => {
} }
}; };
const getReception = async (request, h) => {
const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status);
const creds = await getOrgWhatsAppCredentials(String(bot.organization));
const resolved = await getOrgWhatsAppDisplayDigits(String(bot.organization));
const prefill = receptionPrefill(bot);
const waMeUrl = resolved.digits ? buildWaMeUrl(resolved.digits, prefill) : '';
let qrPngBase64 = '';
if (resolved.digits) {
try {
const buf = await generateReceptionQrPng(resolved.digits, prefill);
qrPngBase64 = buf.toString('base64');
} catch (e) {
console.warn('[reception-qr]', e.message);
}
}
return h.response(
success({
displayPhone: resolved.displayPhone || '',
displayDigits: resolved.digits || '',
waMeUrl,
qrPngBase64,
canSendMessages: Boolean(creds && creds.accessToken && String(creds.phoneNumberId || '').trim()),
graphAccess: Boolean(creds && creds.wabaId && creds.accessToken),
resolveError: resolved.error || null,
prefillText: prefill,
}),
);
};
const getReceptionQrPng = async (request, h) => {
const { bot, err } = await getBotForUser(request.params.botId, request.auth.credentials.user);
if (err) return h.response(error(err.code, err.message)).code(err.status).type('text/plain');
const resolved = await getOrgWhatsAppDisplayDigits(String(bot.organization));
if (!resolved.digits) {
return h.response(resolved.error || 'WhatsApp number unavailable').code(400).type('text/plain');
}
try {
const buf = await generateReceptionQrPng(resolved.digits, receptionPrefill(bot));
return h
.response(buf)
.type('image/png')
.header('Cache-Control', 'private, max-age=0')
.header('Content-Disposition', 'attachment; filename="reception-whatsapp-qr.png"');
} catch (e) {
console.error('[reception-qr]', e.message);
return h.response('Could not generate QR').code(500).type('text/plain');
}
};
module.exports = { module.exports = {
list, list,
create, create,
get, get,
update, update,
remove, remove,
getReception,
getReceptionQrPng,
getByEvent, getByEvent,
listSources, listSources,
createSource, createSource,

View File

@ -5,10 +5,22 @@ const { success, error } = require('../utils/response');
const { const {
uploadEventPhotoToS3, uploadEventPhotoToS3,
deleteEventPhotoFromS3, deleteEventPhotoFromS3,
resolvePhotoThumbUrl,
resolvePhotoPublicUrl,
PHOTO_ACTIVITIES,
PHOTO_KINDS,
} = require('../utils/s3EventPhotos'); } = require('../utils/s3EventPhotos');
const rekognitionService = require('../services/rekognitionService'); const rekognitionService = require('../services/rekognitionService');
const { userOrgId } = require('../services/acl.service'); const { userOrgId } = require('../services/acl.service');
function eventPhotoDisplayUrl(photo) {
return (
resolvePhotoThumbUrl(photo.s3Key || photo.url, { w: 400, h: 400 }) ||
resolvePhotoPublicUrl(photo.url) ||
photo.url
);
}
function toOid(v) { function toOid(v) {
if (!v) return null; if (!v) return null;
if (v instanceof mongoose.Types.ObjectId) return v; if (v instanceof mongoose.Types.ObjectId) return v;
@ -181,7 +193,7 @@ const listPublicEventPhotos = async (request, h) => {
: null, : null,
fileName: p.fileName, fileName: p.fileName,
originalFileName: p.originalFileName, originalFileName: p.originalFileName,
url: p.url, url: eventPhotoDisplayUrl(p),
s3Key: p.s3Key, s3Key: p.s3Key,
mimeType: p.mimeType, mimeType: p.mimeType,
size: p.size, size: p.size,
@ -222,8 +234,10 @@ const uploadPublicEventPhotos = async (request, h) => {
const uploaded = await uploadEventPhotoToS3({ const uploaded = await uploadEventPhotoToS3({
buffer: extracted.buffer, buffer: extracted.buffer,
contentType: mime, contentType: mime,
eventId: `picturewall/${eventId}`, eventId,
filename: storedFilename, filename: storedFilename,
activity: PHOTO_ACTIVITIES.photoSearch,
kind: PHOTO_KINDS.gallery,
}); });
const photo = await EventPhoto.create({ const photo = await EventPhoto.create({
@ -276,7 +290,7 @@ const listEventPhotos = async (request, h) => {
: null, : null,
fileName: p.fileName, fileName: p.fileName,
originalFileName: p.originalFileName, originalFileName: p.originalFileName,
url: p.url, url: eventPhotoDisplayUrl(p),
s3Key: p.s3Key, s3Key: p.s3Key,
mimeType: p.mimeType, mimeType: p.mimeType,
size: p.size, size: p.size,
@ -324,8 +338,10 @@ const uploadEventPhotos = async (request, h) => {
const uploaded = await uploadEventPhotoToS3({ const uploaded = await uploadEventPhotoToS3({
buffer: extracted.buffer, buffer: extracted.buffer,
contentType: mime, contentType: mime,
eventId: `picturewall/${eventId}`, eventId,
filename: storedFilename, filename: storedFilename,
activity: PHOTO_ACTIVITIES.photoSearch,
kind: PHOTO_KINDS.gallery,
}); });
const size = extracted.buffer.length; const size = extracted.buffer.length;
@ -413,8 +429,10 @@ const searchEventPhotos = async (request, h) => {
const uploaded = await uploadEventPhotoToS3({ const uploaded = await uploadEventPhotoToS3({
buffer: query.buffer, buffer: query.buffer,
contentType: mime, contentType: mime,
eventId: `search-tmp/${eventId}`, eventId,
filename: tempFilename, filename: tempFilename,
activity: PHOTO_ACTIVITIES.photoSearch,
kind: PHOTO_KINDS.searchTmp,
}); });
tempS3Key = uploaded.s3Key; tempS3Key = uploaded.s3Key;
@ -473,7 +491,7 @@ const searchEventPhotos = async (request, h) => {
uploadedBy: photo.uploadedBy ? String(photo.uploadedBy) : null, uploadedBy: photo.uploadedBy ? String(photo.uploadedBy) : null,
fileName: photo.fileName, fileName: photo.fileName,
originalFileName: photo.originalFileName, originalFileName: photo.originalFileName,
url: photo.url, url: eventPhotoDisplayUrl(photo),
s3Key: photo.s3Key, s3Key: photo.s3Key,
mimeType: photo.mimeType, mimeType: photo.mimeType,
size: photo.size, size: photo.size,
@ -572,8 +590,10 @@ const searchPublicEventPhotos = async (request, h) => {
const uploaded = await uploadEventPhotoToS3({ const uploaded = await uploadEventPhotoToS3({
buffer: query.buffer, buffer: query.buffer,
contentType: mime, contentType: mime,
eventId: `picturewall/search-tmp/${eventId}`, eventId,
filename: tempFilename, filename: tempFilename,
activity: PHOTO_ACTIVITIES.photoSearch,
kind: PHOTO_KINDS.searchTmp,
}); });
tempS3Key = uploaded.s3Key; tempS3Key = uploaded.s3Key;
@ -676,7 +696,7 @@ const searchPublicEventPhotos = async (request, h) => {
: null, : null,
fileName: photo.fileName, fileName: photo.fileName,
originalFileName: photo.originalFileName, originalFileName: photo.originalFileName,
url: photo.url, url: eventPhotoDisplayUrl(photo),
s3Key: photo.s3Key, s3Key: photo.s3Key,
mimeType: photo.mimeType, mimeType: photo.mimeType,
size: photo.size, size: photo.size,

View File

@ -0,0 +1,171 @@
const mongoose = require('mongoose');
const EventPhotoSearch = require('../models/EventPhotoSearch');
const EventPhoto = require('../models/EventPhoto');
const Event = require('../models/Event');
const { success, error } = require('../utils/response');
const { normalizeEventHeaderDisplay } = require('../utils/formPublicHelpers');
function toOid(v) {
if (!v) return null;
if (v instanceof mongoose.Types.ObjectId) return v;
if (!mongoose.Types.ObjectId.isValid(String(v))) return null;
return new mongoose.Types.ObjectId(String(v));
}
async function assertEventOrgAccess(eventId, user) {
if (user.role === 'super_admin') return true;
const oid = user.organization?._id || user.organization;
const event = await Event.findById(eventId).select('organization').lean();
if (!event) return false;
return event.organization?.toString() === oid?.toString();
}
function ensureSearchToken(doc) {
if (!doc.searchToken) {
doc.searchToken = EventPhotoSearch.generateToken();
return true;
}
return false;
}
async function getOrCreateConfig(eventId) {
const event = await Event.findById(eventId).select('organization').lean();
if (!event) return null;
let config = await EventPhotoSearch.findOne({ event: eventId });
if (!config) {
config = await EventPhotoSearch.create({
event: eventId,
organization: event.organization,
enabled: false,
searchToken: EventPhotoSearch.generateToken(),
});
} else if (ensureSearchToken(config)) {
await config.save();
}
return config;
}
async function photoCountForEvent(eventId) {
return EventPhoto.countDocuments({ event: toOid(eventId) || eventId });
}
function serializeConfig(config, photoCount = 0) {
return {
_id: config._id,
event: config.event,
organization: config.organization,
enabled: Boolean(config.enabled),
accessMode: config.accessMode || 'open',
allowDownload: config.allowDownload !== false,
sharePlatforms: {
linkedin: Boolean(config.sharePlatforms?.linkedin),
facebook: Boolean(config.sharePlatforms?.facebook),
instagram: Boolean(config.sharePlatforms?.instagram),
snapchat: Boolean(config.sharePlatforms?.snapchat),
x: Boolean(config.sharePlatforms?.x),
whatsapp: Boolean(config.sharePlatforms?.whatsapp),
},
eventHeader: normalizeEventHeaderDisplay(config.eventHeader),
searchToken: config.searchToken || null,
photoCount,
createdAt: config.createdAt,
updatedAt: config.updatedAt,
};
}
const getEventPhotoSearch = 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 assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const config = await getOrCreateConfig(eventId);
if (!config) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const photoCount = await photoCountForEvent(eventId);
return h.response(success(serializeConfig(config, photoCount))).code(200);
};
const updateEventPhotoSearch = 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 assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const config = await getOrCreateConfig(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.enabled) ensureSearchToken(config);
}
if (p.accessMode === 'open' || p.accessMode === 'login') config.accessMode = p.accessMode;
if (typeof p.allowDownload === 'boolean') config.allowDownload = p.allowDownload;
if (p.sharePlatforms != null && typeof p.sharePlatforms === 'object') {
const sp = p.sharePlatforms;
config.sharePlatforms = {
linkedin: Boolean(sp.linkedin),
facebook: Boolean(sp.facebook),
instagram: Boolean(sp.instagram),
snapchat: Boolean(sp.snapchat),
x: Boolean(sp.x),
whatsapp: Boolean(sp.whatsapp),
};
}
if (p.eventHeader != null && typeof p.eventHeader === 'object') {
config.eventHeader = normalizeEventHeaderDisplay({
...(config.eventHeader?.toObject?.() || config.eventHeader || {}),
...p.eventHeader,
});
}
if (p.regenerateTokens === true) {
config.searchToken = EventPhotoSearch.generateToken();
}
await config.save();
const photoCount = await photoCountForEvent(eventId);
return h.response(success(serializeConfig(config, photoCount), 'Photo search updated')).code(200);
};
const ensureTokensHandler = 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 assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const config = await getOrCreateConfig(eventId);
if (!config) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (ensureSearchToken(config)) await config.save();
const photoCount = await photoCountForEvent(eventId);
return h.response(success(serializeConfig(config, photoCount))).code(200);
};
module.exports = {
getEventPhotoSearch,
updateEventPhotoSearch,
ensureTokensHandler,
getOrCreateConfig,
serializeConfig,
};

View File

@ -4,7 +4,7 @@ const PhotoWallPhoto = require('../models/PhotoWallPhoto');
const Event = require('../models/Event'); const Event = require('../models/Event');
const Form = require('../models/Form'); const Form = require('../models/Form');
const { success, error } = require('../utils/response'); const { success, error } = require('../utils/response');
const { deleteEventPhotoFromS3, resolvePhotoPublicUrl } = require('../utils/s3EventPhotos'); const { deleteEventPhotoFromS3, resolvePhotoPublicUrl, resolvePhotoThumbUrl } = require('../utils/s3EventPhotos');
const { normalizeEventHeaderDisplay } = require('../utils/formPublicHelpers'); const { normalizeEventHeaderDisplay } = require('../utils/formPublicHelpers');
const { const {
normalizeFrameImageLayout, normalizeFrameImageLayout,
@ -137,9 +137,14 @@ function serializePhoto(doc) {
photoWall: doc.photoWall, photoWall: doc.photoWall,
event: doc.event, event: doc.event,
organization: doc.organization, organization: doc.organization,
url: resolvePhotoPublicUrl(doc.url) || doc.url, url: resolvePhotoThumbUrl(doc.s3Key || doc.url, { w: 640, h: 640 }) ||
resolvePhotoPublicUrl(doc.url) ||
doc.url,
s3Key: doc.s3Key, s3Key: doc.s3Key,
framedUrl: resolvePhotoPublicUrl(doc.framedUrl), framedUrl: doc.framedUrl
? resolvePhotoThumbUrl(doc.framedS3Key || doc.framedUrl, { w: 640, h: 640 }) ||
resolvePhotoPublicUrl(doc.framedUrl)
: null,
framedS3Key: doc.framedS3Key || null, framedS3Key: doc.framedS3Key || null,
mimeType: doc.mimeType, mimeType: doc.mimeType,
size: doc.size, size: doc.size,

View File

@ -0,0 +1,314 @@
const mongoose = require('mongoose');
const AiAvatar = require('../models/AiAvatar');
const AiAvatarTemplate = require('../models/AiAvatarTemplate');
const AiAvatarOutput = require('../models/AiAvatarOutput');
const Event = require('../models/Event');
const EventMicrosite = require('../models/EventMicrosite');
const { success, error } = require('../utils/response');
const {
uploadEventPhotoToS3,
getEventPhotoBufferFromS3,
resolvePhotoPublicUrl,
resolvePhotoThumbUrl,
PHOTO_ACTIVITIES,
PHOTO_KINDS,
} = require('../utils/s3EventPhotos');
const { runImageHeadSwap } = require('../services/waveSpeedHeadSwap');
const {
EVENT_PUBLIC_SELECT,
buildPublicEventPayload,
normalizeEventHeaderDisplay,
} = require('../utils/formPublicHelpers');
const ALLOWED_MIME = new Set([
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'image/bmp',
]);
async function streamToBuffer(stream) {
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
async function extractSingleFile(payload) {
const raw = payload?.file || payload?.files || payload?.photo;
if (!raw) return null;
const file = Array.isArray(raw) ? raw[0] : raw;
if (!file) return null;
let buffer;
if (Buffer.isBuffer(file)) {
buffer = file;
} else if (typeof file.pipe === 'function' || typeof file[Symbol.asyncIterator] === 'function') {
buffer = await streamToBuffer(file);
} else if (file._data && Buffer.isBuffer(file._data)) {
buffer = file._data;
} else {
return null;
}
const originalName = file.hapi?.filename || file.filename || 'selfie.jpg';
const mimeType =
file.hapi?.headers?.['content-type'] ||
file.headers?.['content-type'] ||
payload?.mimeType ||
'image/jpeg';
return { buffer, originalName, mimeType };
}
async function parseStringField(payload, key) {
let raw = payload?.[key];
if (raw == null || raw === '') return '';
if (typeof raw === 'string') return raw.trim();
if (Buffer.isBuffer(raw)) return raw.toString('utf8').trim();
if (typeof raw.pipe === 'function' || typeof raw[Symbol.asyncIterator] === 'function') {
const buf = await streamToBuffer(raw);
return buf.toString('utf8').trim();
}
return String(raw).trim();
}
function safeDownloadFilename(name, fallback = 'ai-avatar.jpg') {
const raw = String(name || fallback).trim() || fallback;
return raw.replace(/[^\w.\-()+ ]+/g, '_').slice(0, 120) || fallback;
}
async function loadEnabledConfig(uploadToken) {
const token = String(uploadToken || '').trim();
if (!token) return null;
const config = await AiAvatar.findOne({ uploadToken: token });
if (!config || !config.enabled) return null;
return config;
}
const getUploadMeta = async (request, h) => {
try {
const config = await loadEnabledConfig(request.params.uploadToken);
if (!config) {
return h.response(error('NOT_FOUND', 'AI Avatar not available')).code(404);
}
const event = await Event.findById(config.event).select(EVENT_PUBLIC_SELECT).lean();
const ms = event
? await EventMicrosite.findOne({ event: config.event }).select('logo').lean()
: null;
const eventPublic = event ? buildPublicEventPayload(event, ms?.logo) : null;
const avatars = await AiAvatarTemplate.find({
aiAvatar: config._id,
enabled: true,
})
.sort({ sortOrder: 1, createdAt: 1 })
.lean();
return h
.response(
success({
eventId: config.event,
eventName: eventPublic?.name || event?.name || 'Event',
event: eventPublic,
eventHeader: normalizeEventHeaderDisplay(config.eventHeader),
accessMode: config.accessMode || 'open',
allowDownload: config.allowDownload !== false,
sharePlatforms: {
linkedin: Boolean(config.sharePlatforms?.linkedin),
facebook: Boolean(config.sharePlatforms?.facebook),
instagram: Boolean(config.sharePlatforms?.instagram),
snapchat: Boolean(config.sharePlatforms?.snapchat),
x: Boolean(config.sharePlatforms?.x),
whatsapp: Boolean(config.sharePlatforms?.whatsapp),
},
avatars: avatars.map((a) => ({
_id: String(a._id),
name: a.name || 'Avatar',
// Media-library uploads live on AWS_URL, not the pictures bucket.
imageUrl: a.imageUrl || null,
// Default crop target: square selfie for head swap
photoSlot: { w: 1080, h: 1080 },
})),
}),
)
.code(200);
} catch (err) {
console.error('[aiAvatar] getUploadMeta failed', err?.message || err);
return h.response(error('INTERNAL', 'Could not load AI Avatar')).code(500);
}
};
const uploadPhoto = async (request, h) => {
const uploadToken = String(request.params.uploadToken || '').trim();
const config = await loadEnabledConfig(uploadToken);
if (!config) {
return h.response(error('NOT_FOUND', 'AI Avatar not available')).code(404);
}
const participantCred = request.auth?.credentials?.participant;
if (config.accessMode === 'login' && !participantCred?._id) {
return h.response(error('UNAUTHORIZED', 'Login required to upload')).code(401);
}
const avatarId = await parseStringField(request.payload || {}, 'avatarId');
if (!avatarId || !mongoose.Types.ObjectId.isValid(avatarId)) {
return h.response(error('VALIDATION_ERROR', 'Please select an avatar')).code(400);
}
const template = await AiAvatarTemplate.findOne({
_id: avatarId,
aiAvatar: config._id,
enabled: true,
}).lean();
if (!template) {
return h.response(error('NOT_FOUND', 'Avatar not found')).code(404);
}
const avatarImageUrl = String(template.imageUrl || '').trim();
if (!avatarImageUrl) {
return h.response(error('VALIDATION_ERROR', 'Selected avatar is not configured yet')).code(400);
}
const file = await extractSingleFile(request.payload || {});
if (!file?.buffer?.length) {
return h.response(error('VALIDATION_ERROR', 'Photo file is required')).code(400);
}
if (!ALLOWED_MIME.has(String(file.mimeType || '').toLowerCase())) {
return h.response(error('VALIDATION_ERROR', 'Unsupported image type')).code(400);
}
if (file.buffer.length > 15 * 1024 * 1024) {
return h.response(error('VALIDATION_ERROR', 'Image must be under 15MB')).code(400);
}
const safeName = String(file.originalName || 'selfie.jpg')
.replace(/[^a-zA-Z0-9._-]/g, '_')
.slice(0, 80);
const storedFilename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${safeName}`;
let uploaded;
try {
uploaded = await uploadEventPhotoToS3({
buffer: file.buffer,
contentType: file.mimeType,
eventId: String(config.event),
filename: storedFilename,
activity: PHOTO_ACTIVITIES.aiAvatar,
kind: PHOTO_KINDS.uploads,
});
} catch (err) {
console.error('[aiAvatar] S3 upload failed', err?.message);
return h.response(error('UPLOAD_FAILED', 'Failed to upload photo')).code(500);
}
const faceImageUrl = resolvePhotoPublicUrl(uploaded.url) || uploaded.url;
const output = await AiAvatarOutput.create({
aiAvatar: config._id,
event: config.event,
organization: config.organization,
template: template._id,
url: uploaded.url,
s3Key: uploaded.s3Key,
mimeType: file.mimeType,
size: file.buffer.length,
originalFileName: file.originalName,
});
try {
const { buffer } = await runImageHeadSwap({
imageUrl: avatarImageUrl,
faceImageUrl,
});
const resultUpload = await uploadEventPhotoToS3({
buffer,
contentType: 'image/jpeg',
eventId: String(config.event),
filename: `result-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.jpg`,
activity: PHOTO_ACTIVITIES.aiAvatar,
kind: PHOTO_KINDS.results,
});
output.resultUrl = resultUpload.url;
output.resultS3Key = resultUpload.s3Key;
await output.save();
} catch (err) {
console.error('[aiAvatar] WaveSpeed head swap failed', err?.message || err);
return h
.response(error('AI_FAILED', err?.message || 'Could not create AI avatar. Try again.'))
.code(500);
}
const previewUrl =
resolvePhotoThumbUrl(output.resultS3Key || output.resultUrl, { w: 1080, h: 1080 }) ||
resolvePhotoPublicUrl(output.resultUrl) ||
output.resultUrl;
const fileUrl =
resolvePhotoPublicUrl(output.resultS3Key || output.resultUrl) || previewUrl;
return h
.response(
success(
{
outputId: String(output._id),
previewUrl,
fileUrl,
},
'Avatar created',
),
)
.code(201);
};
const downloadOutputFile = async (request, h) => {
const config = await loadEnabledConfig(request.params.uploadToken);
if (!config) {
return h.response(error('NOT_FOUND', 'AI Avatar not available')).code(404);
}
const { outputId } = request.params;
if (!mongoose.Types.ObjectId.isValid(outputId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid output id')).code(400);
}
const output = await AiAvatarOutput.findOne({
_id: outputId,
aiAvatar: config._id,
}).lean();
if (!output) return h.response(error('NOT_FOUND', 'Photo not found')).code(404);
const s3Key = output.resultS3Key || output.s3Key;
if (!s3Key) return h.response(error('NOT_FOUND', 'Photo file not available')).code(404);
try {
const buf = await getEventPhotoBufferFromS3(s3Key);
const mime = output.resultS3Key ? 'image/jpeg' : output.mimeType || 'image/jpeg';
const filename = safeDownloadFilename(
output.resultS3Key ? 'ai-avatar.jpg' : output.originalFileName || 'ai-avatar.jpg',
);
const asAttachment =
String(request.query?.download || '').toLowerCase() === '1' ||
String(request.query?.download || '').toLowerCase() === 'true';
return h
.response(buf)
.type(mime)
.header('Cache-Control', 'private, max-age=300')
.header(
'Content-Disposition',
`${asAttachment ? 'attachment' : 'inline'}; filename="${filename}"`,
);
} catch (err) {
console.error('[aiAvatar] file proxy failed', err?.message || err);
return h.response(error('INTERNAL', 'Could not load photo')).code(500);
}
};
module.exports = {
getUploadMeta,
uploadPhoto,
downloadOutputFile,
};

View File

@ -0,0 +1,317 @@
const mongoose = require('mongoose');
const EventPhotoSearch = require('../models/EventPhotoSearch');
const EventPhoto = require('../models/EventPhoto');
const Event = require('../models/Event');
const EventMicrosite = require('../models/EventMicrosite');
const { success, error } = require('../utils/response');
const {
uploadEventPhotoToS3,
deleteEventPhotoFromS3,
getEventPhotoBufferFromS3,
resolvePhotoPublicUrl,
resolvePhotoThumbUrl,
PHOTO_ACTIVITIES,
PHOTO_KINDS,
} = require('../utils/s3EventPhotos');
const rekognitionService = require('../services/rekognitionService');
const {
EVENT_PUBLIC_SELECT,
buildPublicEventPayload,
normalizeEventHeaderDisplay,
} = require('../utils/formPublicHelpers');
const ALLOWED_MIME = new Set([
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'image/bmp',
]);
async function streamToBuffer(stream) {
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
async function extractQueryFile(payload) {
const raw = payload?.files || payload?.file || payload?.photo;
if (!raw) return null;
const file = Array.isArray(raw) ? raw[0] : raw;
if (!file) return null;
let buffer;
if (Buffer.isBuffer(file)) {
buffer = file;
} else if (typeof file.pipe === 'function' || typeof file[Symbol.asyncIterator] === 'function') {
buffer = await streamToBuffer(file);
} else if (file._data && Buffer.isBuffer(file._data)) {
buffer = file._data;
} else {
return null;
}
const originalName = file.hapi?.filename || file.filename || 'query.jpg';
const mimeType =
file.hapi?.headers?.['content-type'] ||
file.headers?.['content-type'] ||
payload?.mimeType ||
'image/jpeg';
return { buffer, originalName, mimeType };
}
function safeDownloadFilename(name, fallback = 'event-photo.jpg') {
const raw = String(name || fallback).trim() || fallback;
return raw.replace(/[^\w.\-()+ ]+/g, '_').slice(0, 120) || fallback;
}
async function loadEnabledConfig(searchToken) {
const token = String(searchToken || '').trim();
if (!token) return null;
const config = await EventPhotoSearch.findOne({ searchToken: token });
if (!config || !config.enabled) return null;
return config;
}
const getSearchMeta = async (request, h) => {
const config = await loadEnabledConfig(request.params.searchToken);
if (!config) {
return h.response(error('NOT_FOUND', 'Photo search not available')).code(404);
}
const event = await Event.findById(config.event).select(EVENT_PUBLIC_SELECT).lean();
const ms = event
? await EventMicrosite.findOne({ event: config.event }).select('logo').lean()
: null;
const eventPublic = event ? buildPublicEventPayload(event, ms?.logo) : null;
return h
.response(
success({
eventId: config.event,
eventName: eventPublic?.name || event?.name || 'Event',
event: eventPublic,
eventHeader: normalizeEventHeaderDisplay(config.eventHeader),
accessMode: config.accessMode || 'open',
allowDownload: config.allowDownload !== false,
sharePlatforms: {
linkedin: Boolean(config.sharePlatforms?.linkedin),
facebook: Boolean(config.sharePlatforms?.facebook),
instagram: Boolean(config.sharePlatforms?.instagram),
snapchat: Boolean(config.sharePlatforms?.snapchat),
x: Boolean(config.sharePlatforms?.x),
whatsapp: Boolean(config.sharePlatforms?.whatsapp),
},
}),
)
.code(200);
};
const searchByToken = async (request, h) => {
const config = await loadEnabledConfig(request.params.searchToken);
if (!config) {
return h.response(error('NOT_FOUND', 'Photo search not available')).code(404);
}
const participantCred = request.auth?.credentials?.participant;
if (config.accessMode === 'login' && !participantCred?._id) {
return h.response(error('UNAUTHORIZED', 'Login required to search')).code(401);
}
const query = await extractQueryFile(request.payload || {});
if (!query?.buffer?.length) {
return h
.response(
error(
'VALIDATION_ERROR',
'No query image received. Use multipart/form-data with a binary field named "files".',
),
)
.code(400);
}
const mime = query.mimeType || 'application/octet-stream';
if (mime !== 'application/octet-stream' && !ALLOWED_MIME.has(mime)) {
return h.response(error('VALIDATION_ERROR', 'Unsupported query image type')).code(400);
}
const eventId = String(config.event);
const searchToken = String(config.searchToken);
const tempFilename = `search-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
let tempS3Key = null;
try {
const uploaded = await uploadEventPhotoToS3({
buffer: query.buffer,
contentType: mime,
eventId,
filename: tempFilename,
activity: PHOTO_ACTIVITIES.photoSearch,
kind: PHOTO_KINDS.searchTmp,
});
tempS3Key = uploaded.s3Key;
const { collectionExists, matches } = await rekognitionService.searchPhoto(eventId, {
s3Key: tempS3Key,
bucket: undefined,
faceMatchThreshold: 50,
maxFaces: 100,
});
if (!collectionExists) {
return h
.response(
success(
{ collectionExists: false, matchCount: 0, matches: [] },
'No collection exists for this event yet',
),
)
.code(200);
}
if (!matches || matches.length === 0) {
return h
.response(
success({ collectionExists: true, matchCount: 0, matches: [] }, 'No matches found'),
)
.code(200);
}
const faceIds = matches.map((m) => m.faceId).filter(Boolean);
const photos = await EventPhoto.find({
event: config.event,
'faces.faceId': { $in: faceIds },
}).lean();
const photoByFaceId = new Map();
for (const photo of photos) {
for (const face of photo.faces || []) {
if (face?.faceId) photoByFaceId.set(face.faceId, photo);
}
}
const bestByPhoto = new Map();
for (const match of matches) {
const photo = photoByFaceId.get(match.faceId);
if (!photo) continue;
const existing = bestByPhoto.get(String(photo._id));
if (!existing || match.similarity > existing.similarity) {
bestByPhoto.set(String(photo._id), {
photo,
similarity: match.similarity,
matchedFace: match,
});
}
}
const results = Array.from(bestByPhoto.values())
.map(({ photo, similarity, matchedFace }) => {
const photoId = String(photo._id);
const fullUrl =
resolvePhotoPublicUrl(photo.s3Key || photo.url) ||
resolvePhotoPublicUrl(photo.url) ||
photo.url ||
null;
return {
_id: photoId,
event: String(photo.event),
organization: String(photo.organization),
fileName: photo.fileName,
originalFileName: photo.originalFileName,
url:
resolvePhotoThumbUrl(photo.s3Key || photo.url, { w: 400, h: 400 }) ||
fullUrl,
/** Full-resolution CDN URL (not API proxy). */
fileUrl: fullUrl,
mimeType: photo.mimeType,
size: photo.size,
uploadedAt: photo.createdAt,
similarity,
matchedFace: {
faceId: matchedFace.faceId,
similarity: matchedFace.similarity,
boundingBox: matchedFace.boundingBox,
},
};
})
.sort((a, b) => b.similarity - a.similarity);
return h
.response(
success(
{ collectionExists: true, matchCount: results.length, matches: results },
`${results.length} photo(s) matched`,
),
)
.code(200);
} catch (err) {
console.error('[photoSearch] Public search failed:', err?.name, err?.message);
if (err && (err.name === 'ResourceNotFoundException' || err.code === 'ResourceNotFoundException')) {
return h
.response(
success(
{ collectionExists: false, matchCount: 0, matches: [] },
'No collection exists for this event yet',
),
)
.code(200);
}
return h.response(error('REKOGNITION_ERROR', 'Photo search failed. Please try again later.')).code(500);
} finally {
if (tempS3Key) {
try {
await deleteEventPhotoFromS3({ s3Key: tempS3Key });
} catch (cleanupErr) {
console.error(`[photoSearch] Failed to delete temp query image ${tempS3Key}:`, cleanupErr?.message);
}
}
}
};
const downloadMatchFile = async (request, h) => {
const config = await loadEnabledConfig(request.params.searchToken);
if (!config) {
return h.response(error('NOT_FOUND', 'Photo search not available')).code(404);
}
const { photoId } = request.params;
if (!mongoose.Types.ObjectId.isValid(photoId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid photo id')).code(400);
}
const photo = await EventPhoto.findOne({ _id: photoId, event: config.event }).lean();
if (!photo?.s3Key) {
return h.response(error('NOT_FOUND', 'Photo not found')).code(404);
}
try {
const buf = await getEventPhotoBufferFromS3(photo.s3Key);
const mime = photo.mimeType || 'image/jpeg';
const filename = safeDownloadFilename(photo.originalFileName || photo.fileName || 'event-photo.jpg');
const asAttachment =
String(request.query?.download || '').toLowerCase() === '1' ||
String(request.query?.download || '').toLowerCase() === 'true';
return h
.response(buf)
.type(mime)
.header('Cache-Control', 'private, max-age=300')
.header(
'Content-Disposition',
`${asAttachment ? 'attachment' : 'inline'}; filename="${filename}"`,
);
} catch (err) {
console.error('[photoSearch] file proxy failed', err?.message || err);
return h.response(error('INTERNAL', 'Could not load photo')).code(500);
}
};
module.exports = {
getSearchMeta,
searchByToken,
downloadMatchFile,
};

View File

@ -0,0 +1,255 @@
const Event = require('../models/Event');
const EventPhoto = require('../models/EventPhoto');
const EventPhotoSearch = require('../models/EventPhotoSearch');
const { success, error } = require('../utils/response');
const { uploadEventPhotoToS3, PHOTO_ACTIVITIES, PHOTO_KINDS } = require('../utils/s3EventPhotos');
const rekognitionService = require('../services/rekognitionService');
const ALLOWED_MIME = new Set([
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
]);
const MAX_BYTES = 8 * 1024 * 1024;
const UPLOADED_NAMES_CAP = 5000;
function normalizeJoinCode(input) {
return String(input || '').trim().toUpperCase();
}
async function streamToBuffer(stream) {
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
async function extractUploadFiles(payload) {
const raw = payload?.files || payload?.file;
if (!raw) return [];
const items = Array.isArray(raw) ? raw : [raw];
const results = [];
for (const file of items) {
let buffer;
if (Buffer.isBuffer(file)) {
buffer = file;
} else if (typeof file.pipe === 'function' || typeof file[Symbol.asyncIterator] === 'function') {
buffer = await streamToBuffer(file);
} else if (file._data && Buffer.isBuffer(file._data)) {
buffer = file._data;
} else {
continue;
}
const originalName = file.hapi?.filename || file.filename || 'photo.jpg';
const mimeType =
file.hapi?.headers?.['content-type'] ||
file.headers?.['content-type'] ||
payload?.mimeType ||
'image/jpeg';
results.push({ buffer, originalName, mimeType });
}
return results;
}
async function loadIngestContext(joinCodeRaw) {
const joinCode = normalizeJoinCode(joinCodeRaw);
if (!joinCode) return { errorCode: 'NOT_FOUND', message: 'Invalid event code', status: 404 };
const event = await Event.findOne({ joinCode }).select('_id name organization joinCode').lean();
if (!event) return { errorCode: 'NOT_FOUND', message: 'Invalid event code', status: 404 };
const photoSearch = await EventPhotoSearch.findOne({ event: event._id }).lean();
if (!photoSearch || !photoSearch.enabled) {
return {
errorCode: 'DISABLED',
message: 'Photo Search is not enabled for this event',
status: 403,
};
}
return { event, photoSearch, joinCode };
}
async function runRekognitionIndexing(photo) {
try {
await rekognitionService.indexPhoto(photo);
} catch (err) {
console.error(
`[photoIngest] Rekognition indexing failed for photo ${photo?._id} ` +
`(event=${photo?.event}, s3Key=${photo?.s3Key}).`,
err?.name,
err?.message,
);
}
}
const getMeta = async (request, h) => {
const ctx = await loadIngestContext(request.params.joinCode);
if (ctx.errorCode) {
return h.response(error(ctx.errorCode, ctx.message)).code(ctx.status);
}
const photoCount = await EventPhoto.countDocuments({ event: ctx.event._id });
return h
.response(
success({
eventId: String(ctx.event._id),
eventName: ctx.event.name || 'Event',
joinCode: ctx.joinCode,
photoCount,
}),
)
.code(200);
};
const listUploadedNames = async (request, h) => {
const ctx = await loadIngestContext(request.params.joinCode);
if (ctx.errorCode) {
return h.response(error(ctx.errorCode, ctx.message)).code(ctx.status);
}
const rows = await EventPhoto.find({ event: ctx.event._id })
.sort({ createdAt: -1 })
.limit(UPLOADED_NAMES_CAP)
.select('originalFileName')
.lean();
const names = [];
const seen = new Set();
for (const row of rows) {
const name = String(row.originalFileName || '').trim();
if (!name || seen.has(name)) continue;
seen.add(name);
names.push(name);
}
return h.response(success({ names, count: names.length })).code(200);
};
const uploadPhotos = async (request, h) => {
const ctx = await loadIngestContext(request.params.joinCode);
if (ctx.errorCode) {
return h.response(error(ctx.errorCode, ctx.message)).code(ctx.status);
}
const files = await extractUploadFiles(request.payload || {});
if (files.length === 0) {
return h
.response(
error(
'VALIDATION_ERROR',
'No files received. Use multipart/form-data with field "files".',
),
)
.code(400);
}
const eventId = String(ctx.event._id);
const orgId = ctx.event.organization;
const created = [];
const skipped = [];
const failed = [];
for (const extracted of files) {
const mime = String(extracted.mimeType || '').toLowerCase();
const originalFileName = String(extracted.originalName || 'photo.jpg').trim() || 'photo.jpg';
try {
if (!ALLOWED_MIME.has(mime) && mime !== 'application/octet-stream') {
failed.push({ originalFileName, error: 'Unsupported image type' });
continue;
}
if (!extracted.buffer?.length) {
failed.push({ originalFileName, error: 'Empty file' });
continue;
}
if (extracted.buffer.length > MAX_BYTES) {
failed.push({ originalFileName, error: 'Image must be under 8MB' });
continue;
}
const existing = await EventPhoto.findOne({
event: eventId,
originalFileName,
})
.select('_id originalFileName url s3Key')
.lean();
if (existing) {
skipped.push({
_id: String(existing._id),
originalFileName,
reason: 'already_uploaded',
});
continue;
}
const sanitizedOriginal = originalFileName.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 80);
const storedFilename = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}-${sanitizedOriginal}`;
const contentType = ALLOWED_MIME.has(mime) ? mime : 'image/jpeg';
const uploaded = await uploadEventPhotoToS3({
buffer: extracted.buffer,
contentType,
eventId,
filename: storedFilename,
activity: PHOTO_ACTIVITIES.photoSearch,
kind: PHOTO_KINDS.gallery,
});
const photo = await EventPhoto.create({
event: eventId,
organization: orgId,
fileName: storedFilename,
originalFileName,
url: uploaded.url,
s3Key: uploaded.s3Key,
mimeType: contentType,
size: extracted.buffer.length,
embedding_generated: false,
});
void runRekognitionIndexing(photo);
created.push({
_id: String(photo._id),
originalFileName,
url: photo.url,
s3Key: photo.s3Key,
size: photo.size,
});
} catch (err) {
console.error('[photoIngest] upload failed', originalFileName, err?.message || err);
failed.push({
originalFileName,
error: err?.message || 'Upload failed',
});
}
}
return h
.response(
success(
{
created,
skipped,
failed,
createdCount: created.length,
skippedCount: skipped.length,
failedCount: failed.length,
},
`${created.length} uploaded, ${skipped.length} skipped, ${failed.length} failed`,
),
)
.code(created.length > 0 ? 201 : 200);
};
module.exports = {
getMeta,
listUploadedNames,
uploadPhotos,
};

View File

@ -7,7 +7,7 @@ const Form = require('../models/Form');
const FormSubmission = require('../models/FormSubmission'); const FormSubmission = require('../models/FormSubmission');
const Participant = require('../models/Participant'); const Participant = require('../models/Participant');
const { success, error } = require('../utils/response'); const { success, error } = require('../utils/response');
const { uploadPhotoWallPhotoToS3, getEventPhotoBufferFromS3, resolvePhotoPublicUrl } = require('../utils/s3EventPhotos'); const { uploadPhotoWallPhotoToS3, getEventPhotoBufferFromS3, resolvePhotoPublicUrl, resolvePhotoThumbUrl } = require('../utils/s3EventPhotos');
const { getMembership } = require('../utils/participantMembership'); const { getMembership } = require('../utils/participantMembership');
const { serializePhoto } = require('./photoWall.controller'); const { serializePhoto } = require('./photoWall.controller');
const { renderAndUploadFramedPhoto } = require('../utils/photoWallFrameRender'); const { renderAndUploadFramedPhoto } = require('../utils/photoWallFrameRender');
@ -426,8 +426,15 @@ const uploadPhoto = async (request, h) => {
} }
} }
const fileUrl = `/api/public/photo-wall/upload/${encodeURIComponent(String(uploadToken).trim())}/photos/${photo._id}/file`; const previewUrl =
const previewUrl = resolvePhotoPublicUrl(photo.framedUrl || photo.url); resolvePhotoThumbUrl(photo.framedS3Key || photo.framedUrl || photo.s3Key || photo.url, {
w: 640,
h: 640,
}) || resolvePhotoPublicUrl(photo.framedUrl || photo.url);
/** Full CDN URL for download/share — do not proxy bytes through the API. */
const fileUrl =
resolvePhotoPublicUrl(photo.framedS3Key || photo.framedUrl || photo.s3Key || photo.url) ||
previewUrl;
return h return h
.response( .response(
@ -436,7 +443,6 @@ const uploadPhoto = async (request, h) => {
photo: serializePhoto(photo), photo: serializePhoto(photo),
awaitingApproval: status === 'pending', awaitingApproval: status === 'pending',
previewUrl, previewUrl,
/** Same-origin API URL for download/share (avoids CloudFront CORS on fetch). */
fileUrl, fileUrl,
framed: Boolean(photo.framedUrl), framed: Boolean(photo.framedUrl),
}, },
@ -532,10 +538,13 @@ const getPresentWall = async (request, h) => {
frameCanvasWidth, frameCanvasWidth,
frameCanvasHeight, frameCanvasHeight,
photos: photos.map((p) => { photos: photos.map((p) => {
const raw = frameEnabled && p.framedUrl ? p.framedUrl : p.url; const rawKey = frameEnabled && p.framedUrl ? p.framedUrl : p.url;
return { return {
_id: p._id, _id: p._id,
url: resolvePhotoPublicUrl(raw) || raw, url:
resolvePhotoThumbUrl(rawKey, { w: 640, h: 640 }) ||
resolvePhotoPublicUrl(rawKey) ||
rawKey,
createdAt: p.createdAt, createdAt: p.createdAt,
...(showUploaderNameOnWall ...(showUploaderNameOnWall
? { displayName: p.displayName ? String(p.displayName) : null } ? { displayName: p.displayName ? String(p.displayName) : null }

View File

@ -0,0 +1,330 @@
const mongoose = require('mongoose');
const SocialShare = require('../models/SocialShare');
const SocialShareFrame = require('../models/SocialShareFrame');
const SocialShareOutput = require('../models/SocialShareOutput');
const Event = require('../models/Event');
const EventMicrosite = require('../models/EventMicrosite');
const { success, error } = require('../utils/response');
const {
uploadEventPhotoToS3,
getEventPhotoBufferFromS3,
resolvePhotoPublicUrl,
resolvePhotoThumbUrl,
PHOTO_ACTIVITIES,
PHOTO_KINDS,
} = require('../utils/s3EventPhotos');
const { renderAndUploadFramedPhoto } = require('../utils/photoWallFrameRender');
const {
EVENT_PUBLIC_SELECT,
buildPublicEventPayload,
normalizeEventHeaderDisplay,
} = require('../utils/formPublicHelpers');
const ALLOWED_MIME = new Set([
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'image/bmp',
]);
async function streamToBuffer(stream) {
const chunks = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
async function extractSingleFile(payload) {
const raw = payload?.file || payload?.files || payload?.photo;
if (!raw) return null;
const file = Array.isArray(raw) ? raw[0] : raw;
if (!file) return null;
let buffer;
if (Buffer.isBuffer(file)) {
buffer = file;
} else if (typeof file.pipe === 'function' || typeof file[Symbol.asyncIterator] === 'function') {
buffer = await streamToBuffer(file);
} else if (file._data && Buffer.isBuffer(file._data)) {
buffer = file._data;
} else {
return null;
}
const originalName = file.hapi?.filename || file.filename || 'selfie.jpg';
const mimeType =
file.hapi?.headers?.['content-type'] ||
file.headers?.['content-type'] ||
payload?.mimeType ||
'image/jpeg';
return { buffer, originalName, mimeType };
}
async function parseStringField(payload, key) {
let raw = payload?.[key];
if (raw == null || raw === '') return '';
if (typeof raw === 'string') return raw.trim();
if (Buffer.isBuffer(raw)) return raw.toString('utf8').trim();
if (typeof raw.pipe === 'function' || typeof raw[Symbol.asyncIterator] === 'function') {
const buf = await streamToBuffer(raw);
return buf.toString('utf8').trim();
}
return String(raw).trim();
}
function safeDownloadFilename(name, fallback = 'social-share.jpg') {
const raw = String(name || fallback).trim() || fallback;
return raw.replace(/[^\w.\-()+ ]+/g, '_').slice(0, 120) || fallback;
}
async function loadEnabledConfig(uploadToken) {
const token = String(uploadToken || '').trim();
if (!token) return null;
const config = await SocialShare.findOne({ uploadToken: token });
if (!config || !config.enabled) return null;
return config;
}
function frameAsWallShape(config, frame) {
return {
event: config.event,
frameEnabled: true,
frameImageUrl: frame.frameImageUrl,
frameImageLayout: frame.frameImageLayout,
frameCanvasWidth: frame.frameCanvasWidth,
frameCanvasHeight: frame.frameCanvasHeight,
frameLayout: frame.frameLayout,
};
}
const getUploadMeta = async (request, h) => {
const config = await loadEnabledConfig(request.params.uploadToken);
if (!config) {
return h.response(error('NOT_FOUND', 'Social share not available')).code(404);
}
const event = await Event.findById(config.event).select(EVENT_PUBLIC_SELECT).lean();
const ms = event
? await EventMicrosite.findOne({ event: config.event }).select('logo').lean()
: null;
const eventPublic = event ? buildPublicEventPayload(event, ms?.logo) : null;
const frames = await SocialShareFrame.find({
socialShare: config._id,
enabled: true,
})
.sort({ sortOrder: 1, createdAt: 1 })
.lean();
return h
.response(
success({
eventId: config.event,
eventName: eventPublic?.name || event?.name || 'Event',
event: eventPublic,
eventHeader: normalizeEventHeaderDisplay(config.eventHeader),
accessMode: config.accessMode || 'open',
requireUploadCrop: Boolean(config.requireUploadCrop),
maxPhotoWidth: config.maxPhotoWidth ?? 1080,
maxPhotoHeight: config.maxPhotoHeight ?? 1080,
allowDownload: config.allowDownload !== false,
sharePlatforms: {
linkedin: Boolean(config.sharePlatforms?.linkedin),
facebook: Boolean(config.sharePlatforms?.facebook),
instagram: Boolean(config.sharePlatforms?.instagram),
snapchat: Boolean(config.sharePlatforms?.snapchat),
x: Boolean(config.sharePlatforms?.x),
whatsapp: Boolean(config.sharePlatforms?.whatsapp),
},
frames: frames.map((f) => {
const layout = Array.isArray(f.frameLayout) ? f.frameLayout : [];
const photoEl = layout.find(
(el) => el && el.kind === 'field' && el.field === 'photo',
);
let photoSlot = null;
if (photoEl) {
const w = Math.max(1, Math.round(Number(photoEl.w) || 400));
// Circles always crop square; otherwise use slot box.
const h =
photoEl.photoShape === 'circle'
? w
: Math.max(1, Math.round(Number(photoEl.h) || w));
photoSlot = { w, h };
}
return {
_id: String(f._id),
name: f.name || 'Frame',
frameImageUrl: resolvePhotoPublicUrl(f.frameImageUrl) || f.frameImageUrl || null,
frameCanvasWidth: f.frameCanvasWidth ?? 1080,
frameCanvasHeight: f.frameCanvasHeight ?? 1080,
photoSlot,
};
}),
}),
)
.code(200);
};
const uploadPhoto = async (request, h) => {
const uploadToken = String(request.params.uploadToken || '').trim();
const config = await loadEnabledConfig(uploadToken);
if (!config) {
return h.response(error('NOT_FOUND', 'Social share not available')).code(404);
}
const participantCred = request.auth?.credentials?.participant;
if (config.accessMode === 'login' && !participantCred?._id) {
return h.response(error('UNAUTHORIZED', 'Login required to upload')).code(401);
}
const frameId = await parseStringField(request.payload || {}, 'frameId');
if (!frameId || !mongoose.Types.ObjectId.isValid(frameId)) {
return h.response(error('VALIDATION_ERROR', 'Please select a frame')).code(400);
}
const frame = await SocialShareFrame.findOne({
_id: frameId,
socialShare: config._id,
enabled: true,
}).lean();
if (!frame) {
return h.response(error('NOT_FOUND', 'Frame not found')).code(404);
}
if (!frame.frameImageUrl) {
return h.response(error('VALIDATION_ERROR', 'Selected frame is not configured yet')).code(400);
}
const file = await extractSingleFile(request.payload || {});
if (!file?.buffer?.length) {
return h.response(error('VALIDATION_ERROR', 'Photo file is required')).code(400);
}
if (!ALLOWED_MIME.has(String(file.mimeType || '').toLowerCase())) {
return h.response(error('VALIDATION_ERROR', 'Unsupported image type')).code(400);
}
if (file.buffer.length > 15 * 1024 * 1024) {
return h.response(error('VALIDATION_ERROR', 'Image must be under 15MB')).code(400);
}
const safeName = String(file.originalName || 'selfie.jpg')
.replace(/[^a-zA-Z0-9._-]/g, '_')
.slice(0, 80);
const storedFilename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}-${safeName}`;
let uploaded;
try {
uploaded = await uploadEventPhotoToS3({
buffer: file.buffer,
contentType: file.mimeType,
eventId: String(config.event),
filename: storedFilename,
activity: PHOTO_ACTIVITIES.socialShare,
kind: PHOTO_KINDS.uploads,
});
} catch (err) {
console.error('[socialShare] S3 upload failed', err?.message);
return h.response(error('UPLOAD_FAILED', 'Failed to upload photo')).code(500);
}
const output = await SocialShareOutput.create({
socialShare: config._id,
event: config.event,
organization: config.organization,
frame: frame._id,
url: uploaded.url,
s3Key: uploaded.s3Key,
mimeType: file.mimeType,
size: file.buffer.length,
originalFileName: file.originalName,
});
try {
const wallShape = frameAsWallShape(config, frame);
const framed = await renderAndUploadFramedPhoto(
{ wall: wallShape, photo: output },
{
activity: PHOTO_ACTIVITIES.socialShare,
kind: PHOTO_KINDS.framed,
},
);
output.framedUrl = framed.framedUrl;
output.framedS3Key = framed.framedS3Key;
await output.save();
} catch (err) {
console.error('[socialShare] frame composite failed', err?.message);
return h.response(error('FRAME_FAILED', 'Could not create framed photo. Try again.')).code(500);
}
const previewUrl =
resolvePhotoThumbUrl(output.framedS3Key || output.framedUrl, { w: 1080, h: 1080 }) ||
resolvePhotoPublicUrl(output.framedUrl) ||
output.framedUrl;
const fileUrl = resolvePhotoPublicUrl(output.framedS3Key || output.framedUrl) || previewUrl;
return h
.response(
success(
{
outputId: String(output._id),
previewUrl,
fileUrl,
framed: true,
},
'Photo created',
),
)
.code(201);
};
const downloadOutputFile = async (request, h) => {
const config = await loadEnabledConfig(request.params.uploadToken);
if (!config) {
return h.response(error('NOT_FOUND', 'Social share not available')).code(404);
}
const { outputId } = request.params;
if (!mongoose.Types.ObjectId.isValid(outputId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid output id')).code(400);
}
const output = await SocialShareOutput.findOne({
_id: outputId,
socialShare: config._id,
}).lean();
if (!output) return h.response(error('NOT_FOUND', 'Photo not found')).code(404);
const s3Key = output.framedS3Key || output.s3Key;
if (!s3Key) return h.response(error('NOT_FOUND', 'Photo file not available')).code(404);
try {
const buf = await getEventPhotoBufferFromS3(s3Key);
const mime = output.framedS3Key ? 'image/jpeg' : output.mimeType || 'image/jpeg';
const filename = safeDownloadFilename(
output.framedS3Key ? 'social-share.jpg' : output.originalFileName || 'social-share.jpg',
);
const asAttachment =
String(request.query?.download || '').toLowerCase() === '1' ||
String(request.query?.download || '').toLowerCase() === 'true';
return h
.response(buf)
.type(mime)
.header('Cache-Control', 'private, max-age=300')
.header(
'Content-Disposition',
`${asAttachment ? 'attachment' : 'inline'}; filename="${filename}"`,
);
} catch (err) {
console.error('[socialShare] file proxy failed', err?.message || err);
return h.response(error('INTERNAL', 'Could not load photo')).code(500);
}
};
module.exports = {
getUploadMeta,
uploadPhoto,
downloadOutputFile,
};

View File

@ -0,0 +1,18 @@
const { success, error } = require('../utils/response');
const { visitorThemeOrDefault } = require('../utils/visitorTheme');
const { resolveVisitorThemeEvent } = require('../utils/resolveVisitorThemeEvent');
const get = async (request, h) => {
const event = await resolveVisitorThemeEvent(request.params.id);
if (!event) {
return h.response(error('NOT_FOUND', 'Theme not found')).code(404);
}
return h.response(
success({
eventId: String(event._id),
theme: visitorThemeOrDefault(event.visitorTheme),
}),
);
};
module.exports = { get };

View File

@ -0,0 +1,317 @@
const mongoose = require('mongoose');
const SocialShare = require('../models/SocialShare');
const SocialShareFrame = require('../models/SocialShareFrame');
const Event = require('../models/Event');
const { success, error } = require('../utils/response');
const { normalizeEventHeaderDisplay } = require('../utils/formPublicHelpers');
const {
normalizeFrameImageLayout,
normalizeFrameLayout,
defaultFrameImageLayout,
} = require('../utils/photoWallFrameLayout');
async function assertEventOrgAccess(eventId, user) {
if (user.role === 'super_admin') return true;
const oid = user.organization?._id || user.organization;
const event = await Event.findById(eventId).select('organization').lean();
if (!event) return false;
return event.organization?.toString() === oid?.toString();
}
function ensureUploadToken(doc) {
if (!doc.uploadToken) {
doc.uploadToken = SocialShare.generateToken();
return true;
}
return false;
}
async function getOrCreateConfig(eventId) {
const event = await Event.findById(eventId).select('organization').lean();
if (!event) return null;
let config = await SocialShare.findOne({ event: eventId });
if (!config) {
config = await SocialShare.create({
event: eventId,
organization: event.organization,
enabled: false,
uploadToken: SocialShare.generateToken(),
});
} else if (ensureUploadToken(config)) {
await config.save();
}
return config;
}
function serializeFrame(frame) {
const cw = frame.frameCanvasWidth ?? 1080;
const ch = frame.frameCanvasHeight ?? 1080;
return {
_id: frame._id,
socialShare: frame.socialShare,
event: frame.event,
name: frame.name || 'Frame',
enabled: frame.enabled !== false,
sortOrder: frame.sortOrder ?? 0,
frameImageUrl: frame.frameImageUrl || null,
frameImageLayout: normalizeFrameImageLayout(
frame.frameImageLayout || defaultFrameImageLayout(),
),
frameCanvasWidth: cw,
frameCanvasHeight: ch,
frameLayout: normalizeFrameLayout(frame.frameLayout, cw, ch),
createdAt: frame.createdAt,
updatedAt: frame.updatedAt,
};
}
function serializeConfig(config, frames = []) {
return {
_id: config._id,
event: config.event,
organization: config.organization,
enabled: Boolean(config.enabled),
accessMode: config.accessMode || 'open',
requireUploadCrop: Boolean(config.requireUploadCrop),
maxPhotoWidth: config.maxPhotoWidth ?? 1080,
maxPhotoHeight: config.maxPhotoHeight ?? 1080,
allowDownload: config.allowDownload !== false,
sharePlatforms: {
linkedin: Boolean(config.sharePlatforms?.linkedin),
facebook: Boolean(config.sharePlatforms?.facebook),
instagram: Boolean(config.sharePlatforms?.instagram),
snapchat: Boolean(config.sharePlatforms?.snapchat),
x: Boolean(config.sharePlatforms?.x),
whatsapp: Boolean(config.sharePlatforms?.whatsapp),
},
eventHeader: normalizeEventHeaderDisplay(config.eventHeader),
uploadToken: config.uploadToken || null,
frames: frames.map(serializeFrame),
frameCount: frames.length,
createdAt: config.createdAt,
updatedAt: config.updatedAt,
};
}
async function loadFrames(socialShareId) {
return SocialShareFrame.find({ socialShare: socialShareId }).sort({ sortOrder: 1, createdAt: 1 });
}
const getSocialShare = 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 assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const config = await getOrCreateConfig(eventId);
if (!config) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const frames = await loadFrames(config._id);
return h.response(success(serializeConfig(config, frames))).code(200);
};
const updateSocialShare = 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 assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const config = await getOrCreateConfig(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.enabled) ensureUploadToken(config);
}
if (p.accessMode === 'open' || p.accessMode === 'login') config.accessMode = p.accessMode;
if (typeof p.requireUploadCrop === 'boolean') config.requireUploadCrop = p.requireUploadCrop;
if (typeof p.allowDownload === 'boolean') config.allowDownload = p.allowDownload;
if (p.maxPhotoWidth != null) {
config.maxPhotoWidth = Math.min(1200, Math.max(80, Math.round(Number(p.maxPhotoWidth) || 1080)));
}
if (p.maxPhotoHeight != null) {
config.maxPhotoHeight = Math.min(1200, Math.max(80, Math.round(Number(p.maxPhotoHeight) || 1080)));
}
if (p.sharePlatforms != null && typeof p.sharePlatforms === 'object') {
const sp = p.sharePlatforms;
config.sharePlatforms = {
linkedin: Boolean(sp.linkedin),
facebook: Boolean(sp.facebook),
instagram: Boolean(sp.instagram),
snapchat: Boolean(sp.snapchat),
x: Boolean(sp.x),
whatsapp: Boolean(sp.whatsapp),
};
}
if (p.eventHeader != null && typeof p.eventHeader === 'object') {
config.eventHeader = normalizeEventHeaderDisplay({
...(config.eventHeader?.toObject?.() || config.eventHeader || {}),
...p.eventHeader,
});
}
if (p.regenerateTokens === true) {
config.uploadToken = SocialShare.generateToken();
}
await config.save();
const frames = await loadFrames(config._id);
return h.response(success(serializeConfig(config, frames), 'Social share updated')).code(200);
};
const ensureTokensHandler = 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 assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const config = await getOrCreateConfig(eventId);
if (!config) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (ensureUploadToken(config)) await config.save();
const frames = await loadFrames(config._id);
return h.response(success(serializeConfig(config, frames))).code(200);
};
const listFrames = 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 assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const config = await getOrCreateConfig(eventId);
if (!config) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const frames = await loadFrames(config._id);
return h.response(success({ frames: frames.map(serializeFrame) })).code(200);
};
const createFrame = 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 assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const config = await getOrCreateConfig(eventId);
if (!config) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
const p = request.payload || {};
const count = await SocialShareFrame.countDocuments({ socialShare: config._id });
const cw = Math.min(4096, Math.max(200, Math.round(Number(p.frameCanvasWidth) || 1080)));
const ch = Math.min(4096, Math.max(200, Math.round(Number(p.frameCanvasHeight) || 1080)));
const frame = await SocialShareFrame.create({
socialShare: config._id,
event: eventId,
name: String(p.name || `Frame ${count + 1}`).trim() || `Frame ${count + 1}`,
enabled: p.enabled !== false,
sortOrder: typeof p.sortOrder === 'number' ? p.sortOrder : count,
frameImageUrl: p.frameImageUrl || null,
frameImageLayout: normalizeFrameImageLayout(p.frameImageLayout || defaultFrameImageLayout()),
frameCanvasWidth: cw,
frameCanvasHeight: ch,
frameLayout: normalizeFrameLayout(p.frameLayout || [], cw, ch),
});
return h.response(success(serializeFrame(frame), 'Frame created')).code(201);
};
const updateFrame = async (request, h) => {
const user = request.auth.credentials?.user;
const { eventId, frameId } = request.params;
if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401);
if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(frameId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400);
}
if (!(await assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const config = await SocialShare.findOne({ event: eventId });
if (!config) return h.response(error('NOT_FOUND', 'Social share not found')).code(404);
const frame = await SocialShareFrame.findOne({ _id: frameId, socialShare: config._id });
if (!frame) return h.response(error('NOT_FOUND', 'Frame not found')).code(404);
const p = request.payload || {};
if (p.name != null) frame.name = String(p.name).trim() || frame.name;
if (typeof p.enabled === 'boolean') frame.enabled = p.enabled;
if (typeof p.sortOrder === 'number') frame.sortOrder = p.sortOrder;
if (p.frameImageUrl !== undefined) {
frame.frameImageUrl = p.frameImageUrl ? String(p.frameImageUrl).trim() : null;
}
if (p.frameCanvasWidth != null) {
frame.frameCanvasWidth = Math.min(4096, Math.max(200, Math.round(Number(p.frameCanvasWidth) || 1080)));
}
if (p.frameCanvasHeight != null) {
frame.frameCanvasHeight = Math.min(4096, Math.max(200, Math.round(Number(p.frameCanvasHeight) || 1080)));
}
const cw = frame.frameCanvasWidth ?? 1080;
const ch = frame.frameCanvasHeight ?? 1080;
if (p.frameImageLayout != null) {
frame.frameImageLayout = normalizeFrameImageLayout(p.frameImageLayout);
}
if (p.frameLayout != null) {
frame.frameLayout = normalizeFrameLayout(p.frameLayout, cw, ch);
}
await frame.save();
return h.response(success(serializeFrame(frame), 'Frame updated')).code(200);
};
const deleteFrame = async (request, h) => {
const user = request.auth.credentials?.user;
const { eventId, frameId } = request.params;
if (!user) return h.response(error('UNAUTHORIZED', 'Not authenticated')).code(401);
if (!mongoose.Types.ObjectId.isValid(eventId) || !mongoose.Types.ObjectId.isValid(frameId)) {
return h.response(error('VALIDATION_ERROR', 'Invalid id')).code(400);
}
if (!(await assertEventOrgAccess(eventId, user))) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const config = await SocialShare.findOne({ event: eventId });
if (!config) return h.response(error('NOT_FOUND', 'Social share not found')).code(404);
const deleted = await SocialShareFrame.findOneAndDelete({ _id: frameId, socialShare: config._id });
if (!deleted) return h.response(error('NOT_FOUND', 'Frame not found')).code(404);
return h.response(success(null, 'Frame deleted')).code(200);
};
module.exports = {
getSocialShare,
updateSocialShare,
ensureTokensHandler,
listFrames,
createFrame,
updateFrame,
deleteFrame,
getOrCreateConfig,
serializeConfig,
serializeFrame,
};

View File

@ -0,0 +1,42 @@
const Event = require('../models/Event');
const { success, error } = require('../utils/response');
const { normalizeVisitorTheme, visitorThemeOrDefault } = require('../utils/visitorTheme');
function assertEventAccess(request, event) {
const user = request.auth.credentials.user;
const orgId = (event.organization && event.organization._id
? event.organization._id
: event.organization
)?.toString();
if (user.role !== 'super_admin' && orgId !== (user.organization?._id || user.organization)?.toString()) {
return false;
}
return true;
}
const get = async (request, h) => {
const event = await Event.findById(request.params.eventId).lean();
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!assertEventAccess(request, event)) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
return h.response(success(visitorThemeOrDefault(event.visitorTheme)));
};
const update = async (request, h) => {
const event = await Event.findById(request.params.eventId);
if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404);
if (!assertEventAccess(request, event)) {
return h.response(error('FORBIDDEN', 'Access denied')).code(403);
}
const next = normalizeVisitorTheme(request.payload || {});
event.set('visitorTheme', next);
if (next.theme !== 'background') event.set('visitorTheme.background', undefined);
if (next.theme !== 'gradient') event.set('visitorTheme.gradient', undefined);
event.markModified('visitorTheme');
await event.save();
const saved = event.toObject ? event.toObject().visitorTheme : event.visitorTheme;
return h.response(success(normalizeVisitorTheme(saved)));
};
module.exports = { get, update };

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

@ -0,0 +1,52 @@
const mongoose = require('mongoose');
const crypto = require('crypto');
const aiAvatarSchema = 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 },
accessMode: {
type: String,
enum: ['open', 'login'],
default: 'open',
},
allowDownload: { type: Boolean, default: true },
sharePlatforms: {
linkedin: { type: Boolean, default: false },
facebook: { type: Boolean, default: false },
instagram: { type: Boolean, default: false },
snapchat: { type: Boolean, default: false },
x: { type: Boolean, default: false },
whatsapp: { type: Boolean, default: false },
},
eventHeader: {
showLogo: { type: Boolean, default: true },
showBanner: { type: Boolean, default: false },
showTitle: { type: Boolean, default: true },
showDateRange: { type: Boolean, default: true },
showEventType: { type: Boolean, default: true },
showLocationType: { type: Boolean, default: true },
showVenue: { type: Boolean, default: true },
},
uploadToken: { type: String, trim: true },
},
{ timestamps: true },
);
aiAvatarSchema.index({ event: 1 }, { unique: true });
aiAvatarSchema.index({ uploadToken: 1 }, { unique: true, sparse: true });
aiAvatarSchema.statics.generateToken = function generateToken() {
return crypto.randomBytes(32).toString('hex');
};
module.exports = mongoose.model('AiAvatar', aiAvatarSchema);

View File

@ -0,0 +1,41 @@
const mongoose = require('mongoose');
/** Minimal record for public file proxy — not listed in admin UI. */
const aiAvatarOutputSchema = new mongoose.Schema(
{
aiAvatar: {
type: mongoose.Schema.Types.ObjectId,
ref: 'AiAvatar',
required: true,
},
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
},
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
},
template: {
type: mongoose.Schema.Types.ObjectId,
ref: 'AiAvatarTemplate',
required: true,
},
/** Visitor selfie */
url: { type: String, required: true },
s3Key: { type: String, required: true },
/** WaveSpeed result */
resultUrl: { type: String, default: null },
resultS3Key: { type: String, default: null },
mimeType: { type: String },
size: { type: Number },
originalFileName: { type: String },
},
{ timestamps: true },
);
aiAvatarOutputSchema.index({ aiAvatar: 1, createdAt: -1 });
module.exports = mongoose.model('AiAvatarOutput', aiAvatarOutputSchema);

View File

@ -0,0 +1,27 @@
const mongoose = require('mongoose');
const aiAvatarTemplateSchema = new mongoose.Schema(
{
aiAvatar: {
type: mongoose.Schema.Types.ObjectId,
ref: 'AiAvatar',
required: true,
},
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
},
name: { type: String, trim: true, default: 'Avatar' },
enabled: { type: Boolean, default: true },
sortOrder: { type: Number, default: 0 },
/** Template body image for WaveSpeed head swap (`image`) */
imageUrl: { type: String, trim: true, default: null },
},
{ timestamps: true },
);
aiAvatarTemplateSchema.index({ aiAvatar: 1, sortOrder: 1 });
aiAvatarTemplateSchema.index({ event: 1 });
module.exports = mongoose.model('AiAvatarTemplate', aiAvatarTemplateSchema);

View File

@ -94,6 +94,54 @@ const conciergeBotSchema = new mongoose.Schema(
lastKnowledgeClearedAt: Date, lastKnowledgeClearedAt: Date,
lastPurgedAt: Date, lastPurgedAt: Date,
/** Reception-desk WhatsApp check-in (credentials come from Organisation WhatsApp settings). */
reception: {
enabled: { type: Boolean, default: false },
prefillText: { type: String, trim: true, default: 'Hi' },
welcomeMessage: {
type: String,
default: 'Hi {firstName}! Welcome to {eventName}.',
},
unmatchedBadgePrompt: {
type: String,
default:
"We couldn't match your WhatsApp to a registration. Please send a clear photo of your badge QR code.",
},
staffHandoffMessage: {
type: String,
default:
"We still couldn't identify you. Please see the reception desk for help. You can also send another badge photo.",
},
dripAudience: {
type: String,
enum: ['matched', 'all_inbound'],
default: 'matched',
},
drips: [
{
trigger: {
type: String,
enum: ['after_first_inbound', 'after_match', 'absolute'],
default: 'after_first_inbound',
},
delayMinutes: { type: Number, default: 0 },
sendAt: Date,
body: { type: String, default: '' },
mediaType: {
type: String,
enum: ['none', 'image', 'video'],
default: 'none',
},
mediaUrl: { type: String, default: '', trim: true },
templateId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'WhatsAppTemplate',
default: null,
},
},
],
},
}, },
{ timestamps: true }, { timestamps: true },
); );
@ -101,5 +149,13 @@ const conciergeBotSchema = new mongoose.Schema(
conciergeBotSchema.index({ organization: 1, event: 1 }, { unique: true }); conciergeBotSchema.index({ organization: 1, event: 1 }, { unique: true });
conciergeBotSchema.index({ wabaPhoneNumberId: 1 }, { unique: true }); conciergeBotSchema.index({ wabaPhoneNumberId: 1 }, { unique: true });
conciergeBotSchema.index({ organization: 1, status: 1 }); conciergeBotSchema.index({ organization: 1, status: 1 });
conciergeBotSchema.index(
{ organization: 1 },
{
unique: true,
name: 'organization_1_reception_enabled',
partialFilterExpression: { 'reception.enabled': true },
},
);
module.exports = mongoose.model('ConciergeBot', conciergeBotSchema); module.exports = mongoose.model('ConciergeBot', conciergeBotSchema);

View File

@ -126,6 +126,32 @@ const eventSchema = new mongoose.Schema(
/** GrapesJS / visual editor main markup (injected at publish when set; canonical store for custom HTML). */ /** GrapesJS / visual editor main markup (injected at publish when set; canonical store for custom HTML). */
micrositeHtml: String, micrositeHtml: String,
}, },
/**
* Visitor-app theme (event / session / quiz / photo pages).
* theme: simple | gradient | background. All color fields stored as rgba().
*/
visitorTheme: {
theme: { type: String, trim: true },
color: { type: String, trim: true },
background: {
url: { type: String, trim: true },
size: { type: String, trim: true },
repeat: { type: String, trim: true },
},
gradient: {
color1: { type: String, trim: true },
color2: { type: String, trim: true },
},
font: { type: String, trim: true },
primaryColor: { type: String, trim: true },
secondaryColor: { type: String, trim: true },
headerColor: { type: String, trim: true },
buttonColor: { type: String, trim: true },
textColor: { type: String, trim: true },
textHighlightColor: { type: String, trim: true },
cardColor: { type: String, trim: true },
cardTextColor: { type: String, trim: true },
},
/** /**
* UPI / manual proof checkout (alongside or instead of org Razorpay). Scoped to this event. * UPI / manual proof checkout (alongside or instead of org Razorpay). Scoped to this event.
* Buyer totals are ticket + tax only; Eventbuzz is covered by ticketingManualPayment enablement (billing). * Buyer totals are ticket + tax only; Eventbuzz is covered by ticketingManualPayment enablement (billing).

View File

@ -0,0 +1,56 @@
const mongoose = require('mongoose');
const crypto = require('crypto');
const eventPhotoSearchSchema = 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 },
/** open = anyone with link; login = requires visitor JWT */
accessMode: {
type: String,
enum: ['open', 'login'],
default: 'open',
},
/** Allow visitor to download matched photos */
allowDownload: { type: Boolean, default: true },
/** Social share buttons on visitor match results */
sharePlatforms: {
linkedin: { type: Boolean, default: false },
facebook: { type: Boolean, default: false },
instagram: { type: Boolean, default: false },
snapchat: { type: Boolean, default: false },
x: { type: Boolean, default: false },
whatsapp: { type: Boolean, default: false },
},
/** Visitor page event header visibility (same shape as Form.settings.eventHeader). */
eventHeader: {
showLogo: { type: Boolean, default: true },
showBanner: { type: Boolean, default: false },
showTitle: { type: Boolean, default: true },
showDateRange: { type: Boolean, default: true },
showEventType: { type: Boolean, default: true },
showLocationType: { type: Boolean, default: true },
showVenue: { type: Boolean, default: true },
},
searchToken: { type: String, trim: true },
},
{ timestamps: true },
);
eventPhotoSearchSchema.index({ event: 1 }, { unique: true });
eventPhotoSearchSchema.index({ searchToken: 1 }, { unique: true, sparse: true });
eventPhotoSearchSchema.statics.generateToken = function generateToken() {
return crypto.randomBytes(32).toString('hex');
};
module.exports = mongoose.model('EventPhotoSearch', eventPhotoSearchSchema);

55
src/models/SocialShare.js Normal file
View File

@ -0,0 +1,55 @@
const mongoose = require('mongoose');
const crypto = require('crypto');
const socialShareSchema = 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 },
accessMode: {
type: String,
enum: ['open', 'login'],
default: 'open',
},
requireUploadCrop: { type: Boolean, default: false },
maxPhotoWidth: { type: Number, default: 1080, min: 80, max: 1200 },
maxPhotoHeight: { type: Number, default: 1080, min: 80, max: 1200 },
allowDownload: { type: Boolean, default: true },
sharePlatforms: {
linkedin: { type: Boolean, default: false },
facebook: { type: Boolean, default: false },
instagram: { type: Boolean, default: false },
snapchat: { type: Boolean, default: false },
x: { type: Boolean, default: false },
whatsapp: { type: Boolean, default: false },
},
eventHeader: {
showLogo: { type: Boolean, default: true },
showBanner: { type: Boolean, default: false },
showTitle: { type: Boolean, default: true },
showDateRange: { type: Boolean, default: true },
showEventType: { type: Boolean, default: true },
showLocationType: { type: Boolean, default: true },
showVenue: { type: Boolean, default: true },
},
uploadToken: { type: String, trim: true },
},
{ timestamps: true },
);
socialShareSchema.index({ event: 1 }, { unique: true });
socialShareSchema.index({ uploadToken: 1 }, { unique: true, sparse: true });
socialShareSchema.statics.generateToken = function generateToken() {
return crypto.randomBytes(32).toString('hex');
};
module.exports = mongoose.model('SocialShare', socialShareSchema);

View File

@ -0,0 +1,35 @@
const mongoose = require('mongoose');
const socialShareFrameSchema = new mongoose.Schema(
{
socialShare: {
type: mongoose.Schema.Types.ObjectId,
ref: 'SocialShare',
required: true,
},
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
},
name: { type: String, trim: true, default: 'Frame' },
enabled: { type: Boolean, default: true },
sortOrder: { type: Number, default: 0 },
frameImageUrl: { type: String, trim: true, default: null },
frameImageLayout: {
x: { type: Number, default: 0 },
y: { type: Number, default: 0 },
w: { type: Number, default: 100 },
h: { type: Number, default: 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: [] },
},
{ timestamps: true },
);
socialShareFrameSchema.index({ socialShare: 1, sortOrder: 1 });
socialShareFrameSchema.index({ event: 1 });
module.exports = mongoose.model('SocialShareFrame', socialShareFrameSchema);

View File

@ -0,0 +1,39 @@
const mongoose = require('mongoose');
/** Minimal record for public file proxy — not listed in admin UI. */
const socialShareOutputSchema = new mongoose.Schema(
{
socialShare: {
type: mongoose.Schema.Types.ObjectId,
ref: 'SocialShare',
required: true,
},
event: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Event',
required: true,
},
organization: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Organization',
required: true,
},
frame: {
type: mongoose.Schema.Types.ObjectId,
ref: 'SocialShareFrame',
required: true,
},
url: { type: String, required: true },
s3Key: { type: String, required: true },
framedUrl: { type: String, default: null },
framedS3Key: { type: String, default: null },
mimeType: { type: String },
size: { type: Number },
originalFileName: { type: String },
},
{ timestamps: true },
);
socialShareOutputSchema.index({ socialShare: 1, createdAt: -1 });
module.exports = mongoose.model('SocialShareOutput', socialShareOutputSchema);

View File

@ -0,0 +1,100 @@
const Joi = require('joi');
const controller = require('../controllers/aiAvatar.controller');
const eventHeaderSchema = Joi.object({
showLogo: Joi.boolean(),
showBanner: Joi.boolean(),
showTitle: Joi.boolean(),
showDateRange: Joi.boolean(),
showEventType: Joi.boolean(),
showLocationType: Joi.boolean(),
showVenue: Joi.boolean(),
});
const updatePayload = Joi.object({
enabled: Joi.boolean(),
accessMode: Joi.string().valid('open', 'login'),
allowDownload: Joi.boolean(),
sharePlatforms: Joi.object({
linkedin: Joi.boolean(),
facebook: Joi.boolean(),
instagram: Joi.boolean(),
snapchat: Joi.boolean(),
x: Joi.boolean(),
whatsapp: Joi.boolean(),
}),
eventHeader: eventHeaderSchema,
regenerateTokens: Joi.boolean(),
}).min(1);
const avatarFields = {
name: Joi.string().allow('').max(120),
enabled: Joi.boolean(),
sortOrder: Joi.number().integer().min(0).max(9999),
imageUrl: Joi.alternatives().try(Joi.string().allow('').max(2000), Joi.valid(null)),
};
const avatarPayload = Joi.object(avatarFields).min(1);
const createAvatarPayload = Joi.object(avatarFields).default({});
module.exports = [
{
method: 'GET',
path: '/api/events/{eventId}/ai-avatar',
options: {
auth: 'jwt',
handler: controller.getAiAvatar,
},
},
{
method: 'PATCH',
path: '/api/events/{eventId}/ai-avatar',
options: {
auth: 'jwt',
validate: { payload: updatePayload },
handler: controller.updateAiAvatar,
},
},
{
method: 'POST',
path: '/api/events/{eventId}/ai-avatar/ensure-tokens',
options: {
auth: 'jwt',
handler: controller.ensureTokensHandler,
},
},
{
method: 'GET',
path: '/api/events/{eventId}/ai-avatar/avatars',
options: {
auth: 'jwt',
handler: controller.listAvatars,
},
},
{
method: 'POST',
path: '/api/events/{eventId}/ai-avatar/avatars',
options: {
auth: 'jwt',
validate: { payload: createAvatarPayload },
handler: controller.createAvatar,
},
},
{
method: 'PATCH',
path: '/api/events/{eventId}/ai-avatar/avatars/{avatarId}',
options: {
auth: 'jwt',
validate: { payload: avatarPayload },
handler: controller.updateAvatar,
},
},
{
method: 'DELETE',
path: '/api/events/{eventId}/ai-avatar/avatars/{avatarId}',
options: {
auth: 'jwt',
handler: controller.deleteAvatar,
},
},
];

View File

@ -79,15 +79,37 @@ const createPayload = Joi.object({
enabled: Joi.boolean(), enabled: Joi.boolean(),
}); });
const receptionDripSchema = Joi.object({
trigger: Joi.string().valid('after_first_inbound', 'after_match', 'absolute'),
delayMinutes: Joi.number().min(0).allow(null),
sendAt: Joi.date().iso().allow(null, ''),
body: Joi.string().allow(''),
mediaType: Joi.string().valid('none', 'image', 'video'),
mediaUrl: Joi.string().allow(''),
templateId: Joi.string().allow('', null),
});
const receptionSchema = Joi.object({
enabled: Joi.boolean(),
prefillText: Joi.string().allow(''),
welcomeMessage: Joi.string().allow(''),
unmatchedBadgePrompt: Joi.string().allow(''),
staffHandoffMessage: Joi.string().allow(''),
dripAudience: Joi.string().valid('matched', 'all_inbound'),
drips: Joi.array().items(receptionDripSchema),
});
const updatePayload = Joi.object({ const updatePayload = Joi.object({
name: Joi.string().allow(''), name: Joi.string().allow(''),
wabaPhoneNumberId: Joi.string(), wabaPhoneNumberId: Joi.string(),
eventId: Joi.string(),
accentColor: accentColorSchema, accentColor: accentColorSchema,
greeting: greetingSchema, greeting: greetingSchema,
registration: registrationSchema, registration: registrationSchema,
knowledge: knowledgeSchema, knowledge: knowledgeSchema,
faqs: Joi.array().items(faqItemSchema), faqs: Joi.array().items(faqItemSchema),
enabled: Joi.boolean(), enabled: Joi.boolean(),
reception: receptionSchema,
}).min(1); }).min(1);
const sourcePayload = Joi.object({ const sourcePayload = Joi.object({
@ -108,6 +130,16 @@ module.exports = [
}, },
{ method: 'POST', path: '/api/concierge-bots', options: { validate: { payload: createPayload }, handler: controller.create } }, { method: 'POST', path: '/api/concierge-bots', options: { validate: { payload: createPayload }, handler: controller.create } },
{ method: 'GET', path: '/api/concierge-bots/{botId}', options: { handler: controller.get } }, { method: 'GET', path: '/api/concierge-bots/{botId}', options: { handler: controller.get } },
{
method: 'GET',
path: '/api/concierge-bots/{botId}/reception',
options: { handler: controller.getReception },
},
{
method: 'GET',
path: '/api/concierge-bots/{botId}/reception-qr.png',
options: { handler: controller.getReceptionQrPng },
},
{ method: 'PUT', path: '/api/concierge-bots/{botId}', options: { validate: { payload: updatePayload }, handler: controller.update } }, { method: 'PUT', path: '/api/concierge-bots/{botId}', options: { validate: { payload: updatePayload }, handler: controller.update } },
{ method: 'DELETE', path: '/api/concierge-bots/{botId}', options: { handler: controller.remove } }, { method: 'DELETE', path: '/api/concierge-bots/{botId}', options: { handler: controller.remove } },
{ method: 'GET', path: '/api/events/{eventId}/concierge-bot', options: { handler: controller.getByEvent } }, { method: 'GET', path: '/api/events/{eventId}/concierge-bot', options: { handler: controller.getByEvent } },

View File

@ -0,0 +1,56 @@
const Joi = require('joi');
const controller = require('../controllers/eventPhotoSearch.controller');
const eventHeaderSchema = Joi.object({
showLogo: Joi.boolean(),
showBanner: Joi.boolean(),
showTitle: Joi.boolean(),
showDateRange: Joi.boolean(),
showEventType: Joi.boolean(),
showLocationType: Joi.boolean(),
showVenue: Joi.boolean(),
});
const updatePayload = Joi.object({
enabled: Joi.boolean(),
accessMode: Joi.string().valid('open', 'login'),
allowDownload: Joi.boolean(),
sharePlatforms: Joi.object({
linkedin: Joi.boolean(),
facebook: Joi.boolean(),
instagram: Joi.boolean(),
snapchat: Joi.boolean(),
x: Joi.boolean(),
whatsapp: Joi.boolean(),
}),
eventHeader: eventHeaderSchema,
regenerateTokens: Joi.boolean(),
}).min(1);
module.exports = [
{
method: 'GET',
path: '/api/events/{eventId}/photo-search',
options: {
auth: 'jwt',
handler: controller.getEventPhotoSearch,
},
},
{
method: 'PATCH',
path: '/api/events/{eventId}/photo-search',
options: {
auth: 'jwt',
validate: { payload: updatePayload },
handler: controller.updateEventPhotoSearch,
},
},
{
method: 'POST',
path: '/api/events/{eventId}/photo-search/ensure-tokens',
options: {
auth: 'jwt',
handler: controller.ensureTokensHandler,
},
},
];

View File

@ -0,0 +1,38 @@
const controller = require('../controllers/publicAiAvatar.controller');
module.exports = [
{
method: 'GET',
path: '/api/public/ai-avatar/{uploadToken}',
options: {
auth: false,
handler: controller.getUploadMeta,
},
},
{
method: 'POST',
path: '/api/public/ai-avatar/{uploadToken}',
options: {
auth: { mode: 'optional', strategy: 'jwt-attendee' },
// WaveSpeed can take a while — allow long request
timeout: { server: 180000, socket: false },
payload: {
maxBytes: 20 * 1024 * 1024,
output: 'stream',
parse: true,
multipart: true,
allow: 'multipart/form-data',
timeout: false,
},
handler: controller.uploadPhoto,
},
},
{
method: 'GET',
path: '/api/public/ai-avatar/{uploadToken}/outputs/{outputId}/file',
options: {
auth: false,
handler: controller.downloadOutputFile,
},
},
];

View File

@ -0,0 +1,35 @@
const controller = require('../controllers/publicEventPhotoSearch.controller');
module.exports = [
{
method: 'GET',
path: '/api/public/photo-search/{searchToken}',
options: {
auth: false,
handler: controller.getSearchMeta,
},
},
{
method: 'POST',
path: '/api/public/photo-search/{searchToken}',
options: {
auth: { mode: 'optional', strategy: 'jwt-attendee' },
payload: {
maxBytes: 20 * 1024 * 1024,
output: 'stream',
parse: true,
multipart: true,
allow: 'multipart/form-data',
},
handler: controller.searchByToken,
},
},
{
method: 'GET',
path: '/api/public/photo-search/{searchToken}/photos/{photoId}/file',
options: {
auth: false,
handler: controller.downloadMatchFile,
},
},
];

View File

@ -0,0 +1,37 @@
const controller = require('../controllers/publicPhotoIngest.controller');
module.exports = [
{
method: 'GET',
path: '/api/public/photo-ingest/{joinCode}',
options: {
auth: false,
handler: controller.getMeta,
},
},
{
method: 'GET',
path: '/api/public/photo-ingest/{joinCode}/uploaded-names',
options: {
auth: false,
handler: controller.listUploadedNames,
},
},
{
method: 'POST',
path: '/api/public/photo-ingest/{joinCode}',
options: {
auth: false,
timeout: { server: 120000, socket: false },
payload: {
maxBytes: 20 * 1024 * 1024,
output: 'stream',
parse: true,
multipart: true,
allow: 'multipart/form-data',
timeout: false,
},
handler: controller.uploadPhotos,
},
},
];

View File

@ -0,0 +1,35 @@
const controller = require('../controllers/publicSocialShare.controller');
module.exports = [
{
method: 'GET',
path: '/api/public/social-share/{uploadToken}',
options: {
auth: false,
handler: controller.getUploadMeta,
},
},
{
method: 'POST',
path: '/api/public/social-share/{uploadToken}',
options: {
auth: { mode: 'optional', strategy: 'jwt-attendee' },
payload: {
maxBytes: 20 * 1024 * 1024,
output: 'stream',
parse: true,
multipart: true,
allow: 'multipart/form-data',
},
handler: controller.uploadPhoto,
},
},
{
method: 'GET',
path: '/api/public/social-share/{uploadToken}/outputs/{outputId}/file',
options: {
auth: false,
handler: controller.downloadOutputFile,
},
},
];

View File

@ -0,0 +1,18 @@
const Joi = require('joi');
const controller = require('../controllers/publicVisitorTheme.controller');
module.exports = [
{
method: 'GET',
path: '/api/public/visitor-theme/{id}',
options: {
auth: false,
validate: {
params: Joi.object({
id: Joi.string().hex().min(24).max(64).required(),
}),
},
handler: controller.get,
},
},
];

View File

@ -0,0 +1,141 @@
const Joi = require('joi');
const controller = require('../controllers/socialShare.controller');
const eventHeaderSchema = Joi.object({
showLogo: Joi.boolean(),
showBanner: Joi.boolean(),
showTitle: Joi.boolean(),
showDateRange: Joi.boolean(),
showEventType: Joi.boolean(),
showLocationType: Joi.boolean(),
showVenue: Joi.boolean(),
});
const frameImageLayoutSchema = Joi.object({
x: Joi.number().min(-400).max(400),
y: Joi.number().min(-400).max(400),
w: Joi.number().min(10).max(500),
h: Joi.number().min(10).max(500),
});
const frameLayoutElementSchema = Joi.object({
id: Joi.string().required(),
kind: Joi.string().valid('field', 'custom_text', 'custom_image').required(),
field: Joi.string().valid('photo', 'name', 'form').optional(),
fieldKey: Joi.string().allow('').max(120).optional(),
text: Joi.string().allow('').max(500).optional(),
imageUrl: Joi.string().allow('').max(2000).optional(),
imageAspectRatio: Joi.number().positive().optional(),
sizeUnit: Joi.string().valid('px', 'percent').optional(),
x: Joi.number().min(0).max(4096).required(),
y: Joi.number().min(0).max(4096).required(),
w: Joi.number().min(1).max(4096).optional(),
h: Joi.number().min(1).max(4096).optional(),
fontSizePx: Joi.number().min(8).max(400).optional(),
fontSizePct: Joi.number().min(1).max(40).optional(),
photoShape: Joi.string().valid('rect', 'rounded', 'circle').optional(),
borderRadiusPx: Joi.number().min(0).max(512).optional(),
fontFamily: Joi.string().allow('').max(80).optional(),
color: Joi.string().allow('').max(40).optional(),
fontWeight: Joi.string().valid('normal', 'bold').optional(),
textAlign: Joi.string().valid('left', 'center', 'right').optional(),
fullWidthCenter: Joi.boolean().optional(),
shrinkToFit: Joi.boolean().optional(),
prefix: Joi.string().allow('').max(80).optional(),
postfix: Joi.string().allow('').max(80).optional(),
}).unknown(true);
const updatePayload = Joi.object({
enabled: Joi.boolean(),
accessMode: Joi.string().valid('open', 'login'),
requireUploadCrop: Joi.boolean(),
maxPhotoWidth: Joi.number().integer().min(80).max(1200),
maxPhotoHeight: Joi.number().integer().min(80).max(1200),
allowDownload: Joi.boolean(),
sharePlatforms: Joi.object({
linkedin: Joi.boolean(),
facebook: Joi.boolean(),
instagram: Joi.boolean(),
snapchat: Joi.boolean(),
x: Joi.boolean(),
whatsapp: Joi.boolean(),
}),
eventHeader: eventHeaderSchema,
regenerateTokens: Joi.boolean(),
}).min(1);
const frameFields = {
name: Joi.string().allow('').max(120),
enabled: Joi.boolean(),
sortOrder: Joi.number().integer().min(0).max(9999),
frameImageUrl: Joi.alternatives().try(Joi.string().allow('').max(2000), Joi.valid(null)),
frameImageLayout: frameImageLayoutSchema,
frameCanvasWidth: Joi.number().integer().min(200).max(4096),
frameCanvasHeight: Joi.number().integer().min(200).max(4096),
frameLayout: Joi.array().items(frameLayoutElementSchema).max(40),
};
const framePayload = Joi.object(frameFields).min(1);
const createFramePayload = Joi.object(frameFields).default({});
module.exports = [
{
method: 'GET',
path: '/api/events/{eventId}/social-share',
options: {
auth: 'jwt',
handler: controller.getSocialShare,
},
},
{
method: 'PATCH',
path: '/api/events/{eventId}/social-share',
options: {
auth: 'jwt',
validate: { payload: updatePayload },
handler: controller.updateSocialShare,
},
},
{
method: 'POST',
path: '/api/events/{eventId}/social-share/ensure-tokens',
options: {
auth: 'jwt',
handler: controller.ensureTokensHandler,
},
},
{
method: 'GET',
path: '/api/events/{eventId}/social-share/frames',
options: {
auth: 'jwt',
handler: controller.listFrames,
},
},
{
method: 'POST',
path: '/api/events/{eventId}/social-share/frames',
options: {
auth: 'jwt',
validate: { payload: createFramePayload },
handler: controller.createFrame,
},
},
{
method: 'PATCH',
path: '/api/events/{eventId}/social-share/frames/{frameId}',
options: {
auth: 'jwt',
validate: { payload: framePayload },
handler: controller.updateFrame,
},
},
{
method: 'DELETE',
path: '/api/events/{eventId}/social-share/frames/{frameId}',
options: {
auth: 'jwt',
handler: controller.deleteFrame,
},
},
];

View File

@ -0,0 +1,51 @@
const Joi = require('joi');
const controller = require('../controllers/visitorTheme.controller');
const mongoId = Joi.string().length(24).hex().required();
const color = Joi.string().max(64).allow('', null);
const requiredColor = Joi.string().max(64).required();
const visitorThemePayload = Joi.object({
theme: Joi.string().valid('simple', 'gradient', 'background').required(),
color: requiredColor,
font: Joi.string().max(64).allow('', null),
primaryColor: color,
secondaryColor: color,
headerColor: color,
buttonColor: color,
textColor: color,
textHighlightColor: color,
cardColor: color,
cardTextColor: color,
background: Joi.object({
url: Joi.string().max(2048).allow('', null),
size: Joi.string().valid('cover', 'contain', 'auto').allow('', null),
repeat: Joi.string().valid('no', 'repeat', 'repeat-x', 'repeat-y').allow('', null),
}).allow(null),
gradient: Joi.object({
color1: color,
color2: color,
}).allow(null),
});
module.exports = [
{
method: 'GET',
path: '/api/events/{eventId}/visitor-theme',
options: {
validate: { params: Joi.object({ eventId: mongoId }) },
handler: controller.get,
},
},
{
method: 'PUT',
path: '/api/events/{eventId}/visitor-theme',
options: {
validate: {
params: Joi.object({ eventId: mongoId }),
payload: visitorThemePayload,
},
handler: controller.update,
},
},
];

View File

@ -0,0 +1,115 @@
/**
* WaveSpeed AI image-head-swap client.
* Requires WAVESPEED_API_KEY in the environment.
*/
const https = require('https');
const http = require('http');
const SUBMIT_URL = 'https://api.wavespeed.ai/api/v3/wavespeed-ai/image-head-swap';
const POLL_MS = 2000;
const MAX_POLLS = 90; // ~3 minutes
function getApiKey() {
const key = String(process.env.WAVESPEED_API_KEY || '').trim();
if (!key) throw new Error('WAVESPEED_API_KEY is not configured');
return key;
}
async function requestJson(url, options = {}) {
const response = await fetch(url, options);
if (!response.ok) {
const text = await response.text();
throw new Error(text || `WaveSpeed HTTP ${response.status}`);
}
return response.json();
}
function fetchUrlBuffer(url) {
return new Promise((resolve, reject) => {
const lib = String(url).startsWith('https') ? https : http;
const req = lib.get(url, { timeout: 60000 }, (res) => {
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
fetchUrlBuffer(res.headers.location).then(resolve, reject);
return;
}
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode} downloading WaveSpeed output`));
res.resume();
return;
}
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve(Buffer.concat(chunks)));
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('WaveSpeed output download timeout'));
});
});
}
/**
* Run head swap: avatar template (`image`) + visitor face (`face_image`).
* @returns {{ outputUrl: string, buffer: Buffer }}
*/
async function runImageHeadSwap({ imageUrl, faceImageUrl }) {
const apiKey = getApiKey();
const headers = {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
};
const submitBody = await requestJson(SUBMIT_URL, {
method: 'POST',
headers,
body: JSON.stringify({
image: imageUrl,
face_image: faceImageUrl,
output_format: 'jpeg',
}),
});
const task = submitBody.data ?? submitBody;
const predictionId = task.id;
if (!predictionId) {
throw new Error('WaveSpeed submission did not return a prediction id');
}
const resultUrl =
task.urls?.get || `https://api.wavespeed.ai/api/v3/predictions/${predictionId}/result`;
for (let i = 0; i < MAX_POLLS; i += 1) {
const body = await requestJson(resultUrl, {
headers: { Authorization: `Bearer ${apiKey}` },
});
const result = body.data ?? body;
const status = String(result.status || '');
if (status === 'completed') {
const outputs = result.outputs;
const outputUrl = Array.isArray(outputs) ? outputs[0] : outputs;
if (!outputUrl || typeof outputUrl !== 'string') {
throw new Error('WaveSpeed completed without an output URL');
}
const buffer = await fetchUrlBuffer(outputUrl);
if (!buffer?.length) throw new Error('WaveSpeed output download empty');
return { outputUrl, buffer };
}
if (['failed', 'cancelled', 'timeout'].includes(status)) {
throw new Error(`WaveSpeed ${status}: ${JSON.stringify(result.error || result)}`);
}
if (!['created', 'processing'].includes(status)) {
throw new Error(`Unexpected WaveSpeed status: ${status}`);
}
await new Promise((resolve) => setTimeout(resolve, POLL_MS));
}
throw new Error('WaveSpeed head swap timed out');
}
module.exports = {
runImageHeadSwap,
};

View File

@ -2,6 +2,7 @@
const CONCIERGE_GREETING_DEFAULTS = { const CONCIERGE_GREETING_DEFAULTS = {
profileName: 'Visitor', profileName: 'Visitor',
eventName: 'Event', eventName: 'Event',
firstName: 'there',
}; };
/** /**
@ -10,7 +11,7 @@ const CONCIERGE_GREETING_DEFAULTS = {
* When the template is empty, returns a sensible default greeting. * When the template is empty, returns a sensible default greeting.
* *
* @param {string} template * @param {string} template
* @param {{ profileName?: string, eventName?: string }} [values] * @param {{ profileName?: string, eventName?: string, firstName?: string }} [values]
* @returns {string} * @returns {string}
*/ */
function interpolateConciergeGreeting(template, values = {}) { function interpolateConciergeGreeting(template, values = {}) {
@ -28,8 +29,15 @@ function interpolateConciergeGreeting(template, values = {}) {
} }
let out = greeting; let out = greeting;
const firstFromProfile =
values.firstName != null && String(values.firstName).trim() !== ''
? String(values.firstName).trim()
: values.profileName != null && String(values.profileName).trim() !== ''
? String(values.profileName).trim().split(/\s+/)[0]
: '';
const merged = { ...values, firstName: firstFromProfile };
for (const [key, fallback] of Object.entries(CONCIERGE_GREETING_DEFAULTS)) { for (const [key, fallback] of Object.entries(CONCIERGE_GREETING_DEFAULTS)) {
const raw = values[key]; const raw = merged[key];
const val = raw != null && String(raw).trim() !== '' ? String(raw).trim() : fallback; const val = raw != null && String(raw).trim() !== '' ? String(raw).trim() : fallback;
out = out.replace(new RegExp(`\\{${key}\\}`, 'gi'), val); out = out.replace(new RegExp(`\\{${key}\\}`, 'gi'), val);
} }

View File

@ -599,6 +599,54 @@ async function sendTextMessage(phoneNumberId, accessToken, toDigits, text) {
}); });
} }
async function sendImageMessage(phoneNumberId, accessToken, toDigits, imageUrl, caption) {
const image = { link: String(imageUrl || '').trim() };
const cap = String(caption || '').trim();
if (cap) image.caption = cap.slice(0, 1024);
return postWhatsAppMessage(phoneNumberId, accessToken, toDigits, {
type: 'image',
image,
});
}
async function sendVideoMessage(phoneNumberId, accessToken, toDigits, videoUrl, caption) {
const video = { link: String(videoUrl || '').trim() };
const cap = String(caption || '').trim();
if (cap) video.caption = cap.slice(0, 1024);
return postWhatsAppMessage(phoneNumberId, accessToken, toDigits, {
type: 'video',
video,
});
}
/**
* Download inbound WhatsApp media bytes (image/video) using Graph media id.
* @returns {Promise<{ buffer: Buffer, mimeType: string } | null>}
*/
async function downloadWhatsAppMedia(mediaId, accessToken) {
const id = String(mediaId || '').trim();
if (!id || !accessToken) return null;
const metaUrl = `${graphBaseUrl()}/${encodeURIComponent(id)}`;
const metaRes = await fetch(metaUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const meta = await metaRes.json().catch(() => ({}));
logMetaGraphExchange('GET /{media-id}', {
method: 'GET',
url: metaUrl,
headers: { Authorization: 'Bearer ***' },
}, { httpStatus: metaRes.status, ok: metaRes.ok, body: { url: meta.url ? '[redacted]' : undefined, mime_type: meta.mime_type } });
const fileUrl = String(meta.url || '').trim();
if (!metaRes.ok || !fileUrl) return null;
const binRes = await fetch(fileUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!binRes.ok) return null;
const buf = Buffer.from(await binRes.arrayBuffer());
const mimeType = String(meta.mime_type || binRes.headers.get('content-type') || 'application/octet-stream');
return { buffer: buf, mimeType };
}
/** /**
* @param {string} phoneNumberId * @param {string} phoneNumberId
* @param {string} accessToken * @param {string} accessToken
@ -654,7 +702,10 @@ module.exports = {
parseWhatsAppCloudMessagesSendResponse, parseWhatsAppCloudMessagesSendResponse,
sendTemplateMessage, sendTemplateMessage,
sendTextMessage, sendTextMessage,
sendImageMessage,
sendVideoMessage,
sendInteractiveButtons, sendInteractiveButtons,
postWhatsAppMessage, postWhatsAppMessage,
downloadWhatsAppMedia,
metaDebugEnabled, metaDebugEnabled,
}; };

View File

@ -0,0 +1,183 @@
const { extractPhoneLast10Keys, phoneLast10Digits } = require('./phone');
const NAME_AUTO_MIN = 0.88;
const NAME_AUTO_GAP = 0.15;
const HONORIFICS = new Set(['mr', 'mrs', 'ms', 'miss', 'dr', 'prof', 'sir', 'md', 'adv']);
function membershipForEvent(participant, eventId) {
const eid = String(eventId);
const rows = Array.isArray(participant?.eventMemberships) ? participant.eventMemberships : [];
return rows.find((m) => String(m.event) === eid) || null;
}
function membershipAllowed(m) {
if (!m) return false;
const status = String(m.status || '').toLowerCase();
return status !== 'cancelled' && status !== 'rejected';
}
function participantFullName(p) {
const display = String(p.displayName || '').trim();
const parts = [p.firstName, p.lastName].filter((x) => String(x || '').trim()).join(' ').trim();
return display || parts || '';
}
function normalizeNameTokens(raw) {
const lower = String(raw || '')
.toLowerCase()
.replace(/[.'’`,]/g, ' ')
.replace(/[^a-z\s]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!lower) return [];
return lower.split(' ').filter((t) => t && !HONORIFICS.has(t));
}
function isInitialToken(t) {
return Boolean(t) && t.length === 1;
}
function tokenCompatible(a, b) {
if (!a || !b) return false;
if (a === b) return true;
if (isInitialToken(a) && b.startsWith(a)) return true;
if (isInitialToken(b) && a.startsWith(b)) return true;
return false;
}
function jaroWinkler(s1, s2) {
const a = String(s1 || '');
const b = String(s2 || '');
if (!a || !b) return 0;
if (a === b) return 1;
const maxDist = Math.floor(Math.max(a.length, b.length) / 2) - 1;
const aMatch = new Array(a.length).fill(false);
const bMatch = new Array(b.length).fill(false);
let matches = 0;
for (let i = 0; i < a.length; i += 1) {
const start = Math.max(0, i - maxDist);
const end = Math.min(i + maxDist + 1, b.length);
for (let j = start; j < end; j += 1) {
if (bMatch[j] || a[i] !== b[j]) continue;
aMatch[i] = true;
bMatch[j] = true;
matches += 1;
break;
}
}
if (!matches) return 0;
let t = 0;
let k = 0;
for (let i = 0; i < a.length; i += 1) {
if (!aMatch[i]) continue;
while (!bMatch[k]) k += 1;
if (a[i] !== b[k]) t += 1;
k += 1;
}
const m = matches;
const jaro = (m / a.length + m / b.length + (m - t / 2) / m) / 3;
let prefix = 0;
for (let i = 0; i < Math.min(4, a.length, b.length); i += 1) {
if (a[i] === b[i]) prefix += 1;
else break;
}
return jaro + prefix * 0.1 * (1 - jaro);
}
/**
* Score WhatsApp profile name vs a registration name. 0–1.
*/
function scoreName(waName, registrationName) {
const wa = normalizeNameTokens(waName);
const reg = normalizeNameTokens(registrationName);
if (!wa.length || !reg.length) return 0;
if (wa.join(' ') === reg.join(' ')) return 1;
const lastWa = wa[wa.length - 1];
const lastReg = reg[reg.length - 1];
const lastOk = tokenCompatible(lastWa, lastReg);
const firstOk = tokenCompatible(wa[0], reg[0]);
let matched = 0;
const used = new Set();
for (const token of wa) {
let best = -1;
for (let i = 0; i < reg.length; i += 1) {
if (used.has(i)) continue;
if (tokenCompatible(token, reg[i])) {
best = i;
break;
}
}
if (best >= 0) {
used.add(best);
matched += 1;
}
}
const coverage = matched / Math.max(wa.length, 1);
const extraRegOk = reg.length >= wa.length;
let score = 0.35 * (lastOk ? 1 : jaroWinkler(lastWa, lastReg)) + 0.35 * (firstOk ? 1 : 0) + 0.3 * coverage;
if (lastOk && firstOk) score = Math.max(score, extraRegOk ? 0.92 : 0.9);
if (lastOk && wa.length === 1) score = Math.min(score, 0.55);
if (!lastOk && coverage < 0.7) score = Math.min(score, 0.5);
return Math.max(0, Math.min(1, score));
}
async function loadEventParticipants(Participant, organizationId, eventId) {
const rows = await Participant.find({
tenantOrganization: organizationId,
deletedAt: null,
eventMemberships: { $elemMatch: { event: eventId } },
})
.select('firstName lastName displayName phone email eventMemberships')
.lean();
return rows.filter((p) => membershipAllowed(membershipForEvent(p, eventId)));
}
/**
* @returns {{ kind: 'unique', participant: object, score: number } | { kind: 'none' } | { kind: 'ambiguous', candidates: object[] }}
*/
async function matchParticipantByPhone({ Participant, organizationId, eventId, waDigits }) {
const key = phoneLast10Digits(waDigits);
if (!key || key.length < 8) return { kind: 'none' };
const people = await loadEventParticipants(Participant, organizationId, eventId);
const hits = people.filter((p) => extractPhoneLast10Keys(p.phone).some((k) => k === key || (k.length >= 8 && key.endsWith(k)) || (key.length >= 8 && k.endsWith(key))));
if (hits.length === 1) return { kind: 'unique', participant: hits[0], score: 1 };
if (hits.length > 1) return { kind: 'ambiguous', candidates: hits };
return { kind: 'none' };
}
/**
* @returns {{ kind: 'unique', participant: object, score: number } | { kind: 'none' } | { kind: 'ambiguous', candidates: object[], topScore: number }}
*/
async function matchParticipantByName({ Participant, organizationId, eventId, waName }) {
const name = String(waName || '').trim();
if (!name) return { kind: 'none' };
const people = await loadEventParticipants(Participant, organizationId, eventId);
const ranked = people
.map((p) => ({ participant: p, score: scoreName(name, participantFullName(p)) }))
.sort((a, b) => b.score - a.score);
if (!ranked.length) return { kind: 'none' };
const top = ranked[0];
const second = ranked[1];
const gap = second ? top.score - second.score : 1;
if (top.score >= NAME_AUTO_MIN && gap >= NAME_AUTO_GAP) {
return { kind: 'unique', participant: top.participant, score: top.score };
}
if (top.score >= 0.55) {
return { kind: 'ambiguous', candidates: ranked.slice(0, 3).map((r) => r.participant), topScore: top.score };
}
return { kind: 'none' };
}
module.exports = {
NAME_AUTO_MIN,
NAME_AUTO_GAP,
participantFullName,
scoreName,
matchParticipantByPhone,
matchParticipantByName,
membershipForEvent,
membershipAllowed,
};

View File

@ -35,4 +35,33 @@ function phoneLast10Digits(raw) {
return digits.slice(-10); return digits.slice(-10);
} }
module.exports = { normalizeWhatsAppTo, phoneLast10Digits }; /**
* All last-10 digit keys from a stored phone field that may contain two numbers,
* separators, +91, spaces, or a leading 0.
* @param {string} raw
* @returns {string[]}
*/
function extractPhoneLast10Keys(raw) {
const text = String(raw || '');
if (!text.trim()) return [];
const parts = text.split(/\s*(?:\/|,|&|;|\||\band\b)\s*/i);
const keys = new Set();
const consider = (chunk) => {
const digits = String(chunk || '').replace(/\D/g, '');
if (!digits) return;
if (digits.length >= 10) {
keys.add(digits.slice(-10));
return;
}
if (digits.length >= 8) keys.add(digits);
};
if (parts.length > 1) {
parts.forEach(consider);
}
const runs = text.match(/\d[\d\s.+()-]{7,}/g) || [];
runs.forEach(consider);
consider(text);
return [...keys];
}
module.exports = { normalizeWhatsAppTo, phoneLast10Digits, extractPhoneLast10Keys };

View File

@ -4,7 +4,10 @@ const { createCanvas, loadImage } = require('@napi-rs/canvas');
const sharp = require('sharp'); const sharp = require('sharp');
const { const {
uploadPhotoWallPhotoToS3, uploadPhotoWallPhotoToS3,
uploadEventPhotoToS3,
getEventPhotoBufferFromS3, getEventPhotoBufferFromS3,
PHOTO_ACTIVITIES,
PHOTO_KINDS,
} = require('./s3EventPhotos'); } = require('./s3EventPhotos');
const { normalizeFrameImageLayout, normalizeFrameLayout } = require('./photoWallFrameLayout'); const { normalizeFrameImageLayout, normalizeFrameLayout } = require('./photoWallFrameLayout');
@ -169,10 +172,12 @@ async function drawFrameBackground(ctx, wall, canvasW, canvasH) {
} }
/** /**
* Composite framed image for a photo wall entry. * Composite framed image for a photo wall / social-share entry.
* @param {{ wall: object, photo: object }} args
* @param {{ activity?: string, kind?: string }} [options] — when set, upload under {eventId}/{activity}/{kind}/…
* @returns {{ framedUrl: string, framedS3Key: string }} * @returns {{ framedUrl: string, framedS3Key: string }}
*/ */
async function renderAndUploadFramedPhoto({ wall, photo }) { async function renderAndUploadFramedPhoto({ wall, photo }, options = {}) {
if (!wall?.frameEnabled) { if (!wall?.frameEnabled) {
throw new Error('Frame not enabled'); throw new Error('Frame not enabled');
} }
@ -239,12 +244,25 @@ async function renderAndUploadFramedPhoto({ wall, photo }) {
const pngBuf = canvas.toBuffer('image/png'); const pngBuf = canvas.toBuffer('image/png');
const jpegBuf = await sharp(pngBuf).jpeg({ quality: 90 }).toBuffer(); const jpegBuf = await sharp(pngBuf).jpeg({ quality: 90 }).toBuffer();
const filename = `framed-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.jpg`; const filename = `framed-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.jpg`;
const uploaded = await uploadPhotoWallPhotoToS3({ const eventId = String(wall.event);
const activity = options.activity || PHOTO_ACTIVITIES.photoWall;
const kind = options.kind || PHOTO_KINDS.framed;
const uploaded =
activity === PHOTO_ACTIVITIES.photoWall && kind === PHOTO_KINDS.framed
? await uploadPhotoWallPhotoToS3({
buffer: jpegBuf, buffer: jpegBuf,
contentType: 'image/jpeg', contentType: 'image/jpeg',
eventId: String(wall.event), eventId,
filename, filename,
framed: true, framed: true,
})
: await uploadEventPhotoToS3({
buffer: jpegBuf,
contentType: 'image/jpeg',
eventId,
filename,
activity,
kind,
}); });
return { framedUrl: uploaded.url, framedS3Key: uploaded.s3Key }; return { framedUrl: uploaded.url, framedS3Key: uploaded.s3Key };
} }

View File

@ -0,0 +1,65 @@
const QRCode = require('qrcode');
const { getOrgWhatsAppCredentials, listWabaPhoneNumbers } = require('./metaWhatsApp');
const { normalizeWhatsAppTo } = require('./phone');
const DEFAULT_PREFILL = 'Hi';
function receptionPrefill(bot) {
const t = String(bot?.reception?.prefillText || '').trim();
return t || DEFAULT_PREFILL;
}
function displayPhoneToDigits(raw) {
return normalizeWhatsAppTo(String(raw || '').replace(/^\+/, ''));
}
/**
* Resolve the public WhatsApp number (digits, country code, no +) from org Cloud settings.
*/
async function getOrgWhatsAppDisplayDigits(organizationId) {
const creds = await getOrgWhatsAppCredentials(String(organizationId));
if (!creds?.accessToken || !String(creds.phoneNumberId || '').trim()) {
return { digits: '', creds, displayPhone: '', error: 'WhatsApp Cloud is not configured to send messages.' };
}
const listed = await listWabaPhoneNumbers(creds.wabaId, creds.accessToken);
if (listed.ok) {
const pid = String(creds.phoneNumberId).trim();
const row = (listed.phones || []).find((p) => String(p.id) === pid);
const display = row?.display_phone_number || '';
const digits = displayPhoneToDigits(display);
if (digits) return { digits, creds, displayPhone: display, error: null };
}
return {
digits: '',
creds,
displayPhone: '',
error: 'Could not read the WhatsApp display number from Meta. Check Organisation WhatsApp settings.',
};
}
function buildWaMeUrl(digits, prefillText) {
const to = String(digits || '').replace(/\D/g, '');
if (!to) return '';
const text = encodeURIComponent(String(prefillText || DEFAULT_PREFILL));
return `https://wa.me/${to}?text=${text}`;
}
async function generateReceptionQrPng(digits, prefillText) {
const url = buildWaMeUrl(digits, prefillText);
if (!url) throw new Error('WhatsApp number is not available');
return QRCode.toBuffer(url, {
type: 'png',
width: 440,
margin: 2,
errorCorrectionLevel: 'M',
color: { dark: '#0f172a', light: '#ffffff' },
});
}
module.exports = {
DEFAULT_PREFILL,
receptionPrefill,
getOrgWhatsAppDisplayDigits,
buildWaMeUrl,
generateReceptionQrPng,
};

View File

@ -0,0 +1,45 @@
const Event = require('../models/Event');
const PhotoWall = require('../models/PhotoWall');
const EventPhotoSearch = require('../models/EventPhotoSearch');
const SocialShare = require('../models/SocialShare');
const AiAvatar = require('../models/AiAvatar');
const EVENT_ID_RE = /^[a-f0-9]{24}$/i;
const FEATURE_TOKEN_RE = /^[a-f0-9]{64}$/i;
function eventIdFromDoc(doc) {
if (!doc || doc.event == null) return null;
return doc.event._id != null ? doc.event._id : doc.event;
}
/**
* Resolve a public identifier to an Event.
* - 24 hex: Event._id
* - 64 hex: photo-wall / photo-search / social-share / AI-avatar token
*/
async function resolveVisitorThemeEvent(rawId) {
const id = String(rawId || '').trim();
if (EVENT_ID_RE.test(id)) {
return Event.findById(id).lean();
}
if (!FEATURE_TOKEN_RE.test(id)) return null;
const [photoWall, photoSearch, socialShare, aiAvatar] = await Promise.all([
PhotoWall.findOne({ $or: [{ uploadToken: id }, { presentToken: id }] })
.select('event')
.lean(),
EventPhotoSearch.findOne({ searchToken: id }).select('event').lean(),
SocialShare.findOne({ uploadToken: id }).select('event').lean(),
AiAvatar.findOne({ uploadToken: id }).select('event').lean(),
]);
const eventRef =
eventIdFromDoc(photoWall) ||
eventIdFromDoc(photoSearch) ||
eventIdFromDoc(socialShare) ||
eventIdFromDoc(aiAvatar);
if (!eventRef) return null;
return Event.findById(eventRef).lean();
}
module.exports = { resolveVisitorThemeEvent };

View File

@ -11,55 +11,202 @@ function sanitizeKeySegment(segment) {
return s.replace(/[^a-zA-Z0-9._-]/g, '_'); return s.replace(/[^a-zA-Z0-9._-]/g, '_');
} }
/** Activity folder names under {eventId}/… */
const PHOTO_ACTIVITIES = {
photoWall: 'photo-wall',
photoSearch: 'photo-search',
aiAvatar: 'ai-avatar',
socialShare: 'social-share',
};
/** Kind folder names under {eventId}/{activity}/… */
const PHOTO_KINDS = {
originals: 'originals',
framed: 'framed',
gallery: 'gallery',
searchTmp: 'search-tmp',
uploads: 'uploads',
results: 'results',
};
/** /**
* Build S3 key for event photos under eventbuzz-pictures bucket. * Build standardized S3 key:
* {eventId}/{activity}/{kind}/{filename}
*/
function buildActivityPhotoKey({ eventId, activity, kind, filename }) {
const safeEventId = sanitizeKeySegment(eventId || 'unknown-event');
const safeActivity = sanitizeKeySegment(activity || 'misc');
const safeKind = sanitizeKeySegment(kind || 'uploads');
const safeFilename = sanitizeKeySegment(filename || 'file');
return `${safeEventId}/${safeActivity}/${safeKind}/${safeFilename}`;
}
/**
* Legacy helper — prefer buildActivityPhotoKey.
* Pattern: {eventId}/{filename} or {eventId}/{subdir}/{filename} * Pattern: {eventId}/{filename} or {eventId}/{subdir}/{filename}
* When subdir contains `/`, each segment is sanitized separately.
*/ */
function buildEventPhotoKey({ eventId, filename, subdir }) { function buildEventPhotoKey({ eventId, filename, subdir }) {
const safeEventId = sanitizeKeySegment(eventId || 'unknown-event'); const safeEventId = sanitizeKeySegment(eventId || 'unknown-event');
const safeFilename = sanitizeKeySegment(filename || 'file'); const safeFilename = sanitizeKeySegment(filename || 'file');
const safeSubdir = subdir ? sanitizeKeySegment(subdir) : ''; if (!subdir) return `${safeEventId}/${safeFilename}`;
if (safeSubdir) return `${safeEventId}/${safeSubdir}/${safeFilename}`; const safeSubdir = String(subdir)
return `${safeEventId}/${safeFilename}`; .split('/')
.map((part) => sanitizeKeySegment(part))
.filter(Boolean)
.join('/');
if (!safeSubdir) return `${safeEventId}/${safeFilename}`;
return `${safeEventId}/${safeSubdir}/${safeFilename}`;
} }
/** /**
* Photo wall keys (pictures bucket): * Photo wall keys — standardized layout:
* - original: photo_wall/{eventId}/{filename} * - original: {eventId}/photo-wall/originals/{filename}
* - framed: photo_wall/{eventId}/frame/{filename} * - framed: {eventId}/photo-wall/framed/{filename}
*/ */
function buildPhotoWallKey({ eventId, filename, framed = false }) { function buildPhotoWallKey({ eventId, filename, framed = false }) {
const safeEventId = sanitizeKeySegment(eventId || 'unknown-event'); return buildActivityPhotoKey({
const safeFilename = sanitizeKeySegment(filename || 'file'); eventId,
if (framed) return `photo_wall/${safeEventId}/frame/${safeFilename}`; activity: PHOTO_ACTIVITIES.photoWall,
return `photo_wall/${safeEventId}/${safeFilename}`; kind: framed ? PHOTO_KINDS.framed : PHOTO_KINDS.originals,
filename,
});
} }
function picturesCloudfrontBase() { function picturesCloudfrontBase() {
// Pictures CDN (eventbuzz-pictures). Do not fall back to AWS_URL (rapportapp media CDN). // Pictures CDN (eventbuzz-pictures + DIT). Do not fall back to AWS_URL (rapportapp media CDN).
return getEnv('AWS_URL_PICTURES', 'https://d155t9or8i84n8.cloudfront.net').replace(/\/+$/, ''); return getEnv('AWS_URL_PICTURES', 'https://pictures.eventbuzz.live').replace(/\/+$/, '');
}
function mediaCloudfrontBase() {
return getEnv('AWS_URL', 'https://did94hhhcgq1d.cloudfront.net').replace(/\/+$/, '');
}
function hostnameFromBase(base) {
if (!base) return '';
try {
return new URL(base.includes('://') ? base : `https://${base}`).hostname.toLowerCase();
} catch {
return '';
}
}
function isPicturesHost(hostname) {
const picturesHost = hostnameFromBase(picturesCloudfrontBase());
const host = String(hostname || '').toLowerCase();
return Boolean(picturesHost && host === picturesHost);
}
/** Media-library keys from s3Media.js: eventbuzz/{orgId}/{eventId}/{module}/{file} */
function isLegacyMediaKey(key) {
return /^eventbuzz\//i.test(String(key || '').trim());
}
function parseAbsoluteUrl(urlOrKey) {
const raw = String(urlOrKey || '').trim();
if (!/^https?:\/\//i.test(raw)) return null;
try {
return new URL(raw);
} catch {
return null;
}
} }
/** /**
* Absolute CloudFront URL for a pictures-bucket key. * Normalize a stored pictures url/key to an S3 object key.
* Accepts a bare key or an already-absolute URL (returned unchanged). * Strips absolute CDN hosts (custom domain + legacy CloudFront) and fit-in prefixes.
*/
function toPicturesKey(urlOrKey) {
let raw = String(urlOrKey || '').trim();
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) {
try {
const u = new URL(raw);
raw = u.pathname || '';
} catch {
/* keep raw */
}
}
raw = raw.replace(/^\/+/, '');
// DIT Thumbor-style path: fit-in/400x400/{key}
raw = raw.replace(/^fit-in\/\d+x\d+\//i, '');
return raw;
}
/**
* Absolute CloudFront URL for a pictures-bucket key (original, no resize).
* Accepts a bare key or an absolute URL (rewritten onto current AWS_URL_PICTURES).
*/ */
function cloudfrontUrlForKey(key) { function cloudfrontUrlForKey(key) {
const raw = String(key || '').trim(); const objectKey = toPicturesKey(key);
if (!raw) return ''; if (!objectKey) return '';
if (/^https?:\/\//i.test(raw)) return raw;
const base = picturesCloudfrontBase(); const base = picturesCloudfrontBase();
if (!base) { if (!base) {
console.warn('[s3EventPhotos] AWS_URL_PICTURES is not set — returning S3 key as url'); console.warn('[s3EventPhotos] AWS_URL_PICTURES is not set — returning S3 key as url');
return raw; return objectKey;
} }
return `${base}/${raw.replace(/^\/+/, '')}`; return `${base}/${objectKey}`;
} }
/** Normalize stored url/framedUrl that may be a bare key (legacy / misconfigured env). */ function mediaUrlForKey(key) {
const objectKey = String(key || '').replace(/^\/+/, '');
if (!objectKey) return '';
const base = mediaCloudfrontBase();
if (!base) return objectKey;
return `${base}/${objectKey}`;
}
/**
* Normalize stored url/framedUrl that may be a bare key (legacy / misconfigured env).
* Absolute URLs on the old media CDN (rapportapp / AWS_URL) are left unchanged —
* those files are not in the eventbuzz-pictures bucket.
*/
function resolvePhotoPublicUrl(urlOrKey) { function resolvePhotoPublicUrl(urlOrKey) {
if (urlOrKey == null || String(urlOrKey).trim() === '') return null; if (urlOrKey == null || String(urlOrKey).trim() === '') return null;
return cloudfrontUrlForKey(urlOrKey); const raw = String(urlOrKey).trim();
const absolute = parseAbsoluteUrl(raw);
if (absolute) {
if (isPicturesHost(absolute.hostname)) {
return cloudfrontUrlForKey(raw) || raw;
}
return raw;
}
if (isLegacyMediaKey(raw)) {
return mediaUrlForKey(raw) || raw;
}
const url = cloudfrontUrlForKey(raw);
return url || null;
}
/**
* On-the-fly resized picture URL via Dynamic Image Transformation (Thumbor fit-in).
* Example: https://pictures.eventbuzz.live/fit-in/400x400/{key}
* Legacy media-CDN URLs cannot be resized on the pictures DIT; return the original.
*/
function resolvePhotoThumbUrl(urlOrKey, { w = 400, h = 400 } = {}) {
if (urlOrKey == null || String(urlOrKey).trim() === '') return null;
const raw = String(urlOrKey).trim();
const absolute = parseAbsoluteUrl(raw);
if (absolute && !isPicturesHost(absolute.hostname)) {
return raw;
}
if (!absolute && isLegacyMediaKey(raw)) {
return mediaUrlForKey(raw) || raw;
}
const objectKey = toPicturesKey(raw);
if (!objectKey) return null;
const width = Math.max(1, Math.min(4096, Math.round(Number(w) || 400)));
const height = Math.max(1, Math.min(4096, Math.round(Number(h) || 400)));
const base = picturesCloudfrontBase();
if (!base) return null;
return `${base}/fit-in/${width}x${height}/${objectKey}`;
} }
let cachedS3Client = null; let cachedS3Client = null;
@ -78,17 +225,10 @@ function getS3Client() {
return cachedS3Client; return cachedS3Client;
} }
/** async function putPicturesObject({ key, buffer, contentType }) {
* Upload a photo buffer to the eventbuzz-pictures S3 bucket.
* Returns { s3Key, url }.
* Optional `subdir` nests the key (e.g. photo-wall → {eventId}/photo-wall/{filename}).
*/
async function uploadEventPhotoToS3({ buffer, contentType, eventId, filename, subdir }) {
const bucket = getEnv('AWS_EVENT_PICTURES_BUCKET', 'eventbuzz-pictures'); const bucket = getEnv('AWS_EVENT_PICTURES_BUCKET', 'eventbuzz-pictures');
if (!bucket) throw new Error('AWS_EVENT_PICTURES_BUCKET is not configured'); if (!bucket) throw new Error('AWS_EVENT_PICTURES_BUCKET is not configured');
const key = buildEventPhotoKey({ eventId, filename, subdir });
await getS3Client().send( await getS3Client().send(
new PutObjectCommand({ new PutObjectCommand({
Bucket: bucket, Bucket: bucket,
@ -101,25 +241,29 @@ async function uploadEventPhotoToS3({ buffer, contentType, eventId, filename, su
return { s3Key: key, url: cloudfrontUrlForKey(key) }; return { s3Key: key, url: cloudfrontUrlForKey(key) };
} }
/**
* Upload a photo buffer to the eventbuzz-pictures S3 bucket.
* Prefer `{ activity, kind }` for the standardized layout:
* {eventId}/{activity}/{kind}/{filename}
* Legacy `{ subdir }` still supported (segment-safe).
*/
async function uploadEventPhotoToS3({ buffer, contentType, eventId, filename, activity, kind, subdir }) {
let key;
if (activity && kind) {
key = buildActivityPhotoKey({ eventId, activity, kind, filename });
} else {
key = buildEventPhotoKey({ eventId, filename, subdir });
}
return putPicturesObject({ key, buffer, contentType });
}
/** /**
* Upload a photo-wall image (original or framed composite) to the pictures bucket. * Upload a photo-wall image (original or framed composite) to the pictures bucket.
* Keys: {eventId}/photo-wall/originals|framed/{filename}
*/ */
async function uploadPhotoWallPhotoToS3({ buffer, contentType, eventId, filename, framed = false }) { async function uploadPhotoWallPhotoToS3({ buffer, contentType, eventId, filename, framed = false }) {
const bucket = getEnv('AWS_EVENT_PICTURES_BUCKET', 'eventbuzz-pictures');
if (!bucket) throw new Error('AWS_EVENT_PICTURES_BUCKET is not configured');
const key = buildPhotoWallKey({ eventId, filename, framed }); const key = buildPhotoWallKey({ eventId, filename, framed });
return putPicturesObject({ key, buffer, contentType });
await getS3Client().send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: buffer,
ContentType: contentType || 'application/octet-stream',
}),
);
return { s3Key: key, url: cloudfrontUrlForKey(key) };
} }
/** /**
@ -160,13 +304,17 @@ async function getEventPhotoBufferFromS3(s3Key) {
} }
module.exports = { module.exports = {
PHOTO_ACTIVITIES,
PHOTO_KINDS,
uploadEventPhotoToS3, uploadEventPhotoToS3,
uploadPhotoWallPhotoToS3, uploadPhotoWallPhotoToS3,
deleteEventPhotoFromS3, deleteEventPhotoFromS3,
getEventPhotoBufferFromS3, getEventPhotoBufferFromS3,
buildActivityPhotoKey,
buildEventPhotoKey, buildEventPhotoKey,
buildPhotoWallKey, buildPhotoWallKey,
cloudfrontUrlForKey, cloudfrontUrlForKey,
toPicturesKey,
resolvePhotoPublicUrl, resolvePhotoPublicUrl,
resolvePhotoThumbUrl,
}; };

147
src/utils/visitorTheme.js Normal file
View File

@ -0,0 +1,147 @@
const FONTS = ['Open Sans', 'Montserrat', 'Roboto', 'Poppins', 'Raleway'];
const MODES = new Set(['simple', 'gradient', 'background']);
const BG_SIZES = new Set(['cover', 'contain', 'auto']);
const BG_REPEATS = new Set(['no', 'repeat', 'repeat-x', 'repeat-y']);
const DEFAULT_VISITOR_THEME = {
theme: 'simple',
color: 'rgba(21, 25, 55, 1)',
font: 'Roboto',
primaryColor: 'rgba(48, 63, 159, 1)',
secondaryColor: 'rgba(76, 175, 80, 1)',
headerColor: 'rgba(255, 255, 255, 0.9)',
buttonColor: 'rgba(44, 53, 122, 1)',
textColor: 'rgba(255, 255, 255, 1)',
textHighlightColor: '',
cardColor: 'rgba(255, 255, 255, 0.65)',
cardTextColor: 'rgba(255, 255, 255, 1)',
};
const DEFAULT_GRADIENT = {
color1: 'rgba(30, 114, 239, 1)',
color2: 'rgba(239, 141, 29, 1)',
};
function clampByte(n) {
return Math.max(0, Math.min(255, Math.round(Number(n))));
}
function formatAlpha(a) {
const n = Number(a);
if (!Number.isFinite(n)) return 1;
const clamped = Math.max(0, Math.min(1, n));
if (clamped === 1) return '1';
if (clamped === 0) return '0';
return String(parseFloat(clamped.toFixed(3)));
}
function toRgbaString(r, g, b, a = 1) {
return `rgba(${clampByte(r)}, ${clampByte(g)}, ${clampByte(b)}, ${formatAlpha(a)})`;
}
function parseHex(input) {
const s = String(input).trim();
const m = s.match(/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
if (!m) return null;
let h = m[1];
if (h.length === 3 || h.length === 4) {
h = h
.split('')
.map((c) => c + c)
.join('');
}
const r = parseInt(h.slice(0, 2), 16);
const g = parseInt(h.slice(2, 4), 16);
const b = parseInt(h.slice(4, 6), 16);
const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a };
}
function parseRgbFunc(input) {
const s = String(input).trim();
const m = s.match(/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/i);
if (!m) return null;
return {
r: Number(m[1]),
g: Number(m[2]),
b: Number(m[3]),
a: m[4] != null ? Number(m[4]) : 1,
};
}
/** Convert hex / rgb() / rgba() to `rgba(r, g, b, a)`. Empty stays "". Invalid stays trimmed original. */
function colorToRgba(input, { allowEmpty = true } = {}) {
if (input == null) return allowEmpty ? '' : DEFAULT_VISITOR_THEME.color;
const s = String(input).trim();
if (!s) return allowEmpty ? '' : DEFAULT_VISITOR_THEME.color;
const parsed = parseHex(s) || parseRgbFunc(s);
if (!parsed) return s;
return toRgbaString(parsed.r, parsed.g, parsed.b, parsed.a);
}
function normalizeMode(raw) {
const mode = String(raw || '').trim().toLowerCase();
return MODES.has(mode) ? mode : 'simple';
}
function normalizeFont(raw) {
const font = String(raw || '').trim();
return FONTS.includes(font) ? font : DEFAULT_VISITOR_THEME.font;
}
function normalizeVisitorTheme(raw) {
const src = raw && typeof raw === 'object' ? raw : {};
const theme = normalizeMode(src.theme);
const font = normalizeFont(src.font);
const color = colorToRgba(src.color, { allowEmpty: false });
const out = {
theme,
color,
font,
primaryColor: colorToRgba(src.primaryColor),
secondaryColor: colorToRgba(src.secondaryColor),
headerColor: colorToRgba(src.headerColor),
buttonColor: colorToRgba(src.buttonColor),
textColor: colorToRgba(src.textColor),
textHighlightColor: colorToRgba(src.textHighlightColor),
cardColor: colorToRgba(src.cardColor),
cardTextColor: colorToRgba(src.cardTextColor),
};
if (theme === 'gradient') {
const g = src.gradient && typeof src.gradient === 'object' ? src.gradient : {};
out.gradient = {
color1: colorToRgba(g.color1, { allowEmpty: false }) || DEFAULT_GRADIENT.color1,
color2: colorToRgba(g.color2, { allowEmpty: false }) || DEFAULT_GRADIENT.color2,
};
}
if (theme === 'background') {
const b = src.background && typeof src.background === 'object' ? src.background : {};
const sizeRaw = String(b.size || '').trim().toLowerCase();
const repeatRaw = String(b.repeat || '').trim().toLowerCase();
out.background = {
url: String(b.url || '').trim(),
size: BG_SIZES.has(sizeRaw) ? sizeRaw : 'cover',
repeat: BG_REPEATS.has(repeatRaw) ? repeatRaw : 'no',
};
}
return out;
}
function visitorThemeOrDefault(raw) {
if (!raw || typeof raw !== 'object' || !String(raw.theme || '').trim()) {
return { ...DEFAULT_VISITOR_THEME };
}
return normalizeVisitorTheme(raw);
}
module.exports = {
FONTS,
DEFAULT_VISITOR_THEME,
colorToRgba,
normalizeVisitorTheme,
visitorThemeOrDefault,
};