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:
parent
d77d6f7cd1
commit
b86cb5e992
|
|
@ -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=
|
||||||
|
|
|
||||||
18
src/app.js
18
src/app.js
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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 } },
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue