- Remove apps/dispatch/ (100% replaced by ops dispatch module, unmaintained) - Commit services/targo-hub/lib/ (24 modules, 6290 lines — was never tracked) - Commit services/docuseal + services/legacy-db docker-compose configs - Extract client app composables: useOTP, useAddressSearch, catalog data, format utils - Refactor CartPage.vue 630→175 lines, CatalogPage.vue 375→95 lines - Clean hardcoded credentials from config.js fallback values - Add client portal: catalog, cart, checkout, OTP verification, address search - Add ops: NetworkPage, AgentFlowsPage, ConversationPanel, UnifiedCreateModal - Add ops composables: useBestTech, useConversations, usePermissions, useScanner - Add field app: scanner composable, docker/nginx configs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
500 lines
21 KiB
JavaScript
500 lines
21 KiB
JavaScript
'use strict'
|
|
const crypto = require('crypto')
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
const cfg = require('./config')
|
|
const { log, json, parseBody, lookupCustomerByPhone, createCommunication, erpFetch } = require('./helpers')
|
|
const sse = require('./sse')
|
|
|
|
const DATA_DIR = path.join(__dirname, '..', 'data')
|
|
const CONV_FILE = path.join(DATA_DIR, 'conversations.json')
|
|
const conversations = new Map()
|
|
const pushSubscriptions = new Map()
|
|
const agentTimers = new Map()
|
|
|
|
function loadFromDisk () {
|
|
try {
|
|
if (!fs.existsSync(CONV_FILE)) return
|
|
for (const conv of JSON.parse(fs.readFileSync(CONV_FILE, 'utf8'))) conversations.set(conv.token, conv)
|
|
log(`Loaded ${conversations.size} conversations from disk`)
|
|
} catch (e) { log('Failed to load conversations:', e.message) }
|
|
}
|
|
|
|
let saveTimer = null
|
|
function saveToDisk () {
|
|
if (saveTimer) return
|
|
saveTimer = setTimeout(() => {
|
|
saveTimer = null
|
|
try {
|
|
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
|
|
fs.writeFileSync(CONV_FILE + '.tmp', JSON.stringify([...conversations.values()], null, 0))
|
|
fs.renameSync(CONV_FILE + '.tmp', CONV_FILE)
|
|
} catch (e) { log('Failed to save conversations:', e.message) }
|
|
}, 500)
|
|
}
|
|
|
|
loadFromDisk()
|
|
|
|
function generateToken () { return crypto.randomBytes(4).toString('base64url') }
|
|
|
|
function createConversation ({ phone, customer, customerName, agentEmail, subject }) {
|
|
const token = generateToken()
|
|
const now = new Date().toISOString()
|
|
const conv = {
|
|
token, phone, customer: customer || null, customerName: customerName || 'Client',
|
|
agentEmail: agentEmail || null, subject: subject || 'Conversation',
|
|
status: 'active', messages: [], createdAt: now, lastActivity: now,
|
|
expiresAt: new Date(Date.now() + 48 * 3600000).toISOString(),
|
|
smsCount: 0, pushSub: null,
|
|
}
|
|
conversations.set(token, conv)
|
|
saveToDisk()
|
|
log(`Conversation created: ${token} for ${phone} (${customerName})`)
|
|
return conv
|
|
}
|
|
|
|
function getConversation (token) {
|
|
const conv = conversations.get(token)
|
|
if (!conv) return null
|
|
if (new Date(conv.expiresAt) < new Date()) { conv.status = 'expired'; return null }
|
|
return conv
|
|
}
|
|
|
|
function addMessage (conv, { from, text, type = 'text', via = 'web' }) {
|
|
const msg = { id: crypto.randomUUID(), from, text, type, via, ts: new Date().toISOString() }
|
|
conv.messages.push(msg)
|
|
conv.lastActivity = msg.ts
|
|
saveToDisk()
|
|
sse.broadcast('conv:' + conv.token, 'conv-message', { token: conv.token, message: msg })
|
|
sse.broadcast('conv-client:' + conv.token, 'conv-message', { message: msg })
|
|
sse.broadcast('conversations', 'conv-message', { token: conv.token, customer: conv.customer, customerName: conv.customerName, message: msg })
|
|
return msg
|
|
}
|
|
|
|
function registerPush (token, subscription) {
|
|
const conv = getConversation(token)
|
|
if (!conv) return false
|
|
conv.pushSub = subscription
|
|
pushSubscriptions.set(token, subscription)
|
|
saveToDisk()
|
|
log(`Push subscription registered for conv ${token}`)
|
|
return true
|
|
}
|
|
|
|
async function sendPushNotification (conv, message) {
|
|
if (!conv.pushSub || !cfg.VAPID_PUBLIC_KEY || !cfg.VAPID_PRIVATE_KEY) return false
|
|
try {
|
|
const webpush = require('web-push')
|
|
webpush.setVapidDetails('mailto:support@gigafibre.ca', cfg.VAPID_PUBLIC_KEY, cfg.VAPID_PRIVATE_KEY)
|
|
await webpush.sendNotification(conv.pushSub, JSON.stringify({
|
|
title: 'Gigafibre', body: message.text?.substring(0, 100) || 'Nouveau message',
|
|
url: `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}`, tag: 'conv-' + conv.token,
|
|
}))
|
|
log(`Push sent for conv ${conv.token}`)
|
|
return true
|
|
} catch (e) {
|
|
log(`Push failed for conv ${conv.token}:`, e.message)
|
|
if (e.statusCode === 410 || e.statusCode === 404) {
|
|
conv.pushSub = null; pushSubscriptions.delete(conv.token); saveToDisk()
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function sendSmsFallback (conv, text) {
|
|
if (conv.smsCount >= 2) { log(`SMS limit reached for conv ${conv.token}, skipping`); return false }
|
|
const { sendSmsInternal } = require('./twilio')
|
|
const success = await sendSmsInternal(conv.phone, text, conv.customer)
|
|
if (success) { conv.smsCount++; saveToDisk() }
|
|
return success
|
|
}
|
|
|
|
async function notifyCustomer (conv, message) {
|
|
const pushed = await sendPushNotification(conv, message)
|
|
if (pushed) return 'push'
|
|
const link = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}`
|
|
const smsText = `${message.text?.substring(0, 100) || 'Nouveau message'}\n\nRépondre: ${link}`
|
|
return (await sendSmsFallback(conv, smsText)) ? 'sms' : 'none'
|
|
}
|
|
|
|
function findConversationByPhone (phone) {
|
|
const digits = phone.replace(/\D/g, '').slice(-10)
|
|
for (const [, conv] of conversations) {
|
|
if (conv.status !== 'active') continue
|
|
if (conv.phone.replace(/\D/g, '').slice(-10) === digits) return conv
|
|
}
|
|
return null
|
|
}
|
|
|
|
async function autoLinkIncomingSms (phone, text) {
|
|
const existing = findConversationByPhone(phone)
|
|
if (existing) return existing
|
|
|
|
const customer = await lookupCustomerByPhone(phone)
|
|
const { sendSmsInternal } = require('./twilio')
|
|
|
|
if (!customer) {
|
|
const conv = createConversation({ phone, customer: null, customerName: phone, agentEmail: null, subject: 'SMS entrant (inconnu)' })
|
|
conv.smsCount = 0
|
|
addMessage(conv, { from: 'customer', text, via: 'sms' })
|
|
const link = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}`
|
|
const visibleText = 'Bonjour, merci pour votre message! Un agent Gigafibre vous répondra sous peu.'
|
|
// Always add the greeting to the conversation (even if SMS fails)
|
|
addMessage(conv, { from: 'agent', text: visibleText, via: 'sms' })
|
|
sendSmsInternal(phone, `${visibleText}\n\nPour continuer en ligne:\n${link}`, null).then(sid => {
|
|
if (sid) conv.smsCount++
|
|
}).catch(e => log('Auto-reply SMS error:', e.message))
|
|
log(`Auto-created conv ${conv.token} for unknown caller ${phone}`)
|
|
return conv
|
|
}
|
|
|
|
const conv = createConversation({
|
|
phone, customer: customer.name, customerName: customer.customer_name || customer.name,
|
|
agentEmail: null, subject: 'SMS entrant',
|
|
})
|
|
conv.smsCount = 0
|
|
addMessage(conv, { from: 'customer', text, via: 'sms' })
|
|
const link = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}`
|
|
const firstName = (customer.customer_name || '').split(' ')[0] || ''
|
|
const greeting = firstName ? `Bonjour ${firstName}, merci` : 'Bonjour, merci'
|
|
const visibleText = `${greeting} pour votre message! Un agent vous répondra sous peu.`
|
|
// Always add the greeting to the conversation (even if SMS fails)
|
|
addMessage(conv, { from: 'agent', text: visibleText, via: 'sms' })
|
|
sendSmsInternal(phone, `${visibleText}\n\nPour continuer la conversation en ligne:\n${link}`, customer.name).then(sid => {
|
|
if (sid) {
|
|
conv.smsCount++
|
|
log(`Auto-reply sent to ${phone} (${sid})`)
|
|
}
|
|
}).catch(e => log('Auto-reply SMS error:', e.message))
|
|
log(`Auto-linked SMS from ${phone} → customer ${customer.name}, conv ${conv.token}`)
|
|
return conv
|
|
}
|
|
|
|
function triggerAgent (conv) {
|
|
if (agentTimers.has(conv.token)) clearTimeout(agentTimers.get(conv.token))
|
|
agentTimers.set(conv.token, setTimeout(() => {
|
|
agentTimers.delete(conv.token)
|
|
runAgentForConv(conv)
|
|
}, 1500))
|
|
}
|
|
|
|
async function runAgentForConv (conv) {
|
|
if (!cfg.AI_API_KEY || !conv.customer || conv.status !== 'active') return
|
|
sse.broadcast('conv-client:' + conv.token, 'conv-typing', { typing: true })
|
|
try {
|
|
const { runAgent } = require('./agent')
|
|
const recentMessages = conv.messages.slice(-20).filter(m => m.from === 'customer' || m.from === 'agent')
|
|
const reply = await runAgent(conv.customer, conv.customerName, recentMessages, { token: conv.token })
|
|
if (reply && conv.status === 'active') {
|
|
addMessage(conv, { from: 'agent', text: reply, type: 'text', via: 'ai' })
|
|
|
|
// If the last customer message came via SMS, send AI reply back via SMS + append web chat link
|
|
const lastCustomerMsg = [...conv.messages].reverse().find(m => m.from === 'customer')
|
|
if (lastCustomerMsg?.via === 'sms' && conv.phone) {
|
|
const chatLink = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}`
|
|
const smsBody = `${reply}\n\nContinuer en ligne: ${chatLink}`
|
|
const { sendSmsInternal } = require('./twilio')
|
|
sendSmsInternal(conv.phone, smsBody, conv.customer).then(sid => {
|
|
if (sid) { conv.smsCount = (conv.smsCount || 0) + 1; log(`AI SMS reply sent to ${conv.phone} (${sid})`) }
|
|
}).catch(e => log('AI SMS reply error:', e.message))
|
|
}
|
|
|
|
createCommunication({
|
|
communication_type: 'Communication', communication_medium: 'Chat',
|
|
sent_or_received: 'Sent', sender: 'ai@gigafibre.ca', sender_full_name: 'Gigafibre Assistant',
|
|
phone_no: conv.phone, content: reply, subject: `Chat AI: ${conv.subject}`,
|
|
reference_doctype: 'Customer', reference_name: conv.customer, status: 'Linked',
|
|
}).catch(() => {})
|
|
}
|
|
} catch (e) { log('Agent error for conv ' + conv.token + ':', e.message) }
|
|
sse.broadcast('conv-client:' + conv.token, 'conv-typing', { typing: false })
|
|
}
|
|
|
|
function listConversations ({ includeAll = false } = {}) {
|
|
const out = []
|
|
for (const [, conv] of conversations) {
|
|
const expired = new Date(conv.expiresAt) < new Date()
|
|
if (!includeAll && (conv.status !== 'active' || expired)) continue
|
|
out.push({
|
|
token: conv.token, phone: conv.phone, customer: conv.customer,
|
|
customerName: conv.customerName, agentEmail: conv.agentEmail, subject: conv.subject,
|
|
status: expired ? 'expired' : conv.status, messageCount: conv.messages.length,
|
|
lastActivity: conv.lastActivity, createdAt: conv.createdAt,
|
|
lastMessage: conv.messages.length ? conv.messages[conv.messages.length - 1] : null,
|
|
})
|
|
}
|
|
out.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity))
|
|
|
|
const discussions = {}
|
|
for (const c of out) {
|
|
const phoneKey = c.phone.replace(/\D/g, '').slice(-10)
|
|
const key = c.status === 'active' ? `${phoneKey}:active` : `${phoneKey}:${c.createdAt.slice(0, 10)}`
|
|
if (!discussions[key]) {
|
|
const day = c.status === 'active' ? c.lastActivity.slice(0, 10) : c.createdAt.slice(0, 10)
|
|
discussions[key] = {
|
|
id: key, phone: c.phone, customer: c.customer, customerName: c.customerName,
|
|
date: day, conversations: [], messages: [], lastActivity: c.lastActivity, status: 'closed',
|
|
}
|
|
}
|
|
discussions[key].conversations.push(c)
|
|
if (c.status === 'active') discussions[key].status = 'active'
|
|
if (c.lastActivity > discussions[key].lastActivity) {
|
|
discussions[key].lastActivity = c.lastActivity
|
|
if (discussions[key].status === 'active') discussions[key].date = c.lastActivity.slice(0, 10)
|
|
}
|
|
}
|
|
|
|
for (const d of Object.values(discussions)) {
|
|
const allMsgs = []
|
|
for (const c of d.conversations) {
|
|
const conv = conversations.get(c.token)
|
|
if (!conv) continue
|
|
for (const m of conv.messages) allMsgs.push({ ...m, convToken: c.token })
|
|
}
|
|
allMsgs.sort((a, b) => a.ts.localeCompare(b.ts))
|
|
d.messages = allMsgs
|
|
d.messageCount = allMsgs.length
|
|
d.lastMessage = allMsgs.length ? allMsgs[allMsgs.length - 1] : null
|
|
}
|
|
|
|
return { conversations: out, discussions: Object.values(discussions).sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)) }
|
|
}
|
|
|
|
function deleteConversation (token) {
|
|
const existed = conversations.delete(token)
|
|
if (existed) saveToDisk()
|
|
return existed
|
|
}
|
|
|
|
function deleteDiscussionByTokens (tokens) {
|
|
let count = 0
|
|
for (const t of tokens) if (conversations.delete(t)) count++
|
|
if (count) saveToDisk()
|
|
return count
|
|
}
|
|
|
|
function deleteDiscussion (phone, date) {
|
|
const phoneDigits = phone.replace(/\D/g, '').slice(-10)
|
|
const tokensToDelete = []
|
|
for (const [token, conv] of conversations) {
|
|
if (conv.phone.replace(/\D/g, '').slice(-10) !== phoneDigits) continue
|
|
if (date === 'active') { if (conv.status === 'active') tokensToDelete.push(token) }
|
|
else if (conv.createdAt.slice(0, 10) === date && conv.status !== 'active') tokensToDelete.push(token)
|
|
}
|
|
for (const t of tokensToDelete) conversations.delete(t)
|
|
if (tokensToDelete.length) saveToDisk()
|
|
return tokensToDelete.length
|
|
}
|
|
|
|
async function archiveDiscussion (tokens) {
|
|
const allMsgs = []
|
|
let customer = null, customerName = null, phone = null, subject = null
|
|
for (const token of tokens) {
|
|
const conv = conversations.get(token)
|
|
if (!conv) continue
|
|
if (!customer && conv.customer) customer = conv.customer
|
|
if (!customerName && conv.customerName) customerName = conv.customerName
|
|
if (!phone) phone = conv.phone
|
|
if (!subject && conv.subject) subject = conv.subject
|
|
for (const m of conv.messages) allMsgs.push(m)
|
|
}
|
|
if (!allMsgs.length) return null
|
|
|
|
// If no customer linked, try to lookup by phone number
|
|
if (!customer && phone) {
|
|
try {
|
|
const found = await lookupCustomerByPhone(phone)
|
|
if (found) {
|
|
customer = found.name
|
|
if (!customerName || customerName === phone) customerName = found.customer_name || found.name
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
allMsgs.sort((a, b) => a.ts.localeCompare(b.ts))
|
|
|
|
const lines = allMsgs.map(m => {
|
|
if (m.from === 'system') return `--- ${m.text} ---`
|
|
const sender = m.from === 'customer' ? (customerName || phone || 'Client')
|
|
: m.via === 'ai' ? 'AI Assistant' : 'Agent'
|
|
const time = new Date(m.ts).toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' })
|
|
const via = m.via === 'sms' ? ' [SMS]' : m.via === 'ai' ? ' [AI]' : ''
|
|
return `**${sender}**${via} (${time}):\n${m.text}`
|
|
})
|
|
|
|
const issueSubject = `[Archivé] ${subject || 'Discussion'} — ${customerName || phone}`
|
|
const issueData = {
|
|
subject: issueSubject, status: 'Closed', priority: 'Low', issue_type: 'SMS/Chat',
|
|
raised_by: phone || '', description: lines.join('\n\n'),
|
|
}
|
|
// Only set customer if we have a valid one — avoid sending null/undefined to ERPNext
|
|
if (customer) issueData.customer = customer
|
|
|
|
try {
|
|
const result = await erpFetch('/api/resource/Issue', { method: 'POST', body: JSON.stringify(issueData) })
|
|
const issueName = result?.data?.name
|
|
log(`Archived ${tokens.length} conversations → Issue ${issueName} (customer: ${customer || 'none'})`)
|
|
deleteDiscussionByTokens(tokens)
|
|
return { issue: issueName, messagesArchived: allMsgs.length, customer: customer || null }
|
|
} catch (e) { log('Archive error:', e.message); throw e }
|
|
}
|
|
|
|
const TK = /^\/conversations\/([A-Za-z0-9_-]{5,10})$/
|
|
const TK_SUB = /^\/conversations\/([A-Za-z0-9_-]{5,10})\//
|
|
|
|
async function handle (req, res, method, p, url) {
|
|
if (p === '/conversations' && method === 'POST') {
|
|
const body = await parseBody(req)
|
|
const { phone, customer, customerName, subject } = body
|
|
if (!phone) return json(res, 400, { error: 'Missing phone' })
|
|
const agentEmail = req.headers['x-authentik-email'] || null
|
|
const conv = createConversation({ phone, customer, customerName, agentEmail, subject })
|
|
const link = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}`
|
|
const smsText = subject
|
|
? `Gigafibre — ${subject}\n\nContinuez la conversation ici:\n${link}`
|
|
: `Gigafibre — Vous avez un nouveau message.\n\nRépondre ici:\n${link}`
|
|
try {
|
|
const { sendSmsInternal } = require('./twilio')
|
|
await sendSmsInternal(phone, smsText, customer)
|
|
conv.smsCount++; saveToDisk()
|
|
} catch (e) { log('Failed to send magic link SMS:', e.message) }
|
|
if (customer) {
|
|
await createCommunication({
|
|
communication_type: 'Communication', communication_medium: 'SMS',
|
|
sent_or_received: 'Sent', sender: 'sms@gigafibre.ca', sender_full_name: 'Targo Ops',
|
|
phone_no: phone, content: smsText, subject: 'Conversation link sent',
|
|
reference_doctype: 'Customer', reference_name: customer, status: 'Linked',
|
|
}).catch(() => {})
|
|
}
|
|
return json(res, 201, { ok: true, token: conv.token, link })
|
|
}
|
|
|
|
if (p === '/conversations' && method === 'GET')
|
|
return json(res, 200, listConversations({ includeAll: url.searchParams.get('all') === '1' }))
|
|
|
|
if (p === '/conversations/archive' && method === 'POST') {
|
|
const { tokens } = await parseBody(req)
|
|
if (!tokens || !tokens.length) return json(res, 400, { error: 'Missing tokens array' })
|
|
try {
|
|
const result = await archiveDiscussion(tokens)
|
|
if (!result) return json(res, 404, { error: 'No messages found' })
|
|
sse.broadcast('conversations', 'conv-deleted', { archived: true, issue: result.issue })
|
|
return json(res, 200, { ok: true, ...result })
|
|
} catch (e) { return json(res, 500, { error: 'Archive failed: ' + e.message }) }
|
|
}
|
|
|
|
if (p === '/conversations/bulk' && method === 'DELETE') {
|
|
const { discussions: discs } = await parseBody(req)
|
|
if (!discs || !discs.length) return json(res, 400, { error: 'Missing discussions array' })
|
|
let totalDeleted = 0
|
|
for (const d of discs) {
|
|
totalDeleted += d.tokens ? deleteDiscussionByTokens(d.tokens) : (d.phone && d.date ? deleteDiscussion(d.phone, d.date) : 0)
|
|
}
|
|
sse.broadcast('conversations', 'conv-deleted', { bulk: true, count: totalDeleted })
|
|
return json(res, 200, { ok: true, deleted: totalDeleted })
|
|
}
|
|
|
|
if (p === '/conversations/discussion' && method === 'DELETE') {
|
|
const { phone, date } = await parseBody(req)
|
|
if (!phone || !date) return json(res, 400, { error: 'Missing phone or date' })
|
|
const count = deleteDiscussion(phone, date)
|
|
log(`Deleted discussion: ${phone} on ${date} (${count} conversations)`)
|
|
sse.broadcast('conversations', 'conv-deleted', { phone, date, count })
|
|
return json(res, 200, { ok: true, deleted: count })
|
|
}
|
|
|
|
let m
|
|
if ((m = p.match(TK)) && method === 'GET') {
|
|
const conv = getConversation(m[1])
|
|
if (!conv) return json(res, 404, { error: 'Conversation not found or expired' })
|
|
return json(res, 200, {
|
|
token: conv.token, customerName: conv.customerName, subject: conv.subject,
|
|
status: conv.status, messages: conv.messages, createdAt: conv.createdAt,
|
|
vapidPublicKey: cfg.VAPID_PUBLIC_KEY || null,
|
|
})
|
|
}
|
|
|
|
if ((m = p.match(TK)) && method === 'DELETE') {
|
|
const existed = deleteConversation(m[1])
|
|
if (!existed) return json(res, 404, { error: 'Conversation not found' })
|
|
log(`Deleted conversation: ${m[1]}`)
|
|
sse.broadcast('conversations', 'conv-deleted', { token: m[1] })
|
|
return json(res, 200, { ok: true })
|
|
}
|
|
|
|
if ((m = p.match(TK_SUB))) {
|
|
const sub = p.slice(p.indexOf(m[1]) + m[1].length + 1)
|
|
const token = m[1]
|
|
|
|
if (sub === 'messages' && method === 'POST') {
|
|
const conv = getConversation(token)
|
|
if (!conv) return json(res, 404, { error: 'Conversation not found or expired' })
|
|
const { text } = await parseBody(req)
|
|
if (!text || !text.trim()) return json(res, 400, { error: 'Missing text' })
|
|
const agentEmail = req.headers['x-authentik-email']
|
|
const from = agentEmail ? 'agent' : 'customer'
|
|
const msg = addMessage(conv, { from, text: text.trim(), via: 'web' })
|
|
if (from === 'agent') msg.notifiedVia = await notifyCustomer(conv, msg)
|
|
if (from === 'customer' && conv.customer) triggerAgent(conv)
|
|
if (conv.customer) {
|
|
createCommunication({
|
|
communication_type: 'Communication', communication_medium: 'Chat',
|
|
sent_or_received: from === 'agent' ? 'Sent' : 'Received',
|
|
sender: from === 'agent' ? (agentEmail || 'sms@gigafibre.ca') : conv.phone,
|
|
sender_full_name: from === 'agent' ? 'Targo Ops' : conv.customerName,
|
|
phone_no: conv.phone, content: text.trim(), subject: `Chat: ${conv.subject}`,
|
|
reference_doctype: 'Customer', reference_name: conv.customer, status: 'Linked',
|
|
}).catch(() => {})
|
|
}
|
|
return json(res, 201, { ok: true, message: msg })
|
|
}
|
|
|
|
if (sub === 'push' && method === 'POST') {
|
|
const body = await parseBody(req)
|
|
if (!body.endpoint || !body.keys) return json(res, 400, { error: 'Invalid push subscription' })
|
|
const ok = registerPush(token, body)
|
|
return json(res, ok ? 200 : 404, ok ? { ok: true } : { error: 'Conversation not found' })
|
|
}
|
|
|
|
if (sub === 'close' && method === 'POST') {
|
|
const conv = getConversation(token)
|
|
if (!conv) return json(res, 404, { error: 'Conversation not found' })
|
|
conv.status = 'closed'
|
|
addMessage(conv, { from: 'system', text: 'Conversation terminée', via: 'web' })
|
|
saveToDisk()
|
|
sse.broadcast('conversations', 'conv-closed', { token: conv.token })
|
|
return json(res, 200, { ok: true })
|
|
}
|
|
|
|
if (sub === 'sse' && method === 'GET') {
|
|
const conv = getConversation(token)
|
|
if (!conv) return json(res, 404, { error: 'Conversation not found' })
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache',
|
|
Connection: 'keep-alive', 'X-Accel-Buffering': 'no',
|
|
})
|
|
res.write(': connected\n\n')
|
|
sse.addClient(['conv-client:' + conv.token], res, 'customer:' + conv.phone)
|
|
const keepalive = setInterval(() => { try { res.write(': ping\n\n') } catch { clearInterval(keepalive) } }, 25000)
|
|
res.on('close', () => clearInterval(keepalive))
|
|
return
|
|
}
|
|
}
|
|
|
|
return json(res, 404, { error: 'Not found' })
|
|
}
|
|
|
|
module.exports = {
|
|
handle,
|
|
createConversation,
|
|
getConversation,
|
|
addMessage,
|
|
findConversationByPhone,
|
|
autoLinkIncomingSms,
|
|
triggerAgent,
|
|
notifyCustomer,
|
|
deleteConversation,
|
|
deleteDiscussion,
|
|
archiveDiscussion,
|
|
}
|