diff --git a/.env.example b/.env.example index 15b0ab2..b57ba15 100644 --- a/.env.example +++ b/.env.example @@ -51,9 +51,10 @@ AWS_SECRET_ACCESS_KEY=ZpIxby1vbCLGB7CPWZsAltZBEqkhwzgBpFOKDGj8 AWS_DEFAULT_REGION=ap-south-1 AWS_BUCKET=rapportapp 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_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 # 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. @@ -220,4 +221,6 @@ GOOGLE_APPLICATION_CREDENTIALS=./eventbuzz-live-firebase-adminsdk-fbsvc-bb3dcfd6 # META_WHATSAPP_WEBHOOK_VERIFY_TOKEN= # 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 \ No newline at end of file +# EVENT_TICKETING_MANUAL_ENABLEMENT_FEE_INR=500 +# WaveSpeed AI (AI Avatar head swap) +WAVESPEED_API_KEY= diff --git a/src/app.js b/src/app.js index 9c7a926..d0eb6bd 100644 --- a/src/app.js +++ b/src/app.js @@ -30,6 +30,7 @@ const platformAdminRoutes = require('./routes/platformAdmin.routes'); const adminRequestRoutes = require('./routes/adminRequest.routes'); const eventStaffRoutes = require('./routes/eventStaff.routes'); const websiteSettingsRoutes = require('./routes/websiteSettings.routes'); +const visitorThemeRoutes = require('./routes/visitorTheme.routes'); const eventMicrositeRoutes = require('./routes/eventMicrosite.routes'); const visitorRoutes = require('./routes/visitor.routes'); const whatsappTemplateRoutes = require('./routes/whatsappTemplate.routes'); @@ -49,9 +50,17 @@ const conciergeBotRoutes = require('./routes/conciergeBot.routes'); const publicConciergeRoutes = require('./routes/publicConcierge.routes'); const publicQuizPollRoutes = require('./routes/publicQuizPoll.routes'); const publicPhotoWallRoutes = require('./routes/publicPhotoWall.routes'); +const 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 eventPhotoRoutes = require('./routes/eventPhoto.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 () => { /** 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(publicQuizPollRoutes); server.route(publicPhotoWallRoutes); + server.route(publicEventPhotoSearchRoutes); + server.route(publicPhotoIngestRoutes); + server.route(publicSocialShareRoutes); + server.route(publicAiAvatarRoutes); + server.route(publicVisitorThemeRoutes); server.route(authRoutes); server.route(eventRoutes); @@ -316,6 +330,7 @@ const createServer = async () => { server.route(adminRequestRoutes); server.route(eventStaffRoutes); server.route(websiteSettingsRoutes); + server.route(visitorThemeRoutes); server.route(eventMicrositeRoutes); server.route(staticMicrositeRoutes); server.route(whatsappTemplateRoutes); @@ -325,6 +340,9 @@ const createServer = async () => { server.route(eventTicketingManualRoutes); server.route(eventPhotoRoutes); server.route(photoWallRoutes); + server.route(eventPhotoSearchRoutes); + server.route(socialShareRoutes); + server.route(aiAvatarRoutes); server.route(conciergeBotRoutes); server.route(attendanceRoutes); server.route(checkinsRoutes); diff --git a/src/config/aclModules.js b/src/config/aclModules.js index 9b6ca22..a17e110 100644 --- a/src/config/aclModules.js +++ b/src/config/aclModules.js @@ -87,6 +87,20 @@ const ACL_MODULE_DEFS = { rowLevel: false, 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) { @@ -114,6 +128,8 @@ const ORDERED_ACL_MODULE_KEYS = [ 'exhibitors', 'content_management', 'photo_wall', + 'social_share', + 'ai_avatar', 'email_outreach_campaigns', 'custom_badge_and_print_file_creation', 'custom_template_for_wa_messaging', @@ -128,6 +144,8 @@ const ORDERED_ACL_MODULE_KEYS = [ ...COMBINED_PLAN_KEYS.filter((k) => ![ 'events', 'tracks', 'sessions', 'forms', 'web_check_in', 'attendance', 'participants', 'agenda_builder', 'multi_track_agenda', 'speaker_management', 'exhibitors', 'content_management', 'photo_wall', + 'social_share', + 'ai_avatar', 'email_outreach_campaigns', 'custom_badge_and_print_file_creation', 'custom_template_for_wa_messaging', 'live_polls', 'multiple_quiz_modes', 'qna_with_moderation', 'ticketing_free_and_paid', 'payment_gateway_integration', diff --git a/src/config/systemRolePermissions.js b/src/config/systemRolePermissions.js index 4ff3594..e44ca00 100644 --- a/src/config/systemRolePermissions.js +++ b/src/config/systemRolePermissions.js @@ -26,6 +26,8 @@ const CORE_EXPLICIT = [ 'participants', 'exhibitors', 'photo_wall', + 'social_share', + 'ai_avatar', 'user_management', 'settings', 'billing', @@ -42,6 +44,8 @@ const SYSTEM_ROLE_PERMISSIONS = { perm('participants', ['read', 'write', 'delete'], 'all'), perm('exhibitors', ['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('settings', ['read', 'write', 'delete'], 'all'), perm('billing', ['read', 'write'], 'all'), @@ -59,6 +63,8 @@ const SYSTEM_ROLE_PERMISSIONS = { perm('participants', ['read', 'write', 'delete'], 'all'), perm('exhibitors', ['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('settings', ['read', 'write'], 'all'), perm('billing', ['read', 'write'], 'all'), @@ -76,6 +82,8 @@ const SYSTEM_ROLE_PERMISSIONS = { perm('participants', ['read', 'write'], 'all'), perm('exhibitors', ['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( (p) => !CORE_EXPLICIT.includes(p.module), ), @@ -90,6 +98,8 @@ const SYSTEM_ROLE_PERMISSIONS = { perm('participants', ['read'], 'all'), perm('exhibitors', ['read'], 'assigned'), perm('photo_wall', ['read'], 'assigned'), + perm('social_share', ['read'], 'assigned'), + perm('ai_avatar', ['read'], 'assigned'), ...allPlanModules(['read'], 'all').filter( (p) => !CORE_EXPLICIT.includes(p.module), ), diff --git a/src/controllers/aiAvatar.controller.js b/src/controllers/aiAvatar.controller.js new file mode 100644 index 0000000..26e5551 --- /dev/null +++ b/src/controllers/aiAvatar.controller.js @@ -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, +}; diff --git a/src/controllers/conciergeBot.controller.js b/src/controllers/conciergeBot.controller.js index 8771964..19acb40 100644 --- a/src/controllers/conciergeBot.controller.js +++ b/src/controllers/conciergeBot.controller.js @@ -6,6 +6,12 @@ const ChatSubscriber = require('../models/ChatSubscriber'); const Event = require('../models/Event'); const { success, error } = require('../utils/response'); const { getOrgWhatsAppCredentials, assertPhoneNumberOnWaba } = require('../utils/metaWhatsApp'); +const { + getOrgWhatsAppDisplayDigits, + buildWaMeUrl, + generateReceptionQrPng, + receptionPrefill, +} = require('../utils/receptionWhatsApp'); const { resolveScopedOrganizationId } = require('../utils/tenantListScope'); const { seedVenueFaqs, @@ -13,11 +19,149 @@ const { normalizeFaqsPayload, } = 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) { const { enqueueConciergeIngest: enqueue } = require('../services/conciergeQueue'); 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) { if (user.role === 'super_admin') return true; 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 === 'ACTIVE') bot.status = 'DRAFT'; } - await bot.save(); + 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(); + } 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(); - return h.response(success({ bot: populated })); + return h.response(success({ bot: populated, eventChanged: eventRebound })); }; 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 = { list, create, get, update, remove, + getReception, + getReceptionQrPng, getByEvent, listSources, createSource, diff --git a/src/controllers/eventPhoto.controller.js b/src/controllers/eventPhoto.controller.js index 303bf11..5ef40c0 100644 --- a/src/controllers/eventPhoto.controller.js +++ b/src/controllers/eventPhoto.controller.js @@ -5,10 +5,22 @@ const { success, error } = require('../utils/response'); const { uploadEventPhotoToS3, deleteEventPhotoFromS3, + resolvePhotoThumbUrl, + resolvePhotoPublicUrl, + PHOTO_ACTIVITIES, + PHOTO_KINDS, } = require('../utils/s3EventPhotos'); const rekognitionService = require('../services/rekognitionService'); 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) { if (!v) return null; if (v instanceof mongoose.Types.ObjectId) return v; @@ -181,7 +193,7 @@ const listPublicEventPhotos = async (request, h) => { : null, fileName: p.fileName, originalFileName: p.originalFileName, - url: p.url, + url: eventPhotoDisplayUrl(p), s3Key: p.s3Key, mimeType: p.mimeType, size: p.size, @@ -222,8 +234,10 @@ const uploadPublicEventPhotos = async (request, h) => { const uploaded = await uploadEventPhotoToS3({ buffer: extracted.buffer, contentType: mime, - eventId: `picturewall/${eventId}`, + eventId, filename: storedFilename, + activity: PHOTO_ACTIVITIES.photoSearch, + kind: PHOTO_KINDS.gallery, }); const photo = await EventPhoto.create({ @@ -276,7 +290,7 @@ const listEventPhotos = async (request, h) => { : null, fileName: p.fileName, originalFileName: p.originalFileName, - url: p.url, + url: eventPhotoDisplayUrl(p), s3Key: p.s3Key, mimeType: p.mimeType, size: p.size, @@ -324,8 +338,10 @@ const uploadEventPhotos = async (request, h) => { const uploaded = await uploadEventPhotoToS3({ buffer: extracted.buffer, contentType: mime, - eventId: `picturewall/${eventId}`, + eventId, filename: storedFilename, + activity: PHOTO_ACTIVITIES.photoSearch, + kind: PHOTO_KINDS.gallery, }); const size = extracted.buffer.length; @@ -413,8 +429,10 @@ const searchEventPhotos = async (request, h) => { const uploaded = await uploadEventPhotoToS3({ buffer: query.buffer, contentType: mime, - eventId: `search-tmp/${eventId}`, + eventId, filename: tempFilename, + activity: PHOTO_ACTIVITIES.photoSearch, + kind: PHOTO_KINDS.searchTmp, }); tempS3Key = uploaded.s3Key; @@ -473,7 +491,7 @@ const searchEventPhotos = async (request, h) => { uploadedBy: photo.uploadedBy ? String(photo.uploadedBy) : null, fileName: photo.fileName, originalFileName: photo.originalFileName, - url: photo.url, + url: eventPhotoDisplayUrl(photo), s3Key: photo.s3Key, mimeType: photo.mimeType, size: photo.size, @@ -572,8 +590,10 @@ const searchPublicEventPhotos = async (request, h) => { const uploaded = await uploadEventPhotoToS3({ buffer: query.buffer, contentType: mime, - eventId: `picturewall/search-tmp/${eventId}`, + eventId, filename: tempFilename, + activity: PHOTO_ACTIVITIES.photoSearch, + kind: PHOTO_KINDS.searchTmp, }); tempS3Key = uploaded.s3Key; @@ -676,7 +696,7 @@ const searchPublicEventPhotos = async (request, h) => { : null, fileName: photo.fileName, originalFileName: photo.originalFileName, - url: photo.url, + url: eventPhotoDisplayUrl(photo), s3Key: photo.s3Key, mimeType: photo.mimeType, size: photo.size, diff --git a/src/controllers/eventPhotoSearch.controller.js b/src/controllers/eventPhotoSearch.controller.js new file mode 100644 index 0000000..68c7b64 --- /dev/null +++ b/src/controllers/eventPhotoSearch.controller.js @@ -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, +}; diff --git a/src/controllers/photoWall.controller.js b/src/controllers/photoWall.controller.js index 6c07d8c..6adebea 100644 --- a/src/controllers/photoWall.controller.js +++ b/src/controllers/photoWall.controller.js @@ -4,7 +4,7 @@ const PhotoWallPhoto = require('../models/PhotoWallPhoto'); const Event = require('../models/Event'); const Form = require('../models/Form'); 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 { normalizeFrameImageLayout, @@ -137,9 +137,14 @@ function serializePhoto(doc) { photoWall: doc.photoWall, event: doc.event, 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, - framedUrl: resolvePhotoPublicUrl(doc.framedUrl), + framedUrl: doc.framedUrl + ? resolvePhotoThumbUrl(doc.framedS3Key || doc.framedUrl, { w: 640, h: 640 }) || + resolvePhotoPublicUrl(doc.framedUrl) + : null, framedS3Key: doc.framedS3Key || null, mimeType: doc.mimeType, size: doc.size, diff --git a/src/controllers/publicAiAvatar.controller.js b/src/controllers/publicAiAvatar.controller.js new file mode 100644 index 0000000..9a38abf --- /dev/null +++ b/src/controllers/publicAiAvatar.controller.js @@ -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, +}; diff --git a/src/controllers/publicEventPhotoSearch.controller.js b/src/controllers/publicEventPhotoSearch.controller.js new file mode 100644 index 0000000..7a481b6 --- /dev/null +++ b/src/controllers/publicEventPhotoSearch.controller.js @@ -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, +}; diff --git a/src/controllers/publicPhotoIngest.controller.js b/src/controllers/publicPhotoIngest.controller.js new file mode 100644 index 0000000..81f63c3 --- /dev/null +++ b/src/controllers/publicPhotoIngest.controller.js @@ -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, +}; diff --git a/src/controllers/publicPhotoWall.controller.js b/src/controllers/publicPhotoWall.controller.js index 23c37f6..992e24d 100644 --- a/src/controllers/publicPhotoWall.controller.js +++ b/src/controllers/publicPhotoWall.controller.js @@ -7,7 +7,7 @@ const Form = require('../models/Form'); const FormSubmission = require('../models/FormSubmission'); const Participant = require('../models/Participant'); 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 { serializePhoto } = require('./photoWall.controller'); 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 = resolvePhotoPublicUrl(photo.framedUrl || photo.url); + const previewUrl = + 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 .response( @@ -436,7 +443,6 @@ const uploadPhoto = async (request, h) => { photo: serializePhoto(photo), awaitingApproval: status === 'pending', previewUrl, - /** Same-origin API URL for download/share (avoids CloudFront CORS on fetch). */ fileUrl, framed: Boolean(photo.framedUrl), }, @@ -532,10 +538,13 @@ const getPresentWall = async (request, h) => { frameCanvasWidth, frameCanvasHeight, photos: photos.map((p) => { - const raw = frameEnabled && p.framedUrl ? p.framedUrl : p.url; + const rawKey = frameEnabled && p.framedUrl ? p.framedUrl : p.url; return { _id: p._id, - url: resolvePhotoPublicUrl(raw) || raw, + url: + resolvePhotoThumbUrl(rawKey, { w: 640, h: 640 }) || + resolvePhotoPublicUrl(rawKey) || + rawKey, createdAt: p.createdAt, ...(showUploaderNameOnWall ? { displayName: p.displayName ? String(p.displayName) : null } diff --git a/src/controllers/publicSocialShare.controller.js b/src/controllers/publicSocialShare.controller.js new file mode 100644 index 0000000..fc484ba --- /dev/null +++ b/src/controllers/publicSocialShare.controller.js @@ -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, +}; diff --git a/src/controllers/publicVisitorTheme.controller.js b/src/controllers/publicVisitorTheme.controller.js new file mode 100644 index 0000000..f49ad80 --- /dev/null +++ b/src/controllers/publicVisitorTheme.controller.js @@ -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 }; diff --git a/src/controllers/socialShare.controller.js b/src/controllers/socialShare.controller.js new file mode 100644 index 0000000..7ace859 --- /dev/null +++ b/src/controllers/socialShare.controller.js @@ -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, +}; diff --git a/src/controllers/visitorTheme.controller.js b/src/controllers/visitorTheme.controller.js new file mode 100644 index 0000000..8af00ea --- /dev/null +++ b/src/controllers/visitorTheme.controller.js @@ -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 }; diff --git a/src/models/AiAvatar.js b/src/models/AiAvatar.js new file mode 100644 index 0000000..714b13e --- /dev/null +++ b/src/models/AiAvatar.js @@ -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); diff --git a/src/models/AiAvatarOutput.js b/src/models/AiAvatarOutput.js new file mode 100644 index 0000000..8156495 --- /dev/null +++ b/src/models/AiAvatarOutput.js @@ -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); diff --git a/src/models/AiAvatarTemplate.js b/src/models/AiAvatarTemplate.js new file mode 100644 index 0000000..00eba99 --- /dev/null +++ b/src/models/AiAvatarTemplate.js @@ -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); diff --git a/src/models/ConciergeBot.js b/src/models/ConciergeBot.js index dae73ad..74f631f 100644 --- a/src/models/ConciergeBot.js +++ b/src/models/ConciergeBot.js @@ -94,6 +94,54 @@ const conciergeBotSchema = new mongoose.Schema( lastKnowledgeClearedAt: 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 }, ); @@ -101,5 +149,13 @@ const conciergeBotSchema = new mongoose.Schema( conciergeBotSchema.index({ organization: 1, event: 1 }, { unique: true }); conciergeBotSchema.index({ wabaPhoneNumberId: 1 }, { unique: true }); 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); diff --git a/src/models/Event.js b/src/models/Event.js index 9bd4743..3fda0a2 100644 --- a/src/models/Event.js +++ b/src/models/Event.js @@ -126,6 +126,32 @@ const eventSchema = new mongoose.Schema( /** GrapesJS / visual editor main markup (injected at publish when set; canonical store for custom HTML). */ 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. * Buyer totals are ticket + tax only; Eventbuzz is covered by ticketingManualPayment enablement (billing). diff --git a/src/models/EventPhotoSearch.js b/src/models/EventPhotoSearch.js new file mode 100644 index 0000000..d425eda --- /dev/null +++ b/src/models/EventPhotoSearch.js @@ -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); diff --git a/src/models/SocialShare.js b/src/models/SocialShare.js new file mode 100644 index 0000000..e34d8e0 --- /dev/null +++ b/src/models/SocialShare.js @@ -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); diff --git a/src/models/SocialShareFrame.js b/src/models/SocialShareFrame.js new file mode 100644 index 0000000..32693e0 --- /dev/null +++ b/src/models/SocialShareFrame.js @@ -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); diff --git a/src/models/SocialShareOutput.js b/src/models/SocialShareOutput.js new file mode 100644 index 0000000..24b0dc9 --- /dev/null +++ b/src/models/SocialShareOutput.js @@ -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); diff --git a/src/routes/aiAvatar.routes.js b/src/routes/aiAvatar.routes.js new file mode 100644 index 0000000..ccb0436 --- /dev/null +++ b/src/routes/aiAvatar.routes.js @@ -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, + }, + }, +]; diff --git a/src/routes/conciergeBot.routes.js b/src/routes/conciergeBot.routes.js index dc6b176..d84c01d 100644 --- a/src/routes/conciergeBot.routes.js +++ b/src/routes/conciergeBot.routes.js @@ -79,15 +79,37 @@ const createPayload = Joi.object({ 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({ name: Joi.string().allow(''), wabaPhoneNumberId: Joi.string(), + eventId: Joi.string(), accentColor: accentColorSchema, greeting: greetingSchema, registration: registrationSchema, knowledge: knowledgeSchema, faqs: Joi.array().items(faqItemSchema), enabled: Joi.boolean(), + reception: receptionSchema, }).min(1); const sourcePayload = Joi.object({ @@ -108,6 +130,16 @@ module.exports = [ }, { 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}/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: 'DELETE', path: '/api/concierge-bots/{botId}', options: { handler: controller.remove } }, { method: 'GET', path: '/api/events/{eventId}/concierge-bot', options: { handler: controller.getByEvent } }, diff --git a/src/routes/eventPhotoSearch.routes.js b/src/routes/eventPhotoSearch.routes.js new file mode 100644 index 0000000..12addf7 --- /dev/null +++ b/src/routes/eventPhotoSearch.routes.js @@ -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, + }, + }, +]; diff --git a/src/routes/publicAiAvatar.routes.js b/src/routes/publicAiAvatar.routes.js new file mode 100644 index 0000000..cf50a19 --- /dev/null +++ b/src/routes/publicAiAvatar.routes.js @@ -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, + }, + }, +]; diff --git a/src/routes/publicEventPhotoSearch.routes.js b/src/routes/publicEventPhotoSearch.routes.js new file mode 100644 index 0000000..0803aa2 --- /dev/null +++ b/src/routes/publicEventPhotoSearch.routes.js @@ -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, + }, + }, +]; diff --git a/src/routes/publicPhotoIngest.routes.js b/src/routes/publicPhotoIngest.routes.js new file mode 100644 index 0000000..0af4be1 --- /dev/null +++ b/src/routes/publicPhotoIngest.routes.js @@ -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, + }, + }, +]; diff --git a/src/routes/publicSocialShare.routes.js b/src/routes/publicSocialShare.routes.js new file mode 100644 index 0000000..89a90f7 --- /dev/null +++ b/src/routes/publicSocialShare.routes.js @@ -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, + }, + }, +]; diff --git a/src/routes/publicVisitorTheme.routes.js b/src/routes/publicVisitorTheme.routes.js new file mode 100644 index 0000000..0ab6778 --- /dev/null +++ b/src/routes/publicVisitorTheme.routes.js @@ -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, + }, + }, +]; diff --git a/src/routes/socialShare.routes.js b/src/routes/socialShare.routes.js new file mode 100644 index 0000000..392fb44 --- /dev/null +++ b/src/routes/socialShare.routes.js @@ -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, + }, + }, +]; diff --git a/src/routes/visitorTheme.routes.js b/src/routes/visitorTheme.routes.js new file mode 100644 index 0000000..522b3bd --- /dev/null +++ b/src/routes/visitorTheme.routes.js @@ -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, + }, + }, +]; diff --git a/src/services/waveSpeedHeadSwap.js b/src/services/waveSpeedHeadSwap.js new file mode 100644 index 0000000..40c5235 --- /dev/null +++ b/src/services/waveSpeedHeadSwap.js @@ -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, +}; diff --git a/src/utils/conciergeGreeting.js b/src/utils/conciergeGreeting.js index 1bc53d4..9e37453 100644 --- a/src/utils/conciergeGreeting.js +++ b/src/utils/conciergeGreeting.js @@ -2,6 +2,7 @@ const CONCIERGE_GREETING_DEFAULTS = { profileName: 'Visitor', eventName: 'Event', + firstName: 'there', }; /** @@ -10,7 +11,7 @@ const CONCIERGE_GREETING_DEFAULTS = { * When the template is empty, returns a sensible default greeting. * * @param {string} template - * @param {{ profileName?: string, eventName?: string }} [values] + * @param {{ profileName?: string, eventName?: string, firstName?: string }} [values] * @returns {string} */ function interpolateConciergeGreeting(template, values = {}) { @@ -28,8 +29,15 @@ function interpolateConciergeGreeting(template, values = {}) { } 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)) { - const raw = values[key]; + const raw = merged[key]; const val = raw != null && String(raw).trim() !== '' ? String(raw).trim() : fallback; out = out.replace(new RegExp(`\\{${key}\\}`, 'gi'), val); } diff --git a/src/utils/metaWhatsApp.js b/src/utils/metaWhatsApp.js index 7acff23..8ec7e48 100644 --- a/src/utils/metaWhatsApp.js +++ b/src/utils/metaWhatsApp.js @@ -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} accessToken @@ -654,7 +702,10 @@ module.exports = { parseWhatsAppCloudMessagesSendResponse, sendTemplateMessage, sendTextMessage, + sendImageMessage, + sendVideoMessage, sendInteractiveButtons, postWhatsAppMessage, + downloadWhatsAppMedia, metaDebugEnabled, }; diff --git a/src/utils/participantMatch.js b/src/utils/participantMatch.js new file mode 100644 index 0000000..4bb43c7 --- /dev/null +++ b/src/utils/participantMatch.js @@ -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, +}; diff --git a/src/utils/phone.js b/src/utils/phone.js index d6f4776..7a3e813 100644 --- a/src/utils/phone.js +++ b/src/utils/phone.js @@ -35,4 +35,33 @@ function phoneLast10Digits(raw) { 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 }; diff --git a/src/utils/photoWallFrameRender.js b/src/utils/photoWallFrameRender.js index a59ff72..4ec39eb 100644 --- a/src/utils/photoWallFrameRender.js +++ b/src/utils/photoWallFrameRender.js @@ -4,7 +4,10 @@ const { createCanvas, loadImage } = require('@napi-rs/canvas'); const sharp = require('sharp'); const { uploadPhotoWallPhotoToS3, + uploadEventPhotoToS3, getEventPhotoBufferFromS3, + PHOTO_ACTIVITIES, + PHOTO_KINDS, } = require('./s3EventPhotos'); 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 }} */ -async function renderAndUploadFramedPhoto({ wall, photo }) { +async function renderAndUploadFramedPhoto({ wall, photo }, options = {}) { if (!wall?.frameEnabled) { throw new Error('Frame not enabled'); } @@ -239,13 +244,26 @@ async function renderAndUploadFramedPhoto({ wall, photo }) { const pngBuf = canvas.toBuffer('image/png'); const jpegBuf = await sharp(pngBuf).jpeg({ quality: 90 }).toBuffer(); const filename = `framed-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.jpg`; - const uploaded = await uploadPhotoWallPhotoToS3({ - buffer: jpegBuf, - contentType: 'image/jpeg', - eventId: String(wall.event), - filename, - framed: true, - }); + 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, + contentType: 'image/jpeg', + eventId, + filename, + framed: true, + }) + : await uploadEventPhotoToS3({ + buffer: jpegBuf, + contentType: 'image/jpeg', + eventId, + filename, + activity, + kind, + }); return { framedUrl: uploaded.url, framedS3Key: uploaded.s3Key }; } diff --git a/src/utils/receptionWhatsApp.js b/src/utils/receptionWhatsApp.js new file mode 100644 index 0000000..78de6ae --- /dev/null +++ b/src/utils/receptionWhatsApp.js @@ -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, +}; diff --git a/src/utils/resolveVisitorThemeEvent.js b/src/utils/resolveVisitorThemeEvent.js new file mode 100644 index 0000000..2893ade --- /dev/null +++ b/src/utils/resolveVisitorThemeEvent.js @@ -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 }; diff --git a/src/utils/s3EventPhotos.js b/src/utils/s3EventPhotos.js index f42637c..4e652f4 100644 --- a/src/utils/s3EventPhotos.js +++ b/src/utils/s3EventPhotos.js @@ -11,57 +11,204 @@ function sanitizeKeySegment(segment) { 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} + * When subdir contains `/`, each segment is sanitized separately. */ function buildEventPhotoKey({ eventId, filename, subdir }) { const safeEventId = sanitizeKeySegment(eventId || 'unknown-event'); const safeFilename = sanitizeKeySegment(filename || 'file'); - const safeSubdir = subdir ? sanitizeKeySegment(subdir) : ''; - if (safeSubdir) return `${safeEventId}/${safeSubdir}/${safeFilename}`; - return `${safeEventId}/${safeFilename}`; + if (!subdir) return `${safeEventId}/${safeFilename}`; + const safeSubdir = String(subdir) + .split('/') + .map((part) => sanitizeKeySegment(part)) + .filter(Boolean) + .join('/'); + if (!safeSubdir) return `${safeEventId}/${safeFilename}`; + return `${safeEventId}/${safeSubdir}/${safeFilename}`; } /** - * Photo wall keys (pictures bucket): - * - original: photo_wall/{eventId}/{filename} - * - framed: photo_wall/{eventId}/frame/{filename} + * Photo wall keys — standardized layout: + * - original: {eventId}/photo-wall/originals/{filename} + * - framed: {eventId}/photo-wall/framed/{filename} */ function buildPhotoWallKey({ eventId, filename, framed = false }) { - const safeEventId = sanitizeKeySegment(eventId || 'unknown-event'); - const safeFilename = sanitizeKeySegment(filename || 'file'); - if (framed) return `photo_wall/${safeEventId}/frame/${safeFilename}`; - return `photo_wall/${safeEventId}/${safeFilename}`; + return buildActivityPhotoKey({ + eventId, + activity: PHOTO_ACTIVITIES.photoWall, + kind: framed ? PHOTO_KINDS.framed : PHOTO_KINDS.originals, + filename, + }); } function picturesCloudfrontBase() { - // Pictures CDN (eventbuzz-pictures). Do not fall back to AWS_URL (rapportapp media CDN). - return getEnv('AWS_URL_PICTURES', 'https://d155t9or8i84n8.cloudfront.net').replace(/\/+$/, ''); + // Pictures CDN (eventbuzz-pictures + DIT). Do not fall back to AWS_URL (rapportapp media CDN). + 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. - * Accepts a bare key or an already-absolute URL (returned unchanged). + * Normalize a stored pictures url/key to an S3 object key. + * 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) { - const raw = String(key || '').trim(); - if (!raw) return ''; - if (/^https?:\/\//i.test(raw)) return raw; + const objectKey = toPicturesKey(key); + if (!objectKey) return ''; const base = picturesCloudfrontBase(); if (!base) { 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) { 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; function getS3Client() { if (cachedS3Client) return cachedS3Client; @@ -78,17 +225,10 @@ function getS3Client() { return cachedS3Client; } -/** - * 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 }) { +async function putPicturesObject({ key, buffer, contentType }) { const bucket = getEnv('AWS_EVENT_PICTURES_BUCKET', 'eventbuzz-pictures'); if (!bucket) throw new Error('AWS_EVENT_PICTURES_BUCKET is not configured'); - const key = buildEventPhotoKey({ eventId, filename, subdir }); - await getS3Client().send( new PutObjectCommand({ Bucket: bucket, @@ -101,25 +241,29 @@ async function uploadEventPhotoToS3({ buffer, contentType, eventId, filename, su 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. + * Keys: {eventId}/photo-wall/originals|framed/{filename} */ 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 }); - - await getS3Client().send( - new PutObjectCommand({ - Bucket: bucket, - Key: key, - Body: buffer, - ContentType: contentType || 'application/octet-stream', - }), - ); - - return { s3Key: key, url: cloudfrontUrlForKey(key) }; + return putPicturesObject({ key, buffer, contentType }); } /** @@ -160,13 +304,17 @@ async function getEventPhotoBufferFromS3(s3Key) { } module.exports = { + PHOTO_ACTIVITIES, + PHOTO_KINDS, uploadEventPhotoToS3, uploadPhotoWallPhotoToS3, deleteEventPhotoFromS3, getEventPhotoBufferFromS3, + buildActivityPhotoKey, buildEventPhotoKey, buildPhotoWallKey, cloudfrontUrlForKey, + toPicturesKey, resolvePhotoPublicUrl, + resolvePhotoThumbUrl, }; - diff --git a/src/utils/visitorTheme.js b/src/utils/visitorTheme.js new file mode 100644 index 0000000..541bf8e --- /dev/null +++ b/src/utils/visitorTheme.js @@ -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, +};