gigafibre-fsm/services/targo-hub/lib/client-diag.js
louispaulb 0f65c02d83 feat(fsm): platform build — comms UI, F→ERPNext sync/billing, roster, campaigns, network, reports
Accumulated work on the dispatch/legacy-writeback branch:
- Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard,
  PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs;
  hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js.
- Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js,
  sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental
  migration scripts; LegacySyncPage, SupplierInvoices + negative-billing /
  terminated-active reports.
- Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit,
  olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers,
  legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets,
  ClientDetail updates.
- docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:12:12 -04:00

139 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict'
// ── Diagnostic CLIENT en libre-service via lien tokenisé ──────────────────────────────────────────
// On envoie au client un lien signé /diag/<jwt> (scope 'customer'). Il ouvre une page mobile qui montre,
// EN DIRECT, ce qui fonctionne (Internet, fibre, Wi-Fi…) — sans aucune donnée interne (série, IP, OLT, dBm).
// Réutilise verifyJwt (magic-link) + serviceStatus (ticket-collab). Route PUBLIQUE : le token EST le secret.
const cfg = require('./config')
const { log } = require('./helpers')
const { verifyJwt } = require('./magic-link')
function sendHtml (res, code, html) { res.writeHead(code, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(html) }
function sendJson (res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(obj)) }
// Transforme le statut interne en vue CLIENT (aucune fuite : pas de série, MAC, IP, OLT, dBm).
function clientView (s, payload) {
const sum = (s && s.summary) || {}
// Vue CLIENT : pilotée par « en ligne » (sur FTTH, si on est en ligne, la fibre est up). Le détail fibre/OLT
// reste côté agent — un poller OLT en panne chez NOUS ne doit jamais afficher « problème » à un client servi.
const online = sum.online === true
const tel = (s && s.subscriptions ? s.subscriptions : []).some(x => /t[ée]l[ée]phon|voip|sip/i.test(String(x.plan || '')))
const checks = [
{ key: 'internet', label: 'Internet', ok: online },
{ key: 'wifi', label: 'Wi-Fi', ok: online },
]
if (tel) checks.push({ key: 'tel', label: 'Téléphonie', ok: online })
return {
name: (payload && payload.name) || (s && s.customer && s.customer.customer_name) || '',
allOk: checks.every(c => c.ok),
online,
checks,
plans: (s && s.subscriptions ? s.subscriptions : []).map(x => x.plan).filter(Boolean).slice(0, 4),
address: s && s.location ? (s.location.address || '') : '',
ts: new Date().toISOString(),
}
}
const ESC = (t) => String(t == null ? '' : t).replace(/[<&>"]/g, c => ({ '<': '&lt;', '&': '&amp;', '>': '&gt;', '"': '&quot;' }[c]))
function expiredPage () {
return `<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lien expiré</title>
<style>body{font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;background:#f1f5f9;color:#1e293b;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px}.c{background:#fff;border-radius:16px;padding:28px;max-width:380px;text-align:center;box-shadow:0 8px 30px rgba(0,0,0,.08)}</style>
</head><body><div class="c"><h2 style="color:#0e7490">Gigafibre</h2><p>Ce lien de diagnostic a expiré ou n'est pas valide.</p><p style="color:#64748b;font-size:14px">Répondez à notre message et nous vous en enverrons un nouveau.</p></div></body></html>`
}
function page (token) {
return `<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Diagnostic Gigafibre</title>
<style>
:root{--ok:#16a34a;--bad:#dc2626;--ink:#0f172a;--mut:#64748b;--bg:#f1f5f9;--teal:#0e7490}
*{box-sizing:border-box}body{font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;background:var(--bg);color:var(--ink);margin:0;padding:18px}
.wrap{max-width:440px;margin:0 auto}
.brand{color:var(--teal);font-weight:700;font-size:20px;text-align:center;margin:6px 0 2px}
.sub{color:var(--mut);font-size:13px;text-align:center;margin-bottom:16px}
.hero{border-radius:16px;padding:22px;text-align:center;color:#fff;margin-bottom:14px}
.hero.ok{background:#16a34a}.hero.bad{background:#dc2626}.hero.wait{background:#64748b}
.hero .big{font-size:20px;font-weight:600}.hero .em{font-size:40px;line-height:1}
.card{background:#fff;border-radius:14px;padding:6px 14px;box-shadow:0 4px 16px rgba(0,0,0,.06)}
.row{display:flex;align-items:center;padding:13px 2px;border-bottom:1px solid #f1f5f9}.row:last-child{border-bottom:none}
.row .lbl{flex:1;font-size:16px}.dot{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-size:14px;font-weight:700}
.dot.ok{background:var(--ok)}.dot.bad{background:var(--bad)}
.plans{color:var(--mut);font-size:13px;text-align:center;margin-top:12px}
.btn{display:block;width:100%;margin-top:16px;padding:13px;border:0;border-radius:12px;background:#fff;color:var(--teal);font-size:15px;font-weight:600;box-shadow:0 2px 10px rgba(0,0,0,.06)}
.foot{color:var(--mut);font-size:12px;text-align:center;margin-top:16px;line-height:1.6}
.skel{height:14px;background:#e2e8f0;border-radius:6px;margin:8px 0;animation:p 1.2s infinite}@keyframes p{50%{opacity:.5}}
.chatbox{background:#fff;border-radius:14px;padding:10px;max-height:280px;overflow-y:auto;box-shadow:0 4px 16px rgba(0,0,0,.06);margin-top:8px}
.msg{padding:9px 12px;border-radius:14px;margin:6px 0;font-size:15px;max-width:85%;line-height:1.4;white-space:pre-wrap}
.msg.me{background:#0e7490;color:#fff;margin-left:auto;border-bottom-right-radius:4px}
.msg.ai{background:#f1f5f9;color:#0f172a;border-bottom-left-radius:4px}
.chatrow{display:flex;gap:8px;margin-top:8px}
.chatrow input{flex:1;padding:12px;border:1px solid #cbd5e1;border-radius:12px;font-size:15px}
.chatrow button{padding:12px 16px;border:0;border-radius:12px;background:#0e7490;color:#fff;font-weight:600}
.typing{font-size:13px;color:#64748b;padding:4px 6px}
</style></head><body><div class="wrap">
<div class="brand">Gigafibre</div><div class="sub" id="sub">Diagnostic de votre service</div>
<div id="hero" class="hero wait"><div class="em">⏳</div><div class="big">Vérification en cours…</div></div>
<div class="card" id="checks"><div class="skel" style="width:80%"></div><div class="skel" style="width:60%"></div><div class="skel" style="width:70%"></div></div>
<div class="plans" id="plans"></div>
<button class="btn" id="refresh" onclick="run()">Revérifier</button>
<button class="btn" id="chatToggle" onclick="openChat()">Une question ? Discuter avec l'assistant</button>
<div id="chat" style="display:none">
<div class="chatbox" id="chatbox"></div>
<div class="chatrow"><input id="chatinp" placeholder="Décrivez votre problème…" autocomplete="off" onkeydown="if(event.key==='Enter'){event.preventDefault();sendChat()}"><button onclick="sendChat()">Envoyer</button></div>
</div>
<div class="foot">Toujours un souci ? Discutez avec l'assistant ci-dessus — un agent prend le relais au besoin.<br>© Gigafibre · TARGO</div>
</div><script>
var TOKEN=${JSON.stringify(token)};
function run(){
var hero=document.getElementById('hero');hero.className='hero wait';hero.innerHTML='<div class="em">⏳</div><div class="big">Vérification en cours…</div>';
fetch('/diag/'+TOKEN+'/data',{cache:'no-store'}).then(function(r){return r.json()}).then(function(d){
if(d.error){document.getElementById('sub').textContent='Lien expiré';hero.className='hero wait';hero.innerHTML='<div class="big">Lien expiré</div>';return}
if(d.name)document.getElementById('sub').textContent='Bonjour '+d.name+(d.address?' · '+d.address:'');
hero.className='hero '+(d.allOk?'ok':'bad');
hero.innerHTML='<div class="em">'+(d.allOk?'✓':'!')+'</div><div class="big">'+(d.allOk?'Tout fonctionne':'Un problème est détecté')+'</div>';
var html='';(d.checks||[]).forEach(function(c){html+='<div class="row"><span class="lbl">'+c.label+'</span><span class="dot '+(c.ok?'ok':'bad')+'">'+(c.ok?'✓':'×')+'</span></div>'});
document.getElementById('checks').innerHTML=html;
document.getElementById('plans').textContent=(d.plans&&d.plans.length)?('Forfait : '+d.plans.join(' · ')):'';
}).catch(function(){hero.className='hero wait';hero.innerHTML='<div class="big">Réessayez dans un instant</div>'});
}
run();
var CONV=null,ES=null;
function openChat(){document.getElementById('chat').style.display='block';document.getElementById('chatToggle').style.display='none';document.getElementById('chatinp').focus()}
function addMsg(who,text){var b=document.getElementById('chatbox');var d=document.createElement('div');d.className='msg '+who;d.textContent=text;b.appendChild(d);b.scrollTop=b.scrollHeight}
function connect(conv){CONV=conv;try{ES=new EventSource('/conversations/'+conv+'/sse');ES.addEventListener('conv-message',function(e){try{var m=JSON.parse(e.data).message;if(m&&m.from!=='customer'&&m.text)addMsg('ai',m.text)}catch(x){}})}catch(x){}}
function sendChat(){var inp=document.getElementById('chatinp');var t=(inp.value||'').trim();if(!t)return;inp.value='';addMsg('me',t);
if(!CONV){fetch('/diag/'+TOKEN+'/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:t})}).then(function(r){return r.json()}).then(function(d){if(d.convToken)connect(d.convToken);else addMsg('ai','Désolé, réessayez dans un instant.')}).catch(function(){addMsg('ai','Connexion interrompue, réessayez.')})}
else{fetch('/conversations/'+CONV+'/messages',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:t})})}
}
</script></body></html>`
}
async function route (req, res, method, path) {
const m = path.match(/^\/diag\/([A-Za-z0-9._-]+?)(\/data|\/chat)?$/)
if (!m) return sendHtml(res, 404, '<h1>404</h1>')
const token = m[1]; const sub = m[2] || ''
const payload = verifyJwt(token)
if (!payload || payload.scope !== 'customer' || !payload.sub) {
return sub ? sendJson(res, 401, { error: 'expired' }) : sendHtml(res, 200, expiredPage())
}
// CHAT : 1er message du client → crée une conversation (liée au compte) + déclenche l'IA (même moteur que le SMS/web-chat).
// L'IA a l'outil check_device_status → réponses diagnostiques ; la conversation remonte dans l'Inbox agent (escalade).
if (sub === '/chat' && method === 'POST') {
let body = ''; await new Promise(r => { req.on('data', c => { body += c }); req.on('end', r); req.on('error', r) })
let text = ''; try { text = String((JSON.parse(body || '{}')).text || '').trim() } catch (e) { /* */ }
if (!text) return sendJson(res, 400, { error: 'text requis' })
const conv = require('./conversation')
const c = conv.createConversation({ phone: null, customer: payload.sub, customerName: payload.name || payload.sub, subject: 'Diagnostic en ligne — ' + (payload.name || payload.sub) })
c.channel = 'webchat'; c.channelKey = 'diag:' + payload.sub; c.queue = 'Supports'; if (payload.email) c.email = payload.email // escalade fiable vers l'Inbox agent
conv.addMessage(c, { from: 'customer', text, via: 'webchat' })
try { conv.triggerAgent(c) } catch (e) { /* */ }
log('client-diag chat: conv ' + c.token + ' (' + payload.sub + ')')
return sendJson(res, 200, { ok: true, convToken: c.token })
}
if (method !== 'GET') return sendHtml(res, 404, '<h1>404</h1>')
if (sub === '/data') {
try { const s = await require('./ticket-collab').serviceStatus({ customer: payload.sub }); return sendJson(res, 200, clientView(s, payload)) } catch (e) { log('client-diag data ' + payload.sub + ': ' + e.message); return sendJson(res, 500, { error: 'indisponible' }) }
}
return sendHtml(res, 200, page(token))
}
module.exports = { route, clientView }