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>
139 lines
11 KiB
JavaScript
139 lines
11 KiB
JavaScript
'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 => ({ '<': '<', '&': '&', '>': '>', '"': '"' }[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 }
|