const mongoose = require('mongoose'); const ConciergeBot = require('../models/ConciergeBot'); const KnowledgeSource = require('../models/KnowledgeSource'); const ContextChunk = require('../models/ContextChunk'); 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, ensureVenueFaqs, normalizeFaqsPayload, } = require('../utils/conciergeVenueFaqs'); const { normalizeWhatsAppCtas } = require('../utils/whatsappCtas'); 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); let agendaCount = Number(d.agendaCount); if (!Number.isFinite(agendaCount)) agendaCount = 3; agendaCount = Math.min(8, Math.max(1, Math.round(agendaCount))); const contentKind = d.contentKind === 'upcoming_agenda' ? 'upcoming_agenda' : 'custom'; const ctas = contentKind === 'upcoming_agenda' ? [] : normalizeWhatsAppCtas(d.ctas, { strict: true }); const body = String(d.body || ''); if (ctas.length && !body.trim()) { throw new Error('Drip message text is required when buttons are set.'); } const row = { trigger, delayMinutes: Number.isFinite(delayMinutes) ? Math.max(0, delayMinutes) : 0, sendAt: d.sendAt ? new Date(d.sendAt) : null, body, mediaType: contentKind === 'upcoming_agenda' ? 'none' : mediaType, mediaUrl: contentKind === 'upcoming_agenda' ? '' : String(d.mediaUrl || '').trim(), contentKind, agendaCount, templateId, ctas, }; if (d._id && mongoose.Types.ObjectId.isValid(String(d._id))) { row._id = d._id; } return row; }) : []; 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); if (scoped.error) return false; return String(organizationId) === String(scoped.organizationId); } function buildListFilter(user, queryOrganization) { if (queryOrganization) { const scoped = resolveScopedOrganizationId(user, queryOrganization); if (!scoped.error) return { organization: scoped.organizationId }; } const scoped = resolveScopedOrganizationId(user, null); if (!scoped.error) return { organization: scoped.organizationId }; if (user.role === 'super_admin') return {}; return null; } async function getBotForUser(botId, user) { const bot = await ConciergeBot.findById(botId).populate('event', 'name startDate endDate'); if (!bot) return { err: { code: 'NOT_FOUND', message: 'Chatbot not found', status: 404 } }; if (!userCanAccessOrganization(user, bot.organization)) { return { err: { code: 'FORBIDDEN', message: 'Access denied', status: 403 } }; } return { bot }; } async function botAggregates(eventId) { const [sourceCount, stagingChunkCount, subscriberCount] = await Promise.all([ KnowledgeSource.countDocuments({ eventId }), ContextChunk.countDocuments({ eventId, isActive: false }), ChatSubscriber.countDocuments({ eventId }), ]); return { sourceCount, stagingChunkCount, subscriberCount }; } const list = async (request, h) => { const user = request.auth.credentials.user; const filter = buildListFilter(user, request.query.organization); if (filter == null) { return h .response( error( 'VALIDATION_ERROR', 'List is scoped by organisation. Pass ?organization= or set organisation on your account.', ), ) .code(400); } const bots = await ConciergeBot.find(filter) .populate('event', 'name startDate endDate status') .sort({ updatedAt: -1 }) .lean(); return h.response(success({ bots })); }; const create = async (request, h) => { const user = request.auth.credentials.user; const { eventId, name, wabaPhoneNumberId, accentColor, greeting, registration, knowledge, faqs, enabled, } = request.payload; if (!mongoose.Types.ObjectId.isValid(String(eventId))) { return h.response(error('VALIDATION_ERROR', 'Invalid event id')).code(400); } const event = await Event.findById(eventId).lean(); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); if (!userCanAccessOrganization(user, event.organization)) { return h.response(error('FORBIDDEN', 'Event not in your organisation')).code(403); } const botOrganizationId = event.organization; const existing = await ConciergeBot.findOne({ event: eventId }); if (existing) { return h.response(error('VALIDATION_ERROR', 'This event already has a chatbot')).code(400); } const strictWaba = String(process.env.CONCIERGE_STRICT_WABA_VALIDATION || '').trim().toLowerCase() === '1' || String(process.env.CONCIERGE_STRICT_WABA_VALIDATION || '').trim().toLowerCase() === 'true'; let wabaWarning = null; try { const creds = await getOrgWhatsAppCredentials(String(botOrganizationId)); if (creds?.accessToken && wabaPhoneNumberId) { const chk = await assertPhoneNumberOnWaba(creds.wabaId, wabaPhoneNumberId, creds.accessToken); if (!chk.ok) { if (strictWaba) { return h.response(error('VALIDATION_ERROR', chk.message)).code(400); } wabaWarning = chk.message; console.warn('[concierge] create bot: phone WABA check skipped:', chk.message); } } } catch (metaErr) { console.warn('[concierge] create bot: Meta check error (continuing):', metaErr.message); if (strictWaba) { return h.response(error('VALIDATION_ERROR', metaErr.message || 'WhatsApp validation failed')).code(400); } wabaWarning = metaErr.message || 'WhatsApp validation could not be completed'; } let bot; try { const seededFaqs = normalizeFaqsPayload(faqs) || seedVenueFaqs(); bot = await ConciergeBot.create({ organization: botOrganizationId, event: eventId, name: name || event.name, wabaPhoneNumberId: String(wabaPhoneNumberId).trim(), accentColor: String(accentColor || '').trim() || 'rgb(13, 148, 136)', greeting, registration, knowledge, faqs: ensureVenueFaqs(seededFaqs), enabled: Boolean(enabled), status: enabled ? 'ACTIVE' : 'DRAFT', }); } catch (e) { if (e && e.code === 11000) { const msg = e.keyPattern?.wabaPhoneNumberId != null ? 'This WhatsApp phone number ID is already used by another chatbot.' : 'This event already has a chatbot.'; return h.response(error('VALIDATION_ERROR', msg)).code(400); } console.error('[concierge] create bot failed:', e); return h.response(error('INTERNAL_ERROR', e.message || 'Failed to create chatbot')).code(500); } console.log('[concierge] created bot', bot._id.toString(), 'event', eventId, 'org', botOrganizationId); const populated = await ConciergeBot.findById(bot._id) .populate('event', 'name startDate endDate status') .lean(); return h .response( success( { bot: populated, ...(wabaWarning ? { warning: wabaWarning } : {}) }, 'Chatbot created', 201, ), ) .code(201); }; async function withEnsuredFaqs(botDoc) { if (!botDoc) return botDoc; const ensured = ensureVenueFaqs(botDoc.faqs); const current = Array.isArray(botDoc.faqs) ? botDoc.faqs : []; const needsSave = current.length !== ensured.length || ensured.some((row, i) => { const cur = current[i]; return ( !cur || String(cur.key || '') !== String(row.key || '') || String(cur.category) !== row.category || String(cur.question) !== row.question || String(cur.answerFormat || '') !== String(row.answerFormat || '') ); }); if (needsSave) { await ConciergeBot.updateOne({ _id: botDoc._id }, { $set: { faqs: ensured } }); botDoc.faqs = ensured; } else if (!Array.isArray(botDoc.faqs) || !botDoc.faqs.length) { botDoc.faqs = ensured; } return botDoc; } const get = 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); await withEnsuredFaqs(bot); const aggregates = await botAggregates(bot.event._id || bot.event); return h.response(success({ bot, aggregates })); }; const update = 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 p = request.payload; const strictWaba = String(process.env.CONCIERGE_STRICT_WABA_VALIDATION || '').trim().toLowerCase() === '1' || String(process.env.CONCIERGE_STRICT_WABA_VALIDATION || '').trim().toLowerCase() === 'true'; if (p.name != null) bot.name = p.name; if (p.wabaPhoneNumberId != null) { const nextPhone = String(p.wabaPhoneNumberId).trim(); const phoneChanged = nextPhone !== String(bot.wabaPhoneNumberId || '').trim(); if (phoneChanged) { try { const creds = await getOrgWhatsAppCredentials(String(bot.organization)); if (creds?.accessToken && nextPhone) { const chk = await assertPhoneNumberOnWaba(creds.wabaId, nextPhone, creds.accessToken); if (!chk.ok) { if (strictWaba) { return h.response(error('VALIDATION_ERROR', chk.message)).code(400); } console.warn('[concierge] update bot: phone WABA check skipped:', chk.message); } } } catch (metaErr) { if (strictWaba) { return h .response(error('VALIDATION_ERROR', metaErr.message || 'WhatsApp validation failed')) .code(400); } console.warn('[concierge] update bot: Meta check error (continuing):', metaErr.message); } bot.wabaPhoneNumberId = nextPhone; } } if (p.accentColor !== undefined) { const next = String(p.accentColor || '').trim(); bot.accentColor = next || 'rgb(13, 148, 136)'; } if (p.greeting) bot.greeting = { ...bot.greeting.toObject?.() || bot.greeting, ...p.greeting }; if (p.registration) { bot.registration = { ...bot.registration.toObject?.() || bot.registration, ...p.registration }; } if (p.knowledge) bot.knowledge = { ...bot.knowledge.toObject?.() || bot.knowledge, ...p.knowledge }; if (p.faqs != null) { const normalized = normalizeFaqsPayload(p.faqs); bot.faqs = ensureVenueFaqs(normalized || []); } else { bot.faqs = ensureVenueFaqs(bot.faqs); } if (p.enabled != null) { bot.enabled = Boolean(p.enabled); if (bot.enabled) bot.status = 'ACTIVE'; if (!bot.enabled && bot.status === 'ACTIVE') bot.status = 'DRAFT'; } if (p.reception != null) { let next; try { next = normalizeReceptionPayload(p.reception); } catch (e) { return h.response(error('VALIDATION_ERROR', e.message || 'Invalid reception settings')).code(400); } 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 }; bot.markModified('reception'); if (next.enabled && bot.status === 'ARCHIVED') { bot.status = bot.enabled ? 'ACTIVE' : 'DRAFT'; } } 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, eventChanged: eventRebound })); }; const remove = 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); await ConciergeBot.deleteOne({ _id: bot._id }); return h.response(success({ deleted: true })); }; const getByEvent = async (request, h) => { const user = request.auth.credentials.user; const { eventId } = request.params; const event = await Event.findById(eventId).lean(); if (!event) return h.response(error('NOT_FOUND', 'Event not found')).code(404); if (!userCanAccessOrganization(user, event.organization)) { return h.response(error('FORBIDDEN', 'Access denied')).code(403); } const bot = await ConciergeBot.findOne({ event: eventId }).populate('event', 'name').lean(); if (!bot) return h.response(error('NOT_FOUND', 'No chatbot for this event')).code(404); await withEnsuredFaqs(bot); const aggregates = await botAggregates(eventId); return h.response(success({ bot, aggregates })); }; const listSources = 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 eventId = bot.event._id || bot.event; const sources = await KnowledgeSource.find({ eventId }).sort({ createdAt: -1 }).lean(); return h.response(success({ sources })); }; const createSource = 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 eventId = bot.event._id || bot.event; const { type, sourcePath, displayName, crawlScope: crawlScopeInput } = request.payload; const doc = { eventId, type, sourcePath, displayName, status: 'PENDING', }; if (type === 'url') { const { resolveCrawlScope } = require('../utils/conciergeUrlCrawl'); const requested = crawlScopeInput === 'domain' || crawlScopeInput === 'path' ? crawlScopeInput : 'auto'; const scope = resolveCrawlScope(sourcePath, requested); doc.crawlScope = scope.mode; doc.crawlPathPrefix = scope.pathPrefix || null; } const source = await KnowledgeSource.create(doc); return h.response(success(source, 'Source created', 201)).code(201); }; const deleteSource = 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 eventId = bot.event._id || bot.event; const source = await KnowledgeSource.findOne({ _id: request.params.sourceId, eventId, }); if (!source) return h.response(error('NOT_FOUND', 'Source not found')).code(404); await ContextChunk.deleteMany({ sourceId: source._id }); await source.deleteOne(); return h.response(success({ deleted: true })); }; const syncSource = async (request, h) => { console.log('[concierge] syncSource called', { params: request.params, payload: request.payload }); 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 eventId = bot.event._id || bot.event; const source = await KnowledgeSource.findOne({ _id: request.params.sourceId, eventId, }); console.log('[concierge] syncSource resolved source', source && { _id: source._id, type: source.type, displayName: source.displayName }); if (!source) return h.response(error('NOT_FOUND', 'Source not found')).code(404); source.status = 'PENDING'; source.errorMessage = null; await source.save(); await enqueueConciergeIngest({ sourceId: String(source._id), botId: String(bot._id), eventId: String(eventId), type: source.type, }); try { const { runConciergeIngest } = require('../utils/conciergeIngest'); await runConciergeIngest(String(source._id)); const refreshed = await KnowledgeSource.findById(source._id).lean(); return h.response(success(refreshed, 'Sync completed')); } catch (e) { console.warn('[concierge] syncSource immediate ingest failed:', e.message || e); return h.response(success(source, 'Sync queued')); } }; const listChunks = 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 eventId = bot.event._id || bot.event; const source = await KnowledgeSource.findOne({ _id: request.params.sourceId, eventId, }); if (!source) return h.response(error('NOT_FOUND', 'Source not found')).code(404); const q = { sourceId: source._id }; if (request.query.staging === 'true') q.isActive = false; const chunks = await ContextChunk.find(q).sort({ createdAt: 1 }).lean(); return h.response(success({ chunks })); }; const patchChunk = 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 eventId = bot.event._id || bot.event; const chunk = await ContextChunk.findOne({ _id: request.params.chunkId, eventId, }); if (!chunk) return h.response(error('NOT_FOUND', 'Chunk not found')).code(404); if (request.payload.rawText != null) chunk.rawText = request.payload.rawText; await chunk.save(); return h.response(success(chunk)); }; const deleteChunk = 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 eventId = bot.event._id || bot.event; const chunk = await ContextChunk.findOne({ _id: request.params.chunkId, eventId, }); if (!chunk) return h.response(error('NOT_FOUND', 'Chunk not found')).code(404); await chunk.deleteOne(); return h.response(success({ deleted: true })); }; const approveSource = 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 eventId = bot.event._id || bot.event; const source = await KnowledgeSource.findOne({ _id: request.params.sourceId, eventId, }); if (!source) return h.response(error('NOT_FOUND', 'Source not found')).code(404); const result = await ContextChunk.updateMany( { sourceId: source._id }, { $set: { isActive: true } }, ); return h.response(success({ modified: result.modifiedCount })); }; const clearKnowledge = 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 eventId = bot.event._id || bot.event; await ContextChunk.deleteMany({ eventId }); await KnowledgeSource.deleteMany({ eventId }); bot.lastKnowledgeClearedAt = new Date(); await bot.save(); return h.response(success({ cleared: true })); }; // const syncEventKnowledge = async (request, h) => { // console.log('[concierge] syncEventKnowledge called', { params: request.params }); // 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); // if (bot.knowledge?.includeEventDetails === false) { // return h // .response( // error( // 'VALIDATION_ERROR', // 'Event programme indexing is disabled for this bot. Enable "Include event programme in knowledge base" in settings first.', // ), // ) // .code(400); // } // const eventId = bot.event._id || bot.event; // const programmeDisplayName = 'Event programme & links'; // async function ensureSource(type, displayName) { // let source = await KnowledgeSource.findOne({ eventId, type }); // if (!source) { // source = await KnowledgeSource.create({ // eventId, // type, // sourcePath: String(eventId), // displayName, // status: 'PENDING', // }); // } else { // source.status = 'PENDING'; // source.errorMessage = null; // source.displayName = displayName; // await source.save(); // } // await enqueueConciergeIngest({ // sourceId: String(source._id), // botId: String(bot._id), // eventId: String(eventId), // type, // }); // return source; // } // const eventSource = await ensureSource('event', programmeDisplayName); // bot.knowledge = bot.knowledge || {}; // bot.knowledge.lastEventSnapshotAt = new Date(); // if (bot.knowledge?.includeFoodRecommendations !== false) { // await ensureSource('food', 'Food Recommendations'); // bot.knowledge.lastFoodSnapshotAt = new Date(); // } // if (bot.knowledge?.includePlacesRecommendations !== false) { // await ensureSource('places', 'Tourist Places Recommendation'); // bot.knowledge.lastPlacesSnapshotAt = new Date(); // } // await bot.save(); // return h.response(success(eventSource, 'Event programme sync queued')); // }; // const eventId = bot.event._id || bot.event; // const programmeDisplayName = 'Event programme & links'; // async function ensureSource(type, displayName) { // let source = await KnowledgeSource.findOne({ eventId, type }); // if (!source) { // source = await KnowledgeSource.create({ // eventId, // type, // sourcePath: String(eventId), // displayName, // status: 'PENDING', // }); // } else { // source.status = 'PENDING'; // source.errorMessage = null; // source.displayName = displayName; // await source.save(); // } // await enqueueConciergeIngest({ // sourceId: String(source._id), // botId: String(bot._id), // eventId: String(eventId), // type, // }); // return source; // } // const eventSource = await ensureSource('event', programmeDisplayName); // bot.knowledge = bot.knowledge || {}; // bot.knowledge.lastEventSnapshotAt = new Date(); // if (bot.knowledge?.includeFoodRecommendations !== false) { // await ensureSource('food', 'Food Recommendations'); // bot.knowledge.lastFoodSnapshotAt = new Date(); // } // if (bot.knowledge?.includePlacesRecommendations !== false) { // await ensureSource('places', 'Tourist Places Recommendation'); // bot.knowledge.lastPlacesSnapshotAt = new Date(); // } // await bot.save(); // return h.response(success(eventSource, 'Event programme sync queued')); // }; // const syncPlacesKnowledge = async (request, h) => { // console.log('[concierge] syncPlacesKnowledge called', { params: request.params }); // 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); // } // if (bot.knowledge?.includePlacesRecommendations === false) { // return h.response( // error('VALIDATION_ERROR', 'Places recommendations are disabled') // ).code(400); // } // const eventId = bot.event._id || bot.event; // let source = await KnowledgeSource.findOne({ // eventId, // type: 'places', // }); // if (!source) { // source = await KnowledgeSource.create({ // eventId, // type: 'places', // sourcePath: String(eventId), // displayName: 'Tourist Places Recommendation', // status: 'PENDING', // }); // } else { // source.status = 'PENDING'; // source.errorMessage = null; // source.displayName = 'Tourist Places Recommendation'; // await source.save(); // } // await enqueueConciergeIngest({ // sourceId: String(source._id), // botId: String(bot._id), // eventId: String(eventId), // type: 'places', // }); // // Also attempt immediate ingestion for places so the UI doesn't stay PENDING // try { // const { runConciergeIngest } = require('../utils/conciergeIngest'); // await runConciergeIngest(String(source._id)); // const refreshed = await KnowledgeSource.findById(source._id).lean(); // return h.response(success(refreshed, 'Places sync completed')); // } catch (e) { // return h.response(success(source, 'Places sync queued')); // } // }; const syncEventKnowledge = async (request, h) => { console.log('[concierge] syncEventKnowledge called', { params: request.params }); 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); } if (bot.knowledge?.includeEventDetails === false) { return h .response( error( 'VALIDATION_ERROR', 'Event programme indexing is disabled for this bot. Enable "Include event programme in knowledge base" in settings first.' ) ) .code(400); } const eventId = bot.event._id || bot.event; let source = await KnowledgeSource.findOne({ eventId, type: 'event', }); if (!source) { source = await KnowledgeSource.create({ eventId, type: 'event', sourcePath: String(eventId), displayName: 'Event programme & links', status: 'PENDING', }); } else { source.status = 'PENDING'; source.errorMessage = null; source.displayName = 'Event programme & links'; await source.save(); } await enqueueConciergeIngest({ sourceId: String(source._id), botId: String(bot._id), eventId: String(eventId), type: 'event', }); bot.knowledge = bot.knowledge || {}; bot.knowledge.lastEventSnapshotAt = new Date(); await bot.save(); return h.response(success(source, 'Event programme sync queued')); }; const syncFoodKnowledge = async (request, h) => { console.log('[concierge] syncFoodKnowledge called', { params: request.params }); 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); } if (bot.knowledge?.includeFoodRecommendations === false) { return h .response( error( 'VALIDATION_ERROR', 'Food recommendations indexing is disabled.' ) ) .code(400); } const eventId = bot.event._id || bot.event; let source = await KnowledgeSource.findOne({ eventId, type: 'food', }); if (!source) { source = await KnowledgeSource.create({ eventId, type: 'food', sourcePath: String(eventId), displayName: 'Food Recommendations', status: 'PENDING', }); } else { source.status = 'PENDING'; source.errorMessage = null; source.displayName = 'Food Recommendations'; await source.save(); } await enqueueConciergeIngest({ sourceId: String(source._id), botId: String(bot._id), eventId: String(eventId), type: 'food', }); bot.knowledge = bot.knowledge || {}; bot.knowledge.lastFoodSnapshotAt = new Date(); await bot.save(); return h.response(success(source, 'Food sync queued')); }; const syncPlacesKnowledge = async (request, h) => { console.log('[concierge] syncPlacesKnowledge called', { params: request.params, }); 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); } if (bot.knowledge?.includePlacesRecommendations === false) { console.warn('[concierge][places] blocked by knowledge gate', { botId: request.params?.botId, eventId, includePlacesRecommendations: bot.knowledge?.includePlacesRecommendations, knowledge: bot.knowledge, }); return h .response( error( 'VALIDATION_ERROR', 'Places recommendations are disabled' ) ) .code(400); } const eventId = bot.event._id || bot.event; let source = await KnowledgeSource.findOne({ eventId, type: 'places', }); if (!source) { source = await KnowledgeSource.create({ eventId, type: 'places', sourcePath: String(eventId), displayName: 'Tourist Places Recommendation', status: 'PENDING', }); } else { source.status = 'PENDING'; source.errorMessage = null; source.displayName = 'Tourist Places Recommendation'; await source.save(); } await enqueueConciergeIngest({ sourceId: String(source._id), botId: String(bot._id), eventId: String(eventId), type: 'places', }); try { const { runConciergeIngest } = require('../utils/conciergeIngest'); await runConciergeIngest(String(source._id)); const refreshed = await KnowledgeSource.findById(source._id).lean(); bot.knowledge = bot.knowledge || {}; bot.knowledge.lastPlacesSnapshotAt = new Date(); await bot.save(); return h.response(success(refreshed, 'Places sync completed')); } catch (e) { return h.response(success(source, 'Places sync queued')); } }; 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, deleteSource, syncSource, listChunks, patchChunk, deleteChunk, approveSource, clearKnowledge, syncEventKnowledge, syncFoodKnowledge, syncPlacesKnowledge, };