'use strict'
// ── Collaboration légère sur un document ERPNext (ticket Issue, etc.) ───────
// Objectif : intervenir VITE (indice, note, mention d'un collègue) SANS toucher l'assignation (_assign intact).
// Stocké comme Comment ERPNext (visible aussi dans Desk) ; mention = notification courriel au collègue. UI = wizard épuré côté OPS.
const cfg = require('./config')
const { log, json, parseBody, lookupCustomersByPhone, lookupCustomersByEmail, readJsonFile } = require('./helpers')
const erp = require('./erp')
// Mots « problème » (jamais un nom → toujours retirés) vs mots « rue » (retirés SEULEMENT en contexte adresse,
// pour ne pas amputer un nom comme « Saint-Pierre »).
const PROBLEM = /\b(probl[eè]me|panne|wi-?fi|internet|t[ée]l[ée]vision|t[ée]l[ée]phone|t[ée]l[ée]|lent[e]?|ralenti|coup[ée]+|d[ée]connect|bug|app|appartement)\b/gi
const STREET = /\b(rue|av|ave|avenue|boul|boulevard|ch|chemin|st|ste|saint|sainte|qc|no)\b/gi
// Normalise pour le fuzzy : minuscules + sans accents + sans séparateurs (« Louis-Paul » = « Louis-paul » = « LouisPaul »).
const normName = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '')
// Recherche client UNIFIÉE depuis un texte libre : téléphone (10 ch.), no civique (→ adresse via Service Location),
// et/ou nom (→ Customer). Insensible à la casse/accents (unaccent). Renvoie des candidats classés pour l'autosuggest.
async function searchCustomers (q) {
const s = String(q || '').trim()
if (s.length < 2) return []
const nums = s.match(/\d+/g) || []
const phone = nums.find(n => n.length >= 10)
const civic = nums.find(n => n.length >= 1 && n.length <= 6 && n !== phone)
let text = s.replace(/\d+/g, ' ').replace(PROBLEM, ' ')
if (civic) text = text.replace(STREET, ' ') // contexte adresse → on retire « rue/st/… » pour isoler le nom ; sinon on garde (« Saint-Pierre »)
text = text.replace(/[^\p{L}\s'-]/gu, ' ').replace(/\s+/g, ' ').trim()
const found = new Map() // name -> { matched, address? }
// 1) Téléphone (REST multi-champs)
if (phone) { try { for (const c of await lookupCustomersByPhone(phone, 6)) if (!found.has(c.name)) found.set(c.name, { matched: 'téléphone' }) } catch (e) { /* */ } }
// 1b) Courriel — la requête ressemble à une adresse e-mail
if (/.+@.+/.test(s)) { try { for (const c of await lookupCustomersByEmail(s, 6)) if (!found.has(c.name)) found.set(c.name, { matched: 'courriel' }) } catch (e) { /* */ } }
let p = null
try { p = require('./address-db').pool() } catch (e) { /* address-db indispo */ }
let addrResolved = false // adresse civique résolue → on NE mélange PAS les homonymes (fuzzy nom) : « 2338 rue X » = LE résident, pas les noms qui se ressemblent.
// 2) No civique → adresse (Service Location → client). Typo de dictée toléré : si rien, RQA (trigram) canonicalise la rue puis on ré-essaie.
if (p && civic && found.size < 12) {
const slByAddr = async (toks) => {
const cond = toks.length ? ' AND unaccent(lower(coalesce(sl.address_line,\'\'))) LIKE unaccent(lower($2))' : ''
const r = await p.query(
`SELECT DISTINCT sl.name, sl.customer, sl.address_line, sl.city FROM "tabService Location" sl
WHERE sl.customer IS NOT NULL AND sl.address_line ILIKE $1 ${cond} ORDER BY sl.address_line LIMIT 8`,
toks.length ? ['%' + civic + '%', '%' + toks.join('%') + '%'] : ['%' + civic + '%'])
return r.rows
}
try {
let street = text.split(/[\s-]+/).filter(w => w.length >= 3).slice(0, 2)
let rows = await slByAddr(street)
// Rue mal orthographiée / dictée (« Vinette » vs « Vinet ») → RQA fuzzy trouve la vraie rue → on ré-essaie. Seulement si la requête ressemble à une adresse (mot « rue/rang/… »).
if (!rows.length && /\b(rue|av|ave|avenue|boul|boulevard|ch|chemin|rang|mont|place|impasse|croissant|terrasse|c[oô]te)\b/i.test(s)) {
try { const canon = (await require('./address-db').searchRaw(s, 1))[0]; const rue = canon && (canon.rue || canon.odonyme_recompose_normal); if (rue) { const t = String(rue).split(/[\s-]+/).filter(w => w.length >= 3).slice(0, 2); if (t.length) rows = await slByAddr(t) } } catch (e) { /* RQA indispo */ }
}
for (const row of rows) if (row.customer && !found.has(row.customer)) found.set(row.customer, { matched: 'adresse', address: [row.address_line, row.city].filter(Boolean).join(', '), service_location: row.name }) // lie la BONNE adresse de service (pas la facturation)
if (rows.length) addrResolved = true // → on saute la recherche par NOM (homonymes) plus bas
} catch (e) { log('searchCustomers addr: ' + e.message) }
}
// 3) Nom (Customer) — FUZZY : normalisé (sans séparateurs/accents/casse) + similarité trigramme (score façon Google, tolère typos).
if (p && found.size < 12 && !addrResolved) {
const qn = normName(text)
if (qn.length >= 3) {
const NORM = "regexp_replace(unaccent(lower(customer_name)), '[^a-z0-9]', '', 'g')"
let rows = []
try {
// word_similarity($q, nom) = meilleure sous-chaîne → « bourdn » matche « andrebourdon ». Seuil explicite (pas de GUC).
// On INCLUT les clients désactivés (anciens / réactivation) mais on classe les ACTIFS d'abord (+ badge « inactif » côté UI).
rows = (await p.query(
`SELECT name, customer_name, word_similarity($1, ${NORM}) AS score FROM "tabCustomer"
WHERE (${NORM} LIKE '%'||$1||'%' OR word_similarity($1, ${NORM}) > 0.4)
ORDER BY (${NORM} LIKE '%'||$1||'%') DESC, coalesce(disabled,0) ASC, score DESC NULLS LAST LIMIT 12`, [qn])).rows
} catch (e) {
// Repli si pg_trgm indisponible : sous-chaîne normalisée seule.
try { rows = (await p.query(`SELECT name, customer_name FROM "tabCustomer" WHERE ${NORM} LIKE '%'||$1||'%' ORDER BY coalesce(disabled,0) ASC, customer_name LIMIT 8`, [qn])).rows } catch (e2) { log('searchCustomers name: ' + e2.message) }
}
for (const row of rows) if (!found.has(row.name)) found.set(row.name, { matched: 'nom', score: row.score != null ? Math.round(row.score * 100) / 100 : null })
}
}
// 3b) Mandataire / contact détaillé (titulaire ≠ demandeur, ex. représentant qui paie/écrit). FUZZY sur les champs legacy.
// try/catch : si les colonnes custom n'existent pas encore, on ignore silencieusement (pas de régression).
if (p && found.size < 12 && !addrResolved) {
const qn = normName(text)
if (qn.length >= 3) {
const NORMM = "regexp_replace(unaccent(lower(coalesce(mandataire,'') || ' ' || coalesce(contact_name_legacy,''))), '[^a-z0-9]', '', 'g')"
try {
const rows = (await p.query(
`SELECT name, customer_name, word_similarity($1, ${NORMM}) AS score FROM "tabCustomer"
WHERE (${NORMM} LIKE '%'||$1||'%' OR word_similarity($1, ${NORMM}) > 0.45)
ORDER BY (${NORMM} LIKE '%'||$1||'%') DESC, coalesce(disabled,0) ASC, score DESC NULLS LAST LIMIT 6`, [qn])).rows
for (const row of rows) if (!found.has(row.name)) found.set(row.name, { matched: 'mandataire', score: row.score != null ? Math.round(row.score * 100) / 100 : null })
} catch (e) { log('searchCustomers mandataire: ' + e.message) }
}
}
if (!found.size) return []
// Enrichissement contacts (1 requête) : courriel + téléphones → résumé + « SMS dispo ? » (mobile présent).
const names = [...found.keys()].slice(0, 12)
let rows = []
if (p) { try { rows = (await p.query('SELECT name, customer_name, email_id, mobile_no, cell_phone, tel_home, tel_office, territory, disabled FROM "tabCustomer" WHERE name = ANY($1)', [names])).rows } catch (e) { log('searchCustomers enrich: ' + e.message) } }
return names.map(n => {
const r = rows.find(x => x.name === n) || {}; const f = found.get(n)
const mobile = r.mobile_no || r.cell_phone || ''
return {
name: n, customer_name: r.customer_name || n, email: r.email_id || '',
mobile, phone: mobile || r.tel_home || r.tel_office || '', can_sms: !!mobile,
territory: r.territory || '', address: f.address || '', matched: f.matched, inactive: !!r.disabled,
service_location: f.service_location || null,
}
})
}
// Recherche MEMBRES DE L'ÉQUIPE (System Users ERPNext sur nos domaines @targo.ca / @targointernet.com) — pour choisir
// VITE un collègue comme destinataire To/Cc, le taguer ou l'assigner. Renvoyés AVANT les clients : un employé cherche
// d'abord un collègue (« michel » → Michel Blais, sans devoir préciser « b »). FUZZY (typo-tolérant) comme les clients.
// On scope STRICTEMENT à user_type='System User' : tabUser contient AUSSI tous les clients (Website User) → sinon « michel » noyé.
async function searchTeam (q) {
const s = String(q || '').trim(); if (s.length < 2) return []
const qn = normName(s); if (qn.length < 2) return []
let p = null; try { p = require('./address-db').pool() } catch (e) { return [] }
if (!p) return []
const NORM = "regexp_replace(unaccent(lower(coalesce(full_name,'') || ' ' || name)), '[^a-z0-9]', '', 'g')"
let rows = []
try {
rows = (await p.query(
`SELECT name, full_name, word_similarity($1, ${NORM}) AS score FROM "tabUser"
WHERE enabled = 1 AND user_type = 'System User'
AND (lower(name) LIKE '%@targo.ca' OR lower(name) LIKE '%@targointernet.com')
AND (${NORM} LIKE '%'||$1||'%' OR word_similarity($1, ${NORM}) > 0.35)
ORDER BY (${NORM} LIKE '%'||$1||'%') DESC, score DESC NULLS LAST LIMIT 8`, [qn])).rows
} catch (e) {
try { rows = (await p.query(`SELECT name, full_name FROM "tabUser" WHERE enabled=1 AND user_type='System User' AND (lower(name) LIKE '%@targo.ca' OR lower(name) LIKE '%@targointernet.com') AND ${NORM} LIKE '%'||$1||'%' ORDER BY full_name LIMIT 8`, [qn])).rows } catch (e2) { log('searchTeam: ' + e2.message) }
}
return rows.map(r => ({ name: r.name, customer_name: r.full_name || r.name, email: r.name, matched: 'équipe', kind: 'équipe', can_sms: false }))
}
// Recherche TECHNICIEN par nom (fuzzy, comme les clients) → pour texter un tech par la voix (« texto au technicien Marc »).
async function searchTechnicians (q) {
let text = String(q || '').replace(/\b(technicien|technicienne|tech)\b/gi, ' ').replace(/\s+/g, ' ').trim()
const qn = normName(text); if (qn.length < 2) return []
let p = null; try { p = require('./address-db').pool() } catch (e) { return [] }
if (!p) return []
const NORM = "regexp_replace(unaccent(lower(full_name)), '[^a-z0-9]', '', 'g')"
let rows = []
try {
rows = (await p.query(`SELECT name, full_name, phone, word_similarity($1, ${NORM}) AS score FROM "tabDispatch Technician"
WHERE (${NORM} LIKE '%'||$1||'%' OR word_similarity($1, ${NORM}) > 0.4)
ORDER BY (${NORM} LIKE '%'||$1||'%') DESC, score DESC NULLS LAST LIMIT 6`, [qn])).rows
} catch (e) { try { rows = (await p.query(`SELECT name, full_name, phone FROM "tabDispatch Technician" WHERE ${NORM} LIKE '%'||$1||'%' LIMIT 6`, [qn])).rows } catch (e2) { log('searchTechnicians: ' + e2.message) } }
return rows.map(r => { const ph = String(r.phone || '').replace(/\D/g, ''); return { name: r.name, customer_name: r.full_name, phone: ph, can_sms: ph.length >= 10, kind: 'technicien' } })
}
// Destinataire = clients ∪ techniciens (pour les SMS/courriels orchestrés). Techs d'abord (contexte « texto au tech »).
async function searchRecipients (q) {
const [techs, custs] = await Promise.all([searchTechnicians(q), searchCustomers(q)])
const out = []
for (const t of techs) out.push(t)
for (const c of custs) out.push({ ...c, kind: c.kind || 'client' })
return out.slice(0, 6)
}
// Liste les adresses de service d'un compte (un compte entreprise en a plusieurs) → pour choisir la bonne.
// Adresses de service d'un compte, TRIÉES : celles avec un service ACTIF d'abord, puis par
// service actif le plus RÉCENT (start_date), puis création. → la 1re ligne = l'adresse à pré-sélectionner.
async function serviceLocations (customer) {
if (!customer) return []
let p = null; try { p = require('./address-db').pool() } catch (e) { return [] }
if (!p) return []
try {
const r = await p.query(`
SELECT sl.name, sl.address_line, sl.city, sl.status,
MAX(CASE WHEN ss.status='Actif' THEN 1 ELSE 0 END) AS has_active,
MAX(ss.start_date) FILTER (WHERE ss.status='Actif') AS last_active
FROM "tabService Location" sl
LEFT JOIN "tabService Subscription" ss ON ss.service_location = sl.name AND ss.customer = sl.customer
WHERE sl.customer = $1
GROUP BY sl.name, sl.address_line, sl.city, sl.status, sl.creation
ORDER BY has_active DESC, last_active DESC NULLS LAST, sl.creation DESC
LIMIT 50`, [customer])
return r.rows.map(x => ({ name: x.name, address: [x.address_line, x.city].filter(Boolean).join(', '), status: x.status || '', active: !!Number(x.has_active) }))
} catch (e) { log('serviceLocations: ' + e.message); return [] }
}
// Adresse de service par DÉFAUT = la plus récente ACTIVE (1re ligne de serviceLocations).
async function defaultServiceLocation (customer) {
const locs = await serviceLocations(customer)
return locs.length ? locs[0].name : null
}
// ── STATUT DU SERVICE (lecture seule, pendant « lecture » du Do Stuff de F) ─────────────
// Qualité du signal optique GPON (Rx power, dBm) — mêmes seuils que useDeviceStatus.signalQuality.
function signalFrom (rx) {
if (rx == null || isNaN(rx)) return null
if (rx > -8) return 'excellent'; if (rx > -20) return 'bon'; if (rx > -25) return 'faible'; return 'critique'
}
// Combine TR-069 (canal de gestion) + OLT SNMP (fibre, AUTORITAIRE) → statut définitif.
// Miroir serveur de useDeviceStatus.combinedStatus : l'OLT prime, TR-069 = repli si pas de donnée OLT.
function combineDeviceStatus (summary, onu) {
const li = summary && summary.lastInform ? new Date(summary.lastInform).getTime() : null
const tr069 = li ? (Date.now() - li) < 15 * 60 * 1000 : null
const oltOnline = onu ? (onu.status === 'online') : null
let online, source, detail
if (oltOnline === true) { online = true; source = tr069 === true ? 'both' : 'olt'; detail = tr069 === true ? 'TR-069 + Fibre OK' : 'Fibre OK · TR-069 inactif' }
else if (oltOnline === false) { online = false; source = 'olt'; detail = tr069 === true ? 'Fibre coupée · TR-069 résiduel' : 'Fibre hors ligne' }
else if (tr069 === true) { online = true; source = 'tr069'; detail = 'TR-069 actif · fibre non vérifiée' }
else if (tr069 === false) { online = false; source = 'tr069'; detail = 'TR-069 inactif · fibre non vérifiée' }
else { online = null; source = 'unknown'; detail = 'Aucune donnée' }
const rx = onu && onu.rxPower != null ? Number(onu.rxPower) : (summary && summary.rxPower != null ? Number(summary.rxPower) : null)
return {
online, source, detail,
label: online === true ? 'En ligne' : online === false ? 'Hors ligne' : 'Inconnu',
rxPower: rx != null && !isNaN(rx) ? rx : null, signal: signalFrom(rx),
lastInform: (summary && summary.lastInform) || null,
minutesAgo: li ? Math.floor((Date.now() - li) / 60000) : null,
wifiClients: summary && summary.wifi ? (summary.wifi.totalClients || 0) : null,
hostsCount: summary && summary.hostsCount != null ? summary.hostsCount : null,
uptime: (summary && summary.uptime) || null,
ip: (summary && summary.ip) || null,
model: summary ? [summary.manufacturer, summary.model].filter(Boolean).join(' ').trim() : '',
offlineCause: (onu && onu.lastOfflineCause) || null,
oltName: (onu && onu.oltName) || null, oltPort: onu && onu.port != null ? onu.port : null,
}
}
// Statut « service + modem » par IDENTIFIANT LIBRE (adresse | nom | téléphone | courriel).
// Même paradigme que la création de ticket en langage naturel, mais en LECTURE : résout le compte →
// adresse de service active la plus récente → équipement(s) → statut TR-069 + OLT (fibre). Aucune dépendance à F.
async function serviceStatus ({ q, customer, location } = {}) {
const devices = require('./devices')
let olt = null; try { olt = require('./olt-snmp') } catch (e) { /* SNMP indispo */ }
// 1) Résolution du compte (courriel → lookupCustomersByEmail ; sinon nom/téléphone/adresse → searchCustomers)
let cust = customer || null, matches = []
if (!cust) {
const s = String(q || '').trim()
if (!s) return { ok: false, error: 'identifiant requis (adresse, nom, téléphone ou courriel)' }
if (/.+@.+\..+/.test(s)) {
try { matches = (await lookupCustomersByEmail(s, 6)).map(m => ({ name: m.name, customer_name: m.customer_name || m.name, email: m.email || s, phone: m.phone || '', matched: 'courriel' })) } catch (e) { matches = [] }
} else {
try { matches = await searchCustomers(s) } catch (e) { matches = [] }
}
matches = (matches || []).filter(m => m && m.name)
if (!matches.length) return { ok: true, found: false, matches: [], query: s }
if (matches.length > 1) return { ok: true, found: false, ambiguous: true, matches: matches.slice(0, 6), query: s }
cust = matches[0].name
}
// En-tête client (depuis le match, sinon fiche ERPNext si le compte est passé explicitement)
let head = matches[0] || null
if (!head && cust) { try { const c = await erp.get('Customer', cust); if (c) head = { name: cust, customer_name: c.customer_name || cust, email: c.email_id || '', phone: c.mobile_no || c.cell_phone || '' } } catch (e) { /* */ } }
// 2) Adresse de service (fournie, sinon active la plus récente)
const locs = await serviceLocations(cust)
const loc = location || (locs.length ? locs[0].name : null)
const locInfo = locs.find(l => l.name === loc) || (loc ? { name: loc, address: loc } : null)
// 3) Équipement(s) à cette adresse (repli : tout l'équipement du compte si rien à l'adresse)
const EQF = ['name', 'serial_number', 'mac_address', 'ip_address', 'brand', 'model', 'status', 'service_location', 'olt_ip']
let equip = []
try {
const f = [['customer', '=', cust]]; if (loc) f.push(['service_location', '=', loc])
equip = await erp.list('Service Equipment', { filters: f, fields: EQF, limit: 6 }) || []
if (!equip.length && loc) equip = await erp.list('Service Equipment', { filters: [['customer', '=', cust]], fields: EQF, limit: 6 }) || []
} catch (e) { log('serviceStatus equip ' + cust + ': ' + e.message) }
// 4) Statut par équipement (TR-069 via cache/ACS + OLT SNMP autoritaire)
const devs = []
for (const eq of equip.slice(0, 4)) {
const serial = eq.serial_number || null
let summary = null
if (serial) {
try { const c = devices.getCached(serial); summary = (c && c.summary && c.summary.lastInform) ? c.summary : await devices.fetchDeviceDetails(serial) } catch (e) { /* */ }
}
let onu = null; if (serial && olt) { try { onu = olt.getOnuBySerial(serial) } catch (e) { /* */ } }
devs.push({ serial, mac: eq.mac_address || null, olt: eq.olt_ip || null, brand: eq.brand || '', equipmentStatus: eq.status || '', service_location: eq.service_location || null, ...combineDeviceStatus(summary, onu), resolved: !!(summary || onu) })
}
// 5) Abonnement(s) service à l'adresse (moitié « service » : actif/suspendu + forfait)
let subs = []
try {
const f = [['customer', '=', cust]]; if (loc) f.push(['service_location', '=', loc])
subs = await erp.list('Service Subscription', { filters: f, fields: ['name', 'plan_name', 'status', 'start_date'], orderBy: 'start_date desc', limit: 8 }) || []
} catch (e) { /* doctype/champ absent → on ignore */ }
// Résumé global = le « meilleur » équipement (en ligne d'abord)
const best = devs.slice().sort((a, b) => (b.online === true ? 1 : 0) - (a.online === true ? 1 : 0))[0] || null
return {
ok: true, found: true,
customer: { name: cust, customer_name: (head && head.customer_name) || cust, email: (head && head.email) || '', phone: (head && head.phone) || '' },
location: locInfo, locations: locs,
subscriptions: subs.map(s => ({ name: s.name, plan: s.plan_name || '', status: s.status || '', start: s.start_date || null })),
devices: devs,
summary: best
? { online: best.online, label: best.label, detail: best.detail, source: best.source, signal: best.signal, rxPower: best.rxPower }
: { online: null, label: 'Aucun équipement', detail: equip.length ? 'équipement sans numéro de série' : 'aucun équipement lié à ce compte', source: 'none' },
}
}
const OPS_URL = () => (cfg.OPS_PUBLIC_URL || 'https://erp.gigafibre.ca/ops').replace(/\/$/, '')
const ERP_URL = () => (cfg.ERP_PUBLIC_URL || 'https://erp.gigafibre.ca').replace(/\/$/, '')
const shortName = (e) => String(e || '').split('@')[0]
// Activité récente (commentaires/notes) d'un document — pour afficher le fil dans le wizard.
async function activity (doctype, name) {
const doc = await erp.get(doctype, name)
let comments = []
try {
comments = await erp.list('Comment', {
filters: [['reference_doctype', '=', doctype], ['reference_name', '=', name], ['comment_type', 'in', ['Comment', 'Info']]],
fields: ['content', 'comment_email', 'comment_by', 'creation', 'comment_type'], orderBy: 'creation desc', limit: 25,
})
} catch (e) { log('collab activity ' + doctype + '/' + name + ': ' + e.message) }
return { doc: doc || null, comments }
}
// Ajoute un commentaire (note/indice) + notifie les collègues mentionnés. NE TOUCHE PAS _assign / l'assignation.
async function addComment (doctype, name, { text, mentions = [], agent } = {}) {
const content = String(text || '').trim()
if (!content) return { ok: false, error: 'texte requis' }
const ment = (Array.isArray(mentions) ? mentions : []).filter(e => /.+@.+\..+/.test(e))
const tag = ment.length ? ('
↪ ' + ment.map(shortName).join(', ') + '
') : ''
const r = await erp.create('Comment', {
comment_type: 'Comment', reference_doctype: doctype, reference_name: name,
content: tag + '' + content.replace(/') + '
' + (agent ? '— ' + shortName(agent) + ' (OPS)
' : ''),
comment_email: agent || 'ops@targo.ca', comment_by: agent || 'OPS',
})
if (!r.ok) return { ok: false, error: r.error }
// Notifier les mentionnés (courriel) — sans rien changer à l'assignation.
if (ment.length) {
const gmail = require('./gmail')
const subj = (doctype === 'Issue' ? 'Ticket ' : doctype + ' ') + name
// Deep link OPS (détail natif) — plus JAMAIS le desk ERPNext pour les tickets. Autres doctypes : desk en repli
// (rare ; à migrer quand un deep link générique existera).
const link = doctype === 'Issue'
? OPS_URL() + '/#/tickets?open=' + encodeURIComponent(name)
: ERP_URL() + '/app/' + doctype.toLowerCase().replace(/ /g, '-') + '/' + encodeURIComponent(name)
const body = `${shortName(agent) || 'Un collègue'} t'a mentionné sur ${subj} :\n\n${content}\n\nOuvrir : ${link}\n(OPS — collaboration, ceci ne change pas l'assignation du ticket.)`
for (const to of ment) { try { await gmail.sendMessage({ to, subject: `[OPS] mention — ${subj}`, body }) } catch (e) { log('collab notify ' + to + ': ' + e.message) } }
}
return { ok: true, mentioned: ment }
}
// Crée un ticket (ERPNext Issue) autonome — depuis le FAB / composer NL. customer optionnel ; queue = notifie l'équipe.
async function createTicket ({ title, category, priority, description, customer, customer_name, queue, status, due_date, service_location, agent } = {}) {
const t = String(title || '').trim()
if (!t) return { ok: false, error: 'titre requis' }
const cat = String(category || '').trim()
const st = (status === 'On Hold' || status === 'pending' || status === 'En attente') ? 'On Hold' : 'Open' // « pending » = suspendu jusqu'à la date de rappel
const data = {
subject: (cat ? '[' + cat + '] ' : '') + t.slice(0, 130), status: st, priority: priority || 'Medium',
description: (description ? String(description) + '\n\n' : '') + (agent ? '— créé par ' + shortName(agent) + ' (OPS)' : ''),
}
if (customer) data.customer = customer
// Adresse de service : celle fournie, SINON l'adresse active la plus récente du compte (auto).
if (!service_location && customer) { try { service_location = await defaultServiceLocation(customer) } catch (e) {} }
if (service_location) data.custom_service_location = service_location // lie le ticket à la BONNE adresse de service
if (due_date && /^\d{4}-\d{2}-\d{2}/.test(due_date)) data.custom_reminder_date = due_date.slice(0, 10) // rappel / échéance
const r = await erp.create('Issue', data)
if (!r.ok) return { ok: false, error: r.error }
// Router vers l'équipe : notifier les membres de la file (ex. Supports) — comme un courriel entrant.
if (queue) {
const members = (readJsonFile('/app/data/queue_members.json', {})[queue] || []).filter(e => /.+@.+\..+/.test(e))
if (members.length) {
const link = OPS_URL() + '/#/tickets?open=' + encodeURIComponent(r.name) // détail natif OPS (fini le desk ERPNext)
const body = `Nouveau ticket pour l'équipe « ${queue} » :\n\n${data.subject}\n${customer_name ? 'Client : ' + customer_name + '\n' : ''}${description ? '\n' + description + '\n' : ''}\nOuvrir : ${link}\n— TARGO OPS`
try { await require('./outbox').enqueue({ to: members.join(','), subject: `[Ticket · ${queue}] ${data.subject}`.slice(0, 90), body }, { kind: 'ticket-notif', label: queue }) } catch (e) { log('createTicket notify ' + queue + ': ' + e.message) }
}
}
return { ok: true, name: r.name, subject: data.subject }
}
// ── ORCHESTRATEUR EN LANGAGE NATUREL / DICTÉE ──────────────────────────────
// « Crée un ticket de facture non payée pour Louispaul, envoie un texto à ce tech concernant telle adresse. »
// L'IA décompose en ACTIONS (sans inventer d'entités) ; le code RÉSOUT les clients (searchCustomers) ; on renvoie un
// PLAN à confirmer (les actions sortantes = SMS/ticket ne s'exécutent qu'après validation humaine). Puis /run exécute.
const ORCH_SYS = `Tu transformes la commande d'un agent (fournisseur Internet TARGO) en JSON d'ACTIONS. Réponds UNIQUEMENT par {"actions":[...]} (aucun texte autour).
Types autorisés :
- {"type":"create_ticket","customer_query":"","subject":"","category":"Support|Facturation|Installation|Télévision|Téléphonie|Commercial|Autre","priority":"Low|Medium|High|Urgent","status":"Open|On Hold","due_date":"YYYY-MM-DD ou null"}
- {"type":"send_sms","to_query":"","message":""}
- {"type":"send_email","to_query":"<...>","subject":"<...>","body":"<...>"}
- {"type":"note","text":""}
- {"type":"diagnose","customer_query":""} // QUESTION ou VÉRIFICATION d'état (lecture) : connexion, service, internet, modem, signal, « est-il en ligne ? », « a-t-il une panne ? »
Règles : status="On Hold" si on ATTEND quelque chose (paiement, pièce…). Ne résous PAS les entités, garde le terme dicté dans *_query. N'invente jamais de numéro, montant ni adresse. Déduis la catégorie par le sens (« facture »→Facturation, « wifi/panne »→Support, « installation »→Installation).
IMPORTANT — si la commande est une QUESTION ou une demande de VÉRIFICATION d'état (pas une action à exécuter), p.ex. « vérifie/vérifier la connexion|le service|internet de X », « est-ce que X est en ligne », « X a-t-il une panne », « statut de X » → utilise UNIQUEMENT {"type":"diagnose"} (JAMAIS create_ticket). Une vérification ne crée rien.`
async function orchestratePlan (text) {
if (!String(text || '').trim()) return { ok: false, error: 'commande vide' }
let out
try { out = await require('./ai').chat({ task: 'nl', maxTokens: 800, temperature: 0.1, messages: [{ role: 'system', content: ORCH_SYS }, { role: 'user', content: String(text) }] }) } catch (e) { return { ok: false, error: 'IA : ' + e.message } }
let parsed; try { parsed = JSON.parse((out.match(/\{[\s\S]*\}/) || ['{}'])[0]) } catch (e) { return { ok: false, error: 'réponse IA illisible' } }
const actions = Array.isArray(parsed.actions) ? parsed.actions.slice(0, 8) : []
for (const a of actions) {
// Intent LECTURE : on exécute le diagnostic tout de suite (sûr) — serviceStatus gère seul
// 1 client → diagnostic ; plusieurs → {ambiguous, matches} (l'agent précise l'adresse) ; aucun → introuvable.
if (a.type === 'diagnose') {
try { a.diagnostic = await serviceStatus({ q: a.customer_query || '' }) }
catch (e) { a.diagnostic = { ok: false, error: e.message } }
continue
}
const q = a.customer_query || a.to_query
if (!q) continue
if (/^\+?\d[\d\s().-]{6,}$/.test(String(q))) { a.phone = String(q).replace(/\D/g, ''); continue } // numéro brut dicté
try {
// ticket = pour un CLIENT ; sms/courriel = destinataire CLIENT ∪ TECHNICIEN.
const m = (a.type === 'create_ticket') ? await searchCustomers(q) : await searchRecipients(q)
a.candidates = m.slice(0, 4); a.resolved = m[0] || null
} catch (e) { a.candidates = []; a.resolved = null }
}
return { ok: true, actions, transcript: String(text) }
}
async function orchestrateRun (actions, agent) {
const conv = require('./conversation')
const results = []
for (const a of (Array.isArray(actions) ? actions : [])) {
try {
if (a.type === 'create_ticket') {
const r = await createTicket({ title: a.subject || 'Demande', category: a.category, priority: a.priority, status: a.status, due_date: a.due_date, customer: a.resolved && a.resolved.name, customer_name: a.resolved && a.resolved.customer_name, service_location: a.service_location || (a.resolved && a.resolved.service_location), queue: a.queue, agent })
results.push({ type: 'create_ticket', ok: r.ok, name: r.name, label: r.subject, error: r.error })
} else if (a.type === 'send_sms') {
const phone = a.phone || (a.resolved && a.resolved.phone)
if (!phone) { results.push({ type: 'send_sms', ok: false, error: 'destinataire sans numéro' }); continue }
const r = await conv.sendSms({ phone, customer: a.resolved && a.resolved.name, customerName: a.resolved && a.resolved.customer_name, message: a.message })
results.push({ type: 'send_sms', ok: r.ok, to: (a.resolved && a.resolved.customer_name) || phone, via: r.via, error: r.error })
} else if (a.type === 'send_email') {
const to = (a.resolved && a.resolved.email) || a.to
if (!to) { results.push({ type: 'send_email', ok: false, error: 'destinataire sans courriel' }); continue }
const r = await conv.sendNewEmail({ to, subject: a.subject, body: a.body, customer: a.resolved && a.resolved.name, customerName: a.resolved && a.resolved.customer_name })
results.push({ type: 'send_email', ok: r.ok, to, error: r.error })
} else if (a.type === 'note') {
results.push({ type: 'note', ok: true, text: a.text })
}
} catch (e) { results.push({ type: a.type, ok: false, error: e.message }) }
}
return { ok: true, results }
}
// ── Rapport : comptes avec TOTAL RÉCURRENT MENSUEL NÉGATIF ──────────────────
// Un abonnement récurrent ne peut pas être négatif (rabais > frais). F est autoritaire et
// correct ; ce rapport débusque la DÉRIVE côté ERPNext/OPS (rabais ajoutés à la main / artefacts
// d'import). Scan des Service Subscription ACTIF mensuelles, agrégées par (client, adresse).
// Calcul en arrière-plan + cache 1 h (la requête HTTP ne bloque pas ; la page sonde jusqu'à prêt).
let _negBill = { ts: 0, data: null, computing: false, error: null }
async function computeNegativeBilling () {
const groups = new Map() // clé client|adresse → { customer, service_location, total, charges, rebate, rebates[] }
const custTotals = new Map() // client → total mensuel ACTIF toutes adresses (pour distinguer un vrai négatif d'un artefact de regroupement par adresse)
// « Actif/Inactif » du client = statut de compte GLOBAL F (account.status), récupéré après le scan (cf. fAccountStatuses) — autorité.
const TODAY = new Date().toISOString().slice(0, 10)
let start = 0, scanned = 0; const PAGE = 2000
for (let i = 0; i < 60; i++) { // borne dure 120k
const rows = await erp.list('Service Subscription', {
filters: [['status', '=', 'Actif'], ['billing_cycle', '!=', 'Annuel']],
fields: ['name', 'customer', 'service_location', 'monthly_price', 'plan_name', 'product_sku', 'legacy_service_id', 'end_date'],
limit: PAGE, start,
}) || []
if (!rows.length) break
for (const r of rows) {
const price = Number(r.monthly_price || 0)
const cust = r.customer || '?'
const key = cust + '|' + (r.service_location || '')
const legacy = Number(r.legacy_service_id || 0)
let g = groups.get(key)
if (!g) { g = { customer: cust, service_location: r.service_location || '', total: 0, charges: 0, rebate: 0, rebates: [], manualRebates: 0, expiredActive: 0 }; groups.set(key, g) }
g.total += price
custTotals.set(cust, (custTotals.get(cust) || 0) + price)
// signal de fin de service non respectée (date de fin passée mais encore Actif)
if (r.end_date && r.end_date < TODAY) g.expiredActive++
if (price < 0) {
g.rebate += price
if (legacy <= 0) g.manualRebates++ // rabais SANS lien F = ajouté dans ERPNext
g.rebates.push({ name: r.name, sku: r.product_sku || '', plan: r.plan_name || '', price, legacy, source: legacy > 0 ? ('F #' + legacy) : 'ajouté OPS', end_date: r.end_date || null })
} else g.charges += price
}
scanned += rows.length
if (rows.length < PAGE) break
start += PAGE
}
const neg = [...groups.values()].filter(g => g.total < -0.005).sort((a, b) => a.total - b.total)
// Pour chaque compte négatif : examiner TOUS les abos (actif+inactif) → une CHARGE suspendue/annulée
// qui rendrait le total positif = désync de statut F→ERPNext (cause réelle vue sur Schink : FTTH500 Suspendu
// dans ERPNext mais actif dans F). Peu de comptes (~quelques dizaines) → fetch par compte économique.
const allByCust = new Map()
for (const g of neg) {
if (!allByCust.has(g.customer)) {
try { allByCust.set(g.customer, await erp.list('Service Subscription', { filters: [['customer', '=', g.customer]], fields: ['name', 'status', 'monthly_price', 'plan_name', 'service_location', 'legacy_service_id', 'billing_cycle'], limit: 100 }) || []) } catch (e) { allByCust.set(g.customer, []) }
}
const inact = (allByCust.get(g.customer) || []).filter(s => (s.service_location || '') === g.service_location && s.billing_cycle !== 'Annuel' && Number(s.monthly_price || 0) > 0 && s.status !== 'Actif')
g.inactiveChargeSum = inact.reduce((s, x) => s + Number(x.monthly_price || 0), 0)
g.inactiveCharges = inact.map(s => ({ plan: s.plan_name || '', price: Number(s.monthly_price || 0), status: s.status || '', legacy: Number(s.legacy_service_id || 0) }))
}
const causeOf = (g, ct) => {
if (ct >= -0.005) return 'artifact' // rabais/frais sur adresses ≠ → compte OK
if (g.manualRebates > 0) return 'manual' // rabais ajouté hors F
if (g.total + (g.inactiveChargeSum || 0) >= -0.005) return 'suspended_charge' // une charge suspendue/annulée explique le négatif
if (g.expiredActive > 0) return 'expired'
return 'stacked_rebates' // vrais rabais F qui dépassent (à vérifier vs F)
}
// Noms clients + ID de compte F (batch 'in' par 100)
const ids = [...new Set(neg.map(g => g.customer).filter(Boolean))]
const names = {}, acctIdOf = {}
for (let i = 0; i < ids.length; i += 100) {
try { const cs = await erp.list('Customer', { filters: [['name', 'in', ids.slice(i, i + 100)]], fields: ['name', 'customer_name', 'legacy_account_id'], limit: 100 }) || []; for (const c of cs) { names[c.name] = c.customer_name; acctIdOf[c.name] = Number(c.legacy_account_id || 0) } } catch (e) { /* nom optionnel */ }
}
// Statut de compte GLOBAL F (account.status : 1=Actif, 2=Suspendu, 3/4/5=Résilié) — l'autorité (≠ heuristique forfait, ≠ flag disabled).
let acctStatus = {}; const ls = require('./legacy-sync')
try { acctStatus = await ls.fAccountStatuses(Object.values(acctIdOf)) } catch (e) { log('negative-billing fAccountStatuses: ' + e.message) }
const LABEL = ls.F_ACCT_STATUS_LABEL || {}
const round = n => Math.round(n * 100) / 100
return {
count: neg.length, scanned, generated: Date.now(),
rows: neg.slice(0, 2000).map(g => { const ct = round(custTotals.get(g.customer) || 0); const fst = acctStatus[acctIdOf[g.customer]]; return ({ customer: g.customer, customer_name: names[g.customer] || g.customer, service_location: g.service_location, total: round(g.total), charges: round(g.charges), rebate: round(g.rebate), customer_total: ct, account_status: fst != null ? fst : null, account_status_label: LABEL[fst] || (fst != null ? 'Inconnu' : '—'), customer_active: fst === 1, manualRebates: g.manualRebates, expiredActive: g.expiredActive, inactiveChargeSum: round(g.inactiveChargeSum || 0), inactiveCharges: g.inactiveCharges || [], cause: causeOf(g, ct), rebates: g.rebates }) }),
}
}
async function negativeBilling (refresh) {
const fresh = (Date.now() - _negBill.ts) < 3600000
if (!refresh && _negBill.data && fresh) return { ready: true, cached: true, ..._negBill.data }
if (_negBill.computing) return { ready: false, computing: true }
if (!refresh && _negBill.error && fresh) return { ready: false, error: _negBill.error }
_negBill.computing = true
computeNegativeBilling()
.then(d => { _negBill = { ts: Date.now(), data: d, computing: false, error: null }; log(`negative-billing: ${d.count} comptes négatifs / ${d.scanned} abos scannés`) })
.catch(e => { _negBill = { ts: Date.now(), data: null, computing: false, error: e.message }; log('negative-billing ERROR: ' + e.message) })
return { ready: false, computing: true, started: true }
}
// ── Rapport : COMPTES RÉSILIÉS qui gardent des SERVICES ACTIFS (à nettoyer au niveau service) ──
// F ne désactive pas les services récurrents à la résiliation → charges + crédits restent status=1.
// Liste de ménage (les employés à internet fourni sont des exceptions à garder). Calcul en arrière-plan + cache 1 h.
let _termActive = { ts: 0, data: null, computing: false, error: null }
async function computeTerminatedActive () {
const ls = require('./legacy-sync')
const resilieIds = await ls.fResiliatedAccountIds()
if (!resilieIds.length) return { count: 0, services: 0, generated: Date.now(), rows: [] }
const pgp = require('./address-db').pool()
const custAcct = new Map() // customer ERPNext → legacy_account_id (résilié)
if (pgp) {
for (let i = 0; i < resilieIds.length; i += 5000) {
try { const r = await pgp.query('SELECT name, legacy_account_id FROM "tabCustomer" WHERE legacy_account_id = ANY($1)', [resilieIds.slice(i, i + 5000)]); for (const x of r.rows) custAcct.set(x.name, Number(x.legacy_account_id)) } catch (e) { log('terminated-active PG: ' + e.message) }
}
}
if (!custAcct.size) return { count: 0, services: 0, generated: Date.now(), rows: [] }
const byCust = new Map()
let start = 0, scanned = 0; const PAGE = 2000
for (let i = 0; i < 60; i++) {
const rows = await erp.list('Service Subscription', { filters: [['status', '=', 'Actif'], ['billing_cycle', '!=', 'Annuel']], fields: ['name', 'customer', 'service_location', 'monthly_price', 'plan_name', 'product_sku', 'legacy_service_id'], limit: PAGE, start }) || []
if (!rows.length) break
for (const r of rows) {
if (!custAcct.has(r.customer)) continue
let g = byCust.get(r.customer); if (!g) { g = { customer: r.customer, total: 0, services: [] }; byCust.set(r.customer, g) }
const price = Number(r.monthly_price || 0)
g.total += price
g.services.push({ legacy: Number(r.legacy_service_id || 0), sku: r.product_sku || '', plan: r.plan_name || '', price, location: r.service_location || '' })
}
scanned += rows.length
if (rows.length < PAGE) break
start += PAGE
}
const ids = [...byCust.keys()]; const names = {}
for (let i = 0; i < ids.length; i += 100) { try { const cs = await erp.list('Customer', { filters: [['name', 'in', ids.slice(i, i + 100)]], fields: ['name', 'customer_name'], limit: 100 }) || []; for (const c of cs) names[c.name] = c.customer_name } catch (e) { /* */ } }
const round = n => Math.round(n * 100) / 100
const rows = [...byCust.values()].map(g => ({
customer: g.customer, customer_name: names[g.customer] || g.customer, account_id: custAcct.get(g.customer),
active_services: g.services.length,
charges: round(g.services.filter(s => s.price > 0).reduce((a, s) => a + s.price, 0)),
credits: round(g.services.filter(s => s.price < 0).reduce((a, s) => a + s.price, 0)),
total: round(g.total),
services: g.services.map(s => ({ ...s, price: round(s.price) })),
})).sort((a, b) => b.active_services - a.active_services || b.total - a.total)
return { count: rows.length, services: rows.reduce((a, r) => a + r.active_services, 0), scanned, generated: Date.now(), rows: rows.slice(0, 3000) }
}
async function terminatedActive (refresh) {
const fresh = (Date.now() - _termActive.ts) < 3600000
if (!refresh && _termActive.data && fresh) return { ready: true, cached: true, ..._termActive.data }
if (_termActive.computing) return { ready: false, computing: true }
if (!refresh && _termActive.error && fresh) return { ready: false, error: _termActive.error }
_termActive.computing = true
computeTerminatedActive()
.then(d => { _termActive = { ts: Date.now(), data: d, computing: false, error: null }; log(`terminated-active: ${d.count} comptes résiliés / ${d.services} services actifs`) })
.catch(e => { _termActive = { ts: Date.now(), data: null, computing: false, error: e.message }; log('terminated-active ERROR: ' + e.message) })
return { ready: false, computing: true, started: true }
}
async function handle (req, res, method, p, url) {
// POST /collab/orchestrate {text} → PLAN d'actions (ne s'exécute pas). POST /collab/orchestrate/run {actions} → exécute (confirmé).
if (p === '/collab/orchestrate' && method === 'POST') {
const b = await parseBody(req); return json(res, 200, await orchestratePlan(b.text || ''))
}
if (p === '/collab/orchestrate/run' && method === 'POST') {
const b = await parseBody(req); return json(res, 200, await orchestrateRun(b.actions, req.headers['x-authentik-email'] || b.agent || ''))
}
// GET /collab/customer-search?q=&team=1 — autosuggest contact par texte libre (nom / courriel / téléphone / adresse).
// team=1 → inclut les MEMBRES DE L'ÉQUIPE en PREMIER (destinataires To/Cc, tag collègue) ; sinon clients seuls (recherche globale → fiche).
if (p === '/collab/customer-search' && method === 'GET') {
const q = url.searchParams.get('q') || ''
const wantTeam = url.searchParams.get('team') === '1'
const [team, custs] = await Promise.all([wantTeam ? searchTeam(q) : Promise.resolve([]), searchCustomers(q)])
const seen = new Set(team.map(t => (t.email || '').toLowerCase()))
const matches = [...team, ...custs.filter(c => !(c.email && seen.has(c.email.toLowerCase())))]
return json(res, 200, { matches })
}
// GET /collab/ratings — évaluations clients (basses prioritaires) pour la vue Ops « satisfaction »
if (p === '/collab/ratings' && method === 'GET') {
return json(res, 200, require('./rating').listRatings({ limit: Number(url.searchParams.get('limit')) || 150, customer: url.searchParams.get('customer') || '' }))
}
// DELETE /collab/ratings/ — supprimer une évaluation (avis de test)
const mRatingDel = p.match(/^\/collab\/ratings\/([\w-]+)$/)
if (mRatingDel && method === 'DELETE') {
return json(res, 200, { ok: require('./rating').deleteRating(mRatingDel[1]) })
}
// GET /collab/rating-invite-draft?customer=&channel=&kind=invite|feedback — BROUILLON pré-rempli (coords + message, langue du client) pour la fenêtre Compose.
// kind=invite (défaut) : invitation à évaluer (courriel = {{rating}} ; SMS = lien /rate/start tokenisé). kind=feedback : « écrire au client » (suite à un avis, ex. mécontent ; aucun lien).
if (p === '/collab/rating-invite-draft' && method === 'GET') {
const customer = url.searchParams.get('customer') || ''
const channel = url.searchParams.get('channel') === 'sms' ? 'sms' : 'email'
const kind = url.searchParams.get('kind') === 'feedback' ? 'feedback' : 'invite'
let email = url.searchParams.get('email') || ''; let phone = url.searchParams.get('phone') || ''; let name = url.searchParams.get('name') || ''; let lang = url.searchParams.get('lang') || ''
if (customer) { try { const c = await erp.get('Customer', customer); if (c) { email = email || c.email_id || c.email_billing || ''; phone = phone || c.cell_phone || c.mobile_no || ''; lang = lang || c.language || ''; name = name || c.customer_name || '' } } catch (e) { /* */ } }
if (!customer && !email && !phone) return json(res, 400, { error: 'customer ou coordonnées requis' })
const rating = require('./rating')
if (channel === 'sms') {
if (!phone) return json(res, 200, { ok: false, error: 'Aucun numéro mobile pour ce client' })
const text = kind === 'feedback' ? rating.feedbackSms(name, lang) : rating.inviteSmsText(rating.newRatingToken({ customer, email, name, lang }), lang)
return json(res, 200, { ok: true, prefill: { channel: 'sms', phone, customer, customerName: name, text } })
}
if (!email) return json(res, 200, { ok: false, error: 'Aucun courriel pour ce client' })
const subject = kind === 'feedback' ? rating.feedbackSubject(lang) : rating.inviteSubject(lang)
const prefill = { channel: 'email', to: email, customer, customerName: name, subject }
if (kind === 'feedback') prefill.html = rating.feedbackHtml(name, lang) // texte simple → éditable dans l'éditeur
else prefill.advHtml = rating.inviteEmailMarkerHtml(lang, name) // courriel MIS EN PAGE → envoyé tel quel (composeAdvHtml)
return json(res, 200, { ok: true, prefill })
}
// POST /collab/send-rating-invite {customer|email|phone, channel} — renvoie l'invitation d'évaluation (courriel/SMS) DANS LA LANGUE DU CLIENT.
if (p === '/collab/send-rating-invite' && method === 'POST') {
const b = await parseBody(req)
const channel = b.channel === 'sms' ? 'sms' : 'email'
let email = String(b.email || ''); let phone = String(b.phone || ''); let name = String(b.name || ''); let lang = String(b.lang || '')
const customer = String(b.customer || ''); const conv = String(b.conv || '')
if (customer) { // résout courriel / mobile / langue depuis la fiche
try { const c = await erp.get('Customer', customer); if (c) { email = email || c.email_id || c.email_billing || ''; phone = phone || c.cell_phone || c.mobile_no || ''; lang = lang || c.language || ''; name = name || c.customer_name || '' } } catch (e) { /* */ }
}
const rating = require('./rating')
const token = rating.newRatingToken({ customer, conv, email, name, lang })
if (channel === 'sms') {
if (!phone) return json(res, 400, { error: 'Aucun numéro mobile pour ce client' })
try { const { sendSmsInternal } = require('./twilio'); const ok = await sendSmsInternal(phone, rating.inviteSmsText(token, lang), customer || null); return json(res, ok ? 200 : 502, { ok: !!ok, channel: 'sms', to: phone }) } catch (e) { return json(res, 500, { error: e.message }) }
}
if (!email) return json(res, 400, { error: 'Aucun courriel pour ce client' })
try { const r = await require('./conversation').sendNewEmail({ to: email, subject: rating.inviteSubject(lang), html: rating.inviteEmailHtml(token, lang, name), customer, customerName: name, agentEmail: req.headers['x-authentik-email'] || '' }); return json(res, r && r.ok ? 200 : 400, { ok: !!(r && r.ok), channel: 'email', to: email }) } catch (e) { return json(res, 500, { error: e.message }) }
}
// POST /collab/suggest-account — croise les signaux d'un courriel pour proposer le BON compte même quand
// l'expéditeur ≠ le titulaire (ex. mandataire). IA extrait adresse/nom/tél du CORPS, puis recoupe + classe.
if (p === '/collab/suggest-account' && method === 'POST') {
const b = await parseBody(req)
const senderEmail = String(b.email || '').trim()
const senderName = String(b.name || '').trim()
const text = String(b.text || '').slice(0, 4000)
let ex = {}
if (text) {
try {
const sys = 'Tu extrais les identifiants de compte qu\'un client mentionne dans un courriel à un fournisseur Internet. Le TITULAIRE du compte peut différer de l\'expéditeur (ex. un mandataire écrit pour le titulaire). Renvoie UNIQUEMENT du JSON : {"address":"adresse civique complète si mentionnée, sinon vide","names":["noms complets de personnes mentionnés"],"invoice_number":"","phone":""}. N\'invente rien ; vide si absent.'
const out = await require('./ai').chat({ task: 'default', maxTokens: 300, temperature: 0, reasoningEffort: 'none', messages: [{ role: 'system', content: sys }, { role: 'user', content: text }] })
ex = JSON.parse((String(out || '').match(/\{[\s\S]*\}/) || ['{}'])[0]) || {}
} catch (e) { ex = {} }
}
// Signaux pondérés : l'adresse du corps est la plus discriminante ; corroboration par un nom = forte confiance.
const queries = []
if (ex.address) queries.push({ q: ex.address, signal: 'adresse du courriel', w: 5 })
for (const n of (Array.isArray(ex.names) ? ex.names : [])) if (n && String(n).trim().length > 2) queries.push({ q: String(n), signal: 'nom mentionné', w: 3 })
if (ex.phone) queries.push({ q: ex.phone, signal: 'téléphone du courriel', w: 3 })
if (senderName) queries.push({ q: senderName, signal: 'expéditeur', w: 2 })
if (senderEmail) queries.push({ q: senderEmail, signal: 'courriel expéditeur', w: 2 })
const acc = new Map()
for (const { q, signal, w } of queries) {
let matches = []
try { matches = await searchCustomers(q) } catch (e) { matches = [] }
for (const m of matches.slice(0, 5)) {
const cur = acc.get(m.name) || { ...m, score: 0, signals: [] }
cur.score += w
if (!cur.signals.includes(signal)) cur.signals.push(signal)
acc.set(m.name, cur)
}
}
const suggestions = [...acc.values()].sort((a, b) => b.score - a.score).slice(0, 6)
return json(res, 200, { suggestions, extracted: ex })
}
// GET /collab/service-locations?customer= — adresses de service d'un compte (multi-adresses)
if (p === '/collab/service-locations' && method === 'GET') {
return json(res, 200, { locations: await serviceLocations(url.searchParams.get('customer') || '') })
}
// GET /collab/service-status?q=[&customer=&location=] — statut service+modem (lecture)
if (p === '/collab/service-status' && method === 'GET') {
return json(res, 200, await serviceStatus({ q: url.searchParams.get('q') || '', customer: url.searchParams.get('customer') || '', location: url.searchParams.get('location') || '' }))
}
// GET /collab/dostuff?serial=&olt= — statut LIVE du modem via le webhook n8n que F utilise (LENT ~25-30 s ; tech=3)
if (p === '/collab/dostuff' && method === 'GET') {
return json(res, 200, await dostuffStatus({ sn: url.searchParams.get('serial') || url.searchParams.get('sn') || '', olt: url.searchParams.get('olt') || '' }))
}
// GET /collab/negative-billing[?refresh=1] — comptes dont le total récurrent mensuel ACTIF est < 0 (rabais > frais)
if (p === '/collab/negative-billing' && method === 'GET') {
return json(res, 200, await negativeBilling(url.searchParams.get('refresh') === '1'))
}
// GET /collab/terminated-active[?refresh=1] — comptes RÉSILIÉS (F status 3/4/5) gardant des services ACTIFS (ménage)
if (p === '/collab/terminated-active' && method === 'GET') {
return json(res, 200, await terminatedActive(url.searchParams.get('refresh') === '1'))
}
// GET /collab/wan-rate?serial=[&olt=&slot=&port=&ontid=] — compteurs d'octets WAN de l'ONU (Raisecom/tech-2 seulement),
// tels que F les lit dans raisecom_rcmg_bandwith.php. Le CLIENT échantillonne 2× et calcule le débit (delta/sec*8).
if (p === '/collab/wan-rate' && method === 'GET') {
let olt = null; try { olt = require('./olt-snmp') } catch (e) { return json(res, 200, { available: false, reason: 'snmp_unavailable' }) }
const serial = url.searchParams.get('serial') || url.searchParams.get('sn') || ''
const opts = {}
if (url.searchParams.get('olt')) opts.oltIp = url.searchParams.get('olt')
if (url.searchParams.get('slot') != null && url.searchParams.get('slot') !== '') opts.slot = url.searchParams.get('slot')
if (url.searchParams.get('port') != null && url.searchParams.get('port') !== '') opts.port = url.searchParams.get('port')
if (url.searchParams.get('ontid') != null && url.searchParams.get('ontid') !== '') opts.ontId = url.searchParams.get('ontid')
if (url.searchParams.get('type')) opts.type = url.searchParams.get('type')
if (!serial && !opts.oltIp) return json(res, 400, { error: 'serial ou olt requis' })
try { return json(res, 200, await olt.getOnuBandwidth(serial, opts)) }
catch (e) { return json(res, 200, { available: false, reason: 'error', error: e.message }) }
}
// GET /collab/diag-link?customer=C-XXX[&name=] — lien de diagnostic libre-service (token signé 7 j) à envoyer au client
if (p === '/collab/diag-link' && method === 'GET') {
const customer = url.searchParams.get('customer') || ''
if (!customer) return json(res, 400, { error: 'customer requis' })
let name = url.searchParams.get('name') || ''
if (!name) { try { const c = await erp.get('Customer', customer); name = (c && c.customer_name) || '' } catch (e) { /* */ } }
const token = require('./magic-link').generateCustomerToken(customer, name, '', 24 * 7)
const base = (cfg.CLIENT_PUBLIC_URL || 'https://app.gigafibre.ca').replace(/\/$/, '')
return json(res, 200, { ok: true, link: base + '/diag/' + token, customer, name })
}
// ── Bibliothèque d'images de pièces jointes (équipements fréquents, réutilisables — mêmes images qu'une FAQ) ──
// Manifeste léger /app/data/attach_presets.json ; les octets vivent dans le store d'actifs (content-hash, partagé).
if (p === '/collab/attach-presets' && method === 'GET') {
const list = (readJsonFile('/app/data/attach_presets.json') || []).map(x => ({ ...x, url: require('./campaigns').uploadUrl(x.asset, req) }))
return json(res, 200, { presets: list })
}
if (p === '/collab/attach-presets' && method === 'POST') {
const b = await parseBody(req)
const campaigns = require('./campaigns')
const dec = campaigns.decodeDataUrl(b && b.data)
if (!dec || dec.error) return json(res, 400, { error: dec && dec.error === 'too_large' ? 'image trop volumineuse' : 'image invalide' })
const asset = campaigns.persistUpload(dec.buffer, dec.ext)
const id = 'ap_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7)
const fs = require('fs'); const PRESETS = '/app/data/attach_presets.json'
const list = readJsonFile(PRESETS) || []
const preset = { id, name: String((b && b.name) || 'Image').slice(0, 80), category: String((b && b.category) || 'Équipement').slice(0, 40), asset, mime: dec.mime }
list.push(preset)
try { fs.writeFileSync(PRESETS, JSON.stringify(list, null, 2)) } catch (e) { return json(res, 500, { error: 'écriture manifeste' }) }
return json(res, 200, { ok: true, preset: { ...preset, url: campaigns.uploadUrl(asset, req) } })
}
const mPreset = p.match(/^\/collab\/attach-presets\/([\w-]+)$/)
if (mPreset && method === 'DELETE') {
const fs = require('fs'); const PRESETS = '/app/data/attach_presets.json'
const list = (readJsonFile(PRESETS) || []).filter(x => x.id !== mPreset[1]) // retiré du manifeste ; l'actif content-hash reste (potentiellement partagé)
try { fs.writeFileSync(PRESETS, JSON.stringify(list, null, 2)) } catch (e) { return json(res, 500, { error: 'écriture manifeste' }) }
return json(res, 200, { ok: true })
}
// POST /collab/ticket {title, category, priority, description, customer, customer_name, queue} — créer un ticket autonome
if (p === '/collab/ticket' && method === 'POST') {
const b = await parseBody(req)
const r = await createTicket({ ...b, agent: req.headers['x-authentik-email'] || b.agent || '' })
return json(res, r.ok ? 200 : 400, r)
}
// GET /collab/activity?doctype=Issue&name=ISS-0001
if (p === '/collab/activity' && method === 'GET') {
const doctype = url.searchParams.get('doctype'); const name = url.searchParams.get('name')
if (!doctype || !name) return json(res, 400, { error: 'doctype + name requis' })
return json(res, 200, await activity(doctype, name))
}
// POST /collab/comment {doctype, name, text, mentions}
if (p === '/collab/comment' && method === 'POST') {
const b = await parseBody(req)
if (!b.doctype || !b.name) return json(res, 400, { error: 'doctype + name requis' })
const agent = req.headers['x-authentik-email'] || b.agent || ''
const r = await addComment(b.doctype, b.name, { text: b.text, mentions: b.mentions, agent })
return json(res, r.ok ? 200 : 400, r)
}
return json(res, 404, { error: 'not found' })
}
// ── DoStuff (n8n) : statut LIVE d'un modem via le MÊME webhook que F (GET {sn, olt}) ──────────────────
// F appelle n8napi.targo.ca/webhook/dostuff en GET avec un body JSON → on réplique via le module https (fetch
// refuse un body en GET). LENT (~25-30 s : n8n interroge l'OLT en direct) → action À LA DEMANDE seulement.
// Réservé tech=3 (OLT reconnus par n8n) ; tech=2 (poller OLT standard) → n8n renvoie « OLT non reconnue » → repli sur notre statut.
function dostuffStatus ({ sn, olt } = {}) {
return new Promise((resolve) => {
if (!sn || !olt) return resolve({ ok: false, error: 'sn + olt requis' })
let https; try { https = require('https') } catch (e) { return resolve({ ok: false, error: 'https indisponible' }) }
const body = JSON.stringify({ sn: String(sn), olt: String(olt) })
const req = https.request('https://n8napi.targo.ca/webhook/dostuff', { method: 'GET', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 40000 }, (res) => {
let d = ''; res.on('data', c => { d += c }); res.on('end', () => {
let arr; try { arr = JSON.parse(d) } catch (e) { return resolve({ ok: false, error: 'réponse n8n illisible' }) }
const r = Array.isArray(arr) ? arr[0] : arr
if (!r || r.ErrorCode) return resolve({ ok: false, recognized: false, error: (r && r.ErrorCode) || 'réponse vide' }) // OLT non reconnue (tech=2) → repli
const clean = (v) => String(v == null ? '' : v).replace(/^"+|"+$/g, '').trim()
const num = (v) => { const n = parseFloat(clean(v)); return isNaN(n) ? null : n }
const online = clean(r.Status) === '1' || clean(r.InternetOn) === '1'
resolve({
ok: true, source: 'dostuff', online, label: online ? 'En ligne' : 'Hors ligne',
rxPower: num(r.RxSignal), txPower: num(r.TxSignal), signal: signalFrom(num(r.RxSignal)), distance: num(r.Distance),
uptime: clean(r.Uptime) || null, lastDown: clean(r.LastDown) || null, firmware: clean(r.Firmware) || null,
managementIp: clean(r.Management) || null, wanIp: clean(r.Internet) || null, voip: clean(r.VoIP) || null, iptv: clean(r.IPTV) || null,
profileId: clean(r.ProfileID) || null, slot: clean(r.Slot) || null, port: clean(r.Port) || null, onuId: clean(r.OnuID) || null,
})
})
})
req.on('error', e => resolve({ ok: false, error: e.message }))
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'délai dépassé (~40 s) — OLT lent ou modem hors ligne' }) })
req.write(body); req.end()
})
}
module.exports = { handle, addComment, activity, createTicket, serviceStatus, dostuffStatus }