1138 lines
38 KiB
JavaScript
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,
|
|
}; |