EventBuzz_Backend/src/controllers/conciergeBot.controller.js

1138 lines
38 KiB
JavaScript

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=<id> 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,
};