gigafibre-fsm/services/targo-hub/lib/conversation.js
louispaulb 01bb99857f refactor(targo-hub): add erp.js wrapper + migrate 7 lib files to it
Replaces hand-rolled `erpFetch` + `encodeURIComponent(JSON.stringify(...))`
URL building with a structured wrapper: erp.get/list/listRaw/create/update/
remove/getMany/hydrateLabels/raw.

Key wins:
- erp.list auto-retries up to 5 times when v16 rejects a fetched/linked
  field with "Field not permitted in query" — the field is dropped and the
  call continues, so callers don't have to know which fields v16 allows.
- erp.hydrateLabels batches link-label resolution (customer_name,
  service_location_name, …) in one query per link field — no N+1, no
  v16 breakage.
- Consistent {ok, error, status} shape for mutations.

Migrated call sites:
- otp.js: Customer email lookup + verifyOTP customer detail fetch +
  Contact email fallback + Service Location listing
- referral.js: Referral Credit fetch / update / generate
- tech-absence-sms.js: lookupTechByPhone, set/clear absence
- conversation.js: Issue archive create
- magic-link.js: Tech lookup for /refresh
- ical.js: Tech lookup + jobs listing for iCal feed
- tech-mobile.js: 13 erpFetch sites → erp wrapper

Remaining erpFetch callers (dispatch.js, acceptance.js, payments.js,
contracts.js, checkout.js, …) deliberately left untouched this pass —
they each have 10+ sites and need individual smoke-tests.

Live-tested against production ERPNext: tech-mobile page renders 54K
bytes, no runtime errors in targo-hub logs post-restart.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 23:01:27 -04:00

502 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 } = require('./helpers')
const erp = require('./erp')
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 erp.create('Issue', issueData)
if (!result.ok) throw new Error(result.error || 'Issue create failed')
const issueName = result.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,
}