gigafibre-fsm/services/targo-hub/server.js
louispaulb 1af8c62142 feat(planif): géofencing des jobs — timeline En route/Arrivé/Reparti (suivi façon colis)
Le hub compare la position GPS live de chaque tech (Traccar) aux jobs du jour →
détecte les transitions et les journalise par job. Le statut « live » du job est
DÉRIVÉ du journal (compute-on-read, aucune écriture ERPNext — pas de valeur « en
cours » côté doctype). Fiche job : timeline horizontale Assigné → En route → Arrivé
(HH:MM) → Reparti (HH:MM).

- lib/geofence.js : runScan() (rayon 150 m, sortie 230 m hystérésis, séjour 3 min),
  store data/geofence.json par job, machine à états ; startScan() scheduler 90 s
  (GEOFENCE_SCAN=off pour couper). Vérifié prod : 12 jobs × 14 positions, transitions OK.
- server.js : démarre le scan au boot.
- roster.js : GET /roster/job/:name/geofence (timeline) + /geofence-states + /geofence-scan (test).
- api/roster.js : jobGeofence(), geofenceStates().
- PlanificationPage : openJobDetail charge la timeline ; stepper .jd-geo (scoped).

Suite possible : écrire le vrai statut ERPNext à l'arrivée (nécessite d'ajouter une
valeur « En cours » au doctype Dispatch Job) — non fait pour éviter le risque schéma.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:34:55 -04:00

399 lines
28 KiB
JavaScript

'use strict'
const http = require('http')
const { URL } = require('url')
const cfg = require('./lib/config')
const { log, json, parseBody } = require('./lib/helpers')
const rateLimit = require('./lib/ratelimit')
// Limites anti-abus sur les routes PUBLIQUES (token dans l'URL = énumérable) — généreuses pour ne pas gêner un vrai
// usager ; strictes sur l'OTP (brute-force du code SMS). req/fenêtre PAR IP. Webhooks (Twilio/Stripe) EXCLUS
// (authentifiés par signature/token + rafales légitimes) ; /c/ /book /portal /payments exclus (usage interactif).
const RL_RULES = [
{ prefix: '/api/otp', max: 15, win: 60000 },
{ prefix: '/rate', max: 40, win: 60000 },
{ prefix: '/diag/', max: 40, win: 60000 },
{ prefix: '/accept', max: 40, win: 60000 },
{ prefix: '/t/', max: 60, win: 60000 },
{ prefix: '/g/', max: 60, win: 60000 },
{ prefix: '/signup', max: 20, win: 60000 },
{ prefix: '/acte', max: 40, win: 60000 },
]
// Garde-fous de crash : une erreur réseau SORTANTE non interceptée (ex. ECONNRESET « socket hang up » quand
// ERPNext/Frappe — peu de workers — reset une connexion sous rafale) ne doit JAMAIS tuer le gateway. Sans ça, le hub
// plantait et Docker (unless-stopped) le redémarrait → Traefk renvoyait 502 le temps du redémarrage (symptôme :
// « Roster API 502 » en naviguant dans Planification = ~5 appels roster simultanés). On log et on CONTINUE.
process.on('unhandledRejection', (e) => { try { log('unhandledRejection:', (e && e.stack) || e) } catch (_) { /* no-op */ } })
process.on('uncaughtException', (e) => { try { log('uncaughtException:', (e && e.stack) || e) } catch (_) { /* no-op */ } })
const sse = require('./lib/sse')
const twilio = require('./lib/twilio')
const pbx = require('./lib/pbx')
const telephony = require('./lib/telephony')
const devices = require('./lib/devices')
const provision = require('./lib/provision')
const auth = require('./lib/auth')
const conversation = require('./lib/conversation')
const traccar = require('./lib/traccar')
const dispatch = require('./lib/dispatch')
const ical = require('./lib/ical')
const vision = require('./lib/vision')
const giftbit = require('./lib/giftbit')
let voiceAgent
try { voiceAgent = require('./lib/voice-agent') } catch (e) { voiceAgent = null; console.log('Voice agent module not loaded:', e.message) }
// Oktopus stack is decommissioned. Set OKTOPUS_DISABLED=1 (default) to skip
// loading the module and the MQTT monitor — both would otherwise attempt
// connections to a dead broker and spam reconnect errors into stdout. The
// Mongo `devices` collection updates from MQTT heartbeats are no longer
// produced; nothing in ops or the field app reads from that collection.
// Re-enable by setting OKTOPUS_DISABLED=0 in the hub env.
const OKTOPUS_DISABLED = process.env.OKTOPUS_DISABLED !== '0'
let oktopus = null
let oktopusMqtt = null
if (!OKTOPUS_DISABLED) {
try { oktopus = require('./lib/oktopus') } catch (e) { console.log('Oktopus module not loaded:', e.message) }
try { oktopusMqtt = require('./lib/oktopus-mqtt') } catch (e) { console.log('Oktopus MQTT monitor not loaded:', e.message) }
} else {
console.log('Oktopus integration disabled (OKTOPUS_DISABLED=1)')
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, `http://localhost:${cfg.PORT}`)
const path = url.pathname
const method = req.method
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Authentik-Email, X-Authentik-Groups')
if (method === 'OPTIONS') { res.writeHead(204); return res.end() }
try {
// Rate-limit anti-abus (énumération de tokens / brute-force OTP) sur les routes PUBLIQUES sensibles — par IP, fail-open.
{
rateLimit.maybeSweep(60000)
for (const r of RL_RULES) {
if (path.startsWith(r.prefix)) {
if (rateLimit.limited(req, r.prefix, r.max, r.win)) { res.setHeader('Retry-After', '60'); return json(res, 429, { error: 'Trop de requêtes — réessayez dans un instant' }) }
break
}
}
}
// ── Hub auth gate — staged rollout ───────────────────────────────────
// Tiers: PUBLIC (no auth) · customer self-service (own internal auth, also
// listed PUBLIC here, e.g. /payments /portal) · staff-ADMIN (Bearer
// HUB_SERVICE_TOKEN, injected server-side by the ops nginx /hub/ proxy).
// Cross-origin apps with no token (client portal, field-tech) must only
// use PUBLIC routes below.
// HUB_GATE = 'enforce' (401 on deny) | 'report' (log + allow) | unset(off)
// ALWAYS_ENFORCE = already locked & verified (no legit anon traffic) → 401
// regardless of mode. /campaigns/webhook stays public (Mailjet → us).
{
const PUBLIC = [
'/health', '/sse', '/g/', '/c/', '/book', '/field', '/signup', '/store',
'/accept', '/portal', '/magic-link', '/t/', '/acs/', '/diag/', '/rate',
'/acte/form', '/acte/data', '/acte/submit', '/acte/photo', '/acte/entente/doc', // page sous-traitant payé à l'acte (token vérifié dans le handler) — /acte/send /acte/rates /acte/list /acte/img /acte/entente (édition) restent gatés
'/voice/inbound', '/voice/gather', '/voice/connect-agent', '/voice/after-dial', '/voice/twiml', '/voice/status',
'/api/checkout', '/api/catalog', '/api/order', '/api/address', '/api/otp',
'/api/referral', '/api/accept-for-client',
// /payments : routes CLIENT/portail publiques (le portail apps/client appelle
// msg.gigafibre.ca en direct, sans token). Les routes AGENT à risque
// (charge/refund/ppa-run/send-link = mouvement d'argent / envoi) sont
// VOLONTAIREMENT absentes → elles tombent dans le gate et exigent le
// HUB_SERVICE_TOKEN (Ops passe par le proxy /ops/hub qui l'injecte).
// Phase 2 FAIT : balance/methods/invoice/checkout/setup/portal/toggle-ppa sont
// dual-usage → autorisées PAR CLIENT dans lib/payments.js (authorizeCustomer :
// staff token OU JWT magic-link dont `sub` == customer ciblé), bloquant 401 si
// PAYMENTS_AUTH=enforce. Le portail envoie son JWT (sessionStorage targo_portal_jwt).
// Toute NOUVELLE route /payments exposant des données client doit appeler denyIfUnauthorized.
'/payments/pay/', '/payments/return', '/payments/balance/', '/payments/methods/',
'/payments/invoice/', '/payments/checkout', '/payments/setup', '/payments/portal',
'/payments/toggle-ppa',
'/webhook/', '/campaigns/webhook',
'/dispatch/calendar/', // token-authed .ics feed
'/giftbit-demo', // démo sandbox testbed (validation Giftbit) — PUBLIC ; l'API /giftbit/* reste gatée
'/conversations/email-ingest', // ingestion courriel n8n — PUBLIC mais protégé par X-Mail-Token dans le handler
'/conversations/channel-ingest', // ingestion canal (web chat / 3CX / Facebook) — PUBLIC mais protégé par X-Mail-Token
'/prefs', // préférences d'affichage par utilisateur : identité = x-authentik-email (forward-auth SSO), impersonation admin gatée dans le handler (groupes)
'/chatwoot', // Dashboard App Chatwoot : /chatwoot/panel (iframe) PUBLIC ; /chatwoot/lookup protégé par CHATWOOT_PANEL_KEY (PII) dans le handler
]
// Toujours exiger le token (indépendant de HUB_GATE) pour toute route qui
// bouge de l'argent, contrôle réseau/modems, envoie, ou expose PII/RBAC.
// Les carve-outs PUBLIC (convChat, /dispatch/calendar/, routes client /payments)
// gagnent quand même car `isPublic` court-circuite ce bloc en amont.
const ALWAYS_ENFORCE = [
'/devices', '/email-queue', '/campaigns', '/giftbit',
'/auth', '/gmail', '/modem', '/olt', '/traccar', '/admin', '/collab',
'/network', '/sync', '/legacy-payments', '/telephony', '/flow',
'/conversations', '/dispatch', '/sla',
'/payments/charge', '/payments/refund', '/payments/ppa-run', '/payments/send-link',
'/payments/record-invoice', // enregistrement manuel d'un paiement contre facture (crée un Payment Entry) → staff only
]
// Web-chat client (lien magique /c/<token>) : le token EST le secret → routes CLIENT publiques.
// GET /conversations/<token>, GET .../sse, POST .../messages, POST .../push UNIQUEMENT.
// Exclut resolve/archive/discussion (réservées agent) ; le reste de /conversations reste gaté.
const convChat = (() => {
const mm = path.match(/^\/conversations\/([A-Za-z0-9_-]{5,10})(?:\/(messages|sse|push))?$/)
if (!mm || ['resolve', 'archive', 'bulk', 'discussion'].includes(mm[1])) return false
const sub = mm[2]
if (!sub) return method === 'GET' // charger le fil
if (sub === 'sse') return method === 'GET' // flux SSE client
return method === 'POST' // messages | push
})()
// Lecture seule des actifs (images de courriels + bibliothèque) : GET /campaigns/assets/<hash>.<ext> PUBLIC.
// Les <img> (courriels chez le client, vignettes Ops) n'ont pas de token ; le nom = hash de contenu (64 hex, indevinable).
// Le POST /campaigns/assets/upload reste GATÉ (≠ GET et « upload » ne matche pas le motif hash.ext).
const assetGet = method === 'GET' && /^\/campaigns\/assets\/[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(path)
// « Voir dans le navigateur » des courriels (destinataire anonyme) : PUBLIC ; la route /view
// vérifie elle-même ?t=<gift_token> pour les courriels-cadeaux (id rewards-adhoc devinable → non énumérable).
const viewGet = method === 'GET' && /^\/campaigns\/[^/]+\/recipients\/\d+\/view$/.test(path)
const isPublic = convChat || assetGet || viewGet || PUBLIC.some(p => path.startsWith(p))
if (!isPublic) {
const tok = (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
const hasToken = !!process.env.HUB_SERVICE_TOKEN && tok === process.env.HUB_SERVICE_TOKEN
if (!hasToken) {
const mustEnforce = ALWAYS_ENFORCE.some(p => path.startsWith(p))
if (mustEnforce || process.env.HUB_GATE === 'enforce') {
return json(res, 401, { error: 'Unauthorized' })
}
if (process.env.HUB_GATE === 'report') log(`[hub-gate] would-deny ${method} ${path}`)
}
}
}
// Vérif signature Twilio (voix + SMS + statut) — détectée par le header X-Twilio-Signature. Mode REPORT par
// défaut (log, ne bloque PAS) ; TWILIO_VERIFY=enforce → 403. parseBody est mis en cache → le handler re-parse sans souci.
if (method === 'POST' && cfg.TWILIO_AUTH_TOKEN && req.headers['x-twilio-signature']) {
const params = await parseBody(req)
if (!twilio.verifyTwilioSignature(req, params)) {
if (process.env.TWILIO_VERIFY === 'enforce') return json(res, 403, { error: 'Invalid Twilio signature' })
log(`[twilio-sig] would-reject ${method} ${path} (signature invalide — vérifier X-Forwarded-Host/Proto)`)
}
}
if (path === '/health') {
return json(res, 200, { ok: true, clients: sse.clientCount(), topics: sse.topicCount(), uptime: Math.floor(process.uptime()) })
}
if (path === '/sse' && method === 'GET') {
const email = req.headers['x-authentik-email'] || 'anonymous'
const topics = (url.searchParams.get('topics') || '').split(',').map(t => t.trim()).filter(Boolean)
if (!topics.length) return json(res, 400, { error: 'Missing topics parameter' })
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(topics, res, email)
const keepalive = setInterval(() => { try { res.write(': ping\n\n') } catch { clearInterval(keepalive) } }, 25000)
res.on('close', () => clearInterval(keepalive))
return
}
if (path === '/broadcast' && method === 'POST') {
const a = req.headers.authorization || ''
if (cfg.INTERNAL_TOKEN && a !== 'Bearer ' + cfg.INTERNAL_TOKEN) return json(res, 401, { error: 'Unauthorized' })
const body = await parseBody(req)
if (!body.topic || !body.event) return json(res, 400, { error: 'Missing topic or event' })
return json(res, 200, { ok: true, delivered: sse.broadcast(body.topic, body.event, body.data || {}) })
}
if (path === '/webhook/twilio/sms-incoming' && method === 'POST') return twilio.smsIncoming(req, res)
if (path === '/webhook/twilio/sms-status' && method === 'POST') return twilio.smsStatus(req, res)
if (path === '/send/sms' && method === 'POST') return twilio.sendSms(req, res)
if (path === '/voice/token' && method === 'GET') return twilio.voiceToken(req, res, url)
if (path === '/voice/sip-config' && method === 'GET') return twilio.sipConfig(req, res)
if (path === '/voice/twiml' && method === 'POST') return twilio.voiceTwiml(req, res)
if (path === '/voice/status' && method === 'POST') return twilio.voiceStatus(req, res)
if (path === '/voice/inbound' && method === 'POST') return voiceAgent.handleInboundCall(req, res)
if (path === '/voice/gather' && method === 'POST') return voiceAgent.handleGather(req, res)
if (path === '/voice/connect-agent' && method === 'POST') return voiceAgent.handleConnectAgent(req, res)
if (path === '/voice/after-dial' && method === 'POST') return voiceAgent.handleAfterDial(req, res)
if (path === '/voice/heartbeat' && method === 'POST') return voiceAgent.handleHeartbeat(req, res)
if (path === '/voice/to-ai' && method === 'POST') return voiceAgent.handleToAI(req, res)
if (path === '/webhook/3cx/call-event' && method === 'POST') return pbx.callEvent(req, res)
// Uptime-Kuma webhook — synthesized outage alerts
if (path === '/webhook/kuma' && method === 'POST') {
const body = await parseBody(req)
const { handleKumaWebhook } = require('./lib/outage-monitor')
const result = await handleKumaWebhook(body)
return json(res, 200, result)
}
if (path.startsWith('/telephony/')) return telephony.handle(req, res, method, path, url)
if (path.startsWith('/devices')) return devices.handle(req, res, method, path, url)
if (path.startsWith('/acs/')) return devices.handleACSConfig(req, res, method, path, url)
if (path.startsWith('/provision/')) return provision.handle(req, res, method, path)
if (path.startsWith('/oktopus/')) {
if (!oktopus) return require('./lib/helpers').json(res, 410, { error: 'Oktopus integration removed' })
return oktopus.handle(req, res, method, path)
}
if (path.startsWith('/auth/')) return auth.handle(req, res, method, path, url)
if (path.startsWith('/conversations')) return conversation.handle(req, res, method, path, url)
if (path === '/prefs') return require('./lib/user-prefs').handle(req, res, method, path) // préférences d'affichage par utilisateur (filtres/tri/densité)
if (path.startsWith('/sla/')) return require('./lib/sla').handle(req, res, method, path) // politiques SLA (réponse/résolution par file+priorité), éditables dans Settings
if (path.startsWith('/chatwoot/')) return require('./lib/chatwoot').handle(req, res, method, path, url) // Dashboard App Chatwoot : fiche client ERP 360 (panel iframe + lookup protégé par clé)
if (path.startsWith('/billing/')) return require('./lib/proration').handle(req, res, method, path) // moteur de prorata (aperçu lignes proratées par type) — gaté staff
if (path.startsWith('/traccar')) return traccar.handle(req, res, method, path)
if (path.startsWith('/giftbit')) return giftbit.handle(req, res, method, path, url)
if (path.startsWith('/municipality')) return require('./lib/municipality').handle(req, res, method, path, url)
if (path.startsWith('/legacy-sync')) return require('./lib/legacy-sync').handle(req, res, method, path, url)
if (path.startsWith('/legacy-payments')) return require('./lib/legacy-payments').handle(req, res, method, path, url)
if (path.startsWith('/sync/')) return require('./lib/sync-orchestrator').handle(req, res, method, path, url)
if (path.startsWith('/coupons/')) return require('./lib/coupon-triage').handle(req, res, method, path)
if (path.startsWith('/acte')) return require('./lib/subcontractor').handle(req, res, method, path, url) // sous-traitants payés à l'acte (page token + barème + soumissions)
if (path.startsWith('/outbox')) return require('./lib/outbox').handle(req, res, method, path)
if (path.startsWith('/address/conformity')) return require('./lib/address-conformity').handle(req, res, method, path)
if (path.startsWith('/address/') || path.startsWith('/rpc/')) return require('./lib/address-validate').handle(req, res, method, path)
if (path.startsWith('/magic-link')) return require('./lib/magic-link').handle(req, res, method, path)
if (path.startsWith('/portal/')) return require('./lib/portal-auth').handle(req, res, method, path)
// Lightweight tech mobile page: /t/{token}[/action]
if (path.startsWith('/t/')) return require('./lib/tech-mobile').route(req, res, method, path)
if (path.startsWith('/diag/')) return require('./lib/client-diag').route(req, res, method, path) // diagnostic client libre-service (token signé)
if (path.startsWith('/accept')) return require('./lib/acceptance').handle(req, res, method, path)
if (path.startsWith('/api/catalog') || path.startsWith('/api/checkout') || path.startsWith('/api/accept-for-client') || path.startsWith('/api/order') || path.startsWith('/api/address') || path.startsWith('/api/otp')) return require('./lib/checkout').handle(req, res, method, path)
if (path.startsWith('/api/referral/')) return require('./lib/referral').handle(req, res, method, path)
// iCal token: /dispatch/ical-token/TECH-001 (auth required — returns token for building URL)
const icalTokenMatch = path.match(/^\/dispatch\/ical-token\/(.+)$/)
if (icalTokenMatch && method === 'GET') {
const techId = icalTokenMatch[1]
const token = ical.generateToken(techId)
return json(res, 200, { techId, token, url: `/dispatch/calendar/${techId}.ics?token=${token}` })
}
// iCal feed: /dispatch/calendar/TECH-001.ics?token=xxx (token auth, no SSO)
const icalMatch = path.match(/^\/dispatch\/calendar\/(.+)\.ics$/)
if (icalMatch && method === 'GET') return ical.handleCalendar(req, res, icalMatch[1], url.searchParams)
if (path.startsWith('/dispatch/legacy-sync')) return require('./lib/legacy-dispatch-sync').handle(req, res, method, path)
if (path.startsWith('/gmail')) return require('./lib/gmail').handle(req, res, method, path, url)
if (path.startsWith('/supplier-invoices')) return require('./lib/supplier-invoices').handle(req, res, method, path, url)
if (path.startsWith('/collab')) return require('./lib/ticket-collab').handle(req, res, method, path, url)
if (path.startsWith('/dispatch')) return dispatch.handle(req, res, method, path)
if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').handle(req, res, method, path)
// Legacy-MariaDB analytical reports — must be checked BEFORE the ERPNext
// /reports handler so the /reports/legacy/* prefix isn't swallowed.
if (path.startsWith('/reports/legacy')) return require('./lib/legacy-reports').handle(req, res, method, path, url)
if (path.startsWith('/reports')) return require('./lib/reports').handle(req, res, method, path, url)
// Per-address competitor/provider lookup via Québec IHV open data (ADR+FRN).
if (path.startsWith('/serviceability')) return require('./lib/serviceability').handle(req, res, method, path)
// Admin view of ERPNext outbound Email Queue (view/delete/purge).
if (path.startsWith('/email-queue')) return require('./lib/email-queue').handle(req, res, method, path, url)
// Planification (Roster AI) — modèles de shifts, génération via solveur OR-Tools, pause/vacances.
// Copilote roster (Gemini Flash) + politique de reprise — avant le catch-all /roster
if (path === '/roster/assistant' || path === '/roster/policy') return require('./lib/roster-assistant').handle(req, res, method, path)
if (path.startsWith('/roster')) return require('./lib/roster').handle(req, res, method, path, url)
// Portail public de prise de RDV (staging) — page + API client, PUBLIC (pas de SSO).
if (path === '/book' || path.startsWith('/book/')) return require('./lib/roster').handlePublicBooking(req, res, method, path, url)
// App technicien — capture passive du temps (checkpoints GPS/scan), token signé par job, PUBLIC (pas de SSO).
if (path === '/field' || path.startsWith('/field/')) return require('./lib/roster').handleFieldTech(req, res, method, path, url)
// Portail self-service d'abonnement (staging) — page + submit, PUBLIC.
if (path === '/signup' || path.startsWith('/signup/')) return require('./lib/signup').handle(req, res, method, path)
// Boutique matériel (page modèle, staging) — page + (à venir) catalogue ERPNext, PUBLIC.
if (path === '/store' || path.startsWith('/store/')) return require('./lib/store').handle(req, res, method, path)
if (path.startsWith('/campaigns')) return require('./lib/campaigns').handle(req, res, method, path)
// Gift redirect wrapper — short public URLs in campaign emails that
// 302 to the underlying Giftbit shortlink (subject to our expiry/revoke).
if (path.startsWith('/g/') && method === 'GET') return require('./lib/campaigns').handleGiftRedirect(req, res, path)
if (path.startsWith('/rate')) return require('./lib/rating').handle(req, res, method, path, url)
if (path.startsWith('/contract')) return require('./lib/contracts').handle(req, res, method, path)
if (path.startsWith('/payments') || path === '/webhook/stripe') return require('./lib/payments').handle(req, res, method, path, url)
if (path === '/vision/barcodes' && method === 'POST') return vision.handleBarcodes(req, res)
if (path === '/vision/equipment' && method === 'POST') return vision.handleEquipment(req, res)
if (path === '/vision/invoice' && method === 'POST') return vision.handleInvoice(req, res)
if (path === '/vision/payment' && method === 'POST') return vision.handlePayment(req, res)
if (path.startsWith('/ai/')) return require('./lib/ai').handle(req, res, method, path)
if (path.startsWith('/modem')) return require('./lib/modem-bridge').handleModemRequest(req, res, path)
if (path.startsWith('/network/')) return require('./lib/network-intel').handle(req, res, method, path)
if (path.startsWith('/agent/')) return require('./lib/agent').handleAgentApi(req, res, method, path)
if (path.startsWith('/flow/templates')) return require('./lib/flow-templates').handle(req, res, method, path, url)
if (path.startsWith('/flow/runs') || path === '/flow/start' || path === '/flow/instantiate' || path === '/flow/advance' || path === '/flow/complete' || path === '/flow/event') {
return require('./lib/flow-api').handle(req, res, method, path, url)
}
if (path.startsWith('/c/') && method === 'GET') {
const fs = require('fs')
const chatPath = require('path').join(__dirname, 'public', 'chat.html')
try {
const html = fs.readFileSync(chatPath, 'utf8')
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' })
return res.end(html)
} catch { return json(res, 404, { error: 'Chat page not found' }) }
}
if (path.startsWith('/olt')) {
try {
const oltSnmp = require('./lib/olt-snmp')
const oltParts = path.replace('/olt', '').split('/').filter(Boolean)
if ((!oltParts.length || oltParts[0] === 'stats') && method === 'GET') {
return json(res, 200, { olts: oltSnmp.getOltStats() })
}
if (oltParts[0] === 'onus' && method === 'GET') {
const serial = url.searchParams.get('serial')
if (serial) return json(res, 200, oltSnmp.getOnuBySerial(serial) || { error: 'ONU not found' })
return json(res, 200, { onus: oltSnmp.getAllOnus() })
}
if (oltParts[0] === 'poll' && method === 'POST') {
oltSnmp.pollAllOlts().catch(e => log('Manual OLT poll error:', e.message))
return json(res, 200, { ok: true, message: 'OLT poll triggered' })
}
if ((oltParts[0] === 'register' || oltParts[0] === 'config') && method === 'POST') {
const body = await parseBody(req)
if (!body.host || !body.community) return json(res, 400, { error: 'host and community required' })
oltSnmp.registerOlt(body)
return json(res, 200, { ok: true })
}
return json(res, 404, { error: 'OLT endpoint not found' })
} catch (e) {
return json(res, 500, { error: 'OLT module error: ' + e.message })
}
}
json(res, 404, { error: 'Not found' })
} catch (e) {
log('ERROR:', e.message)
json(res, 500, { error: 'Internal error' })
}
})
// WebSocket upgrade for Twilio Media Streams
server.on('upgrade', (req, socket, head) => {
const url = new URL(req.url, `http://localhost:${cfg.PORT}`)
if (url.pathname === '/voice/ws' && voiceAgent) {
const { WebSocketServer } = require('ws')
const wss = new WebSocketServer({ noServer: true })
wss.handleUpgrade(req, socket, head, (ws) => voiceAgent.handleMediaStream(ws, req))
} else {
socket.destroy()
}
})
server.listen(cfg.PORT, '0.0.0.0', () => {
log(`targo-hub listening on :${cfg.PORT}`)
if (voiceAgent) log('Voice agent: enabled')
pbx.startPoller()
devices.startPoller()
try { require('./lib/olt-snmp').startOltPoller() }
catch (e) { log('OLT SNMP poller failed to start:', e.message) }
// Tampon d'envoi sortant (undo-send des notifs de file) : réarme les envois retenus après redémarrage
try { require('./lib/outbox').startup() }
catch (e) { log('outbox startup failed:', e.message) }
if (oktopusMqtt) oktopusMqtt.start()
else log('Oktopus MQTT monitor: skipped (disabled)')
// Cron PPA (auto-paiement carte) — DÉSACTIVÉ par défaut. Politique : scheduler en pause jusqu'au cutover, F autoritaire pour la facturation.
// A causé des charges multiples (pas d'idempotence Stripe + le sync F→ERPNext remet le solde dû → recharge). Ne réactiver (PPA_CRON_ENABLED=on) qu'APRÈS le correctif d'idempotence.
if (String(process.env.PPA_CRON_ENABLED || '').toLowerCase() === 'on') {
try { require('./lib/payments').startPPACron() }
catch (e) { log('PPA cron failed to start:', e.message) }
} else { log('PPA cron DÉSACTIVÉ (PPA_CRON_ENABLED!=on) — auto-paiement carte coupé') }
// Pont legacy (osTicket) → Dispatch Job : tire les tickets « Tech Targo » à dispatcher
try { require('./lib/legacy-dispatch-sync').startSync() }
catch (e) { log('legacy-dispatch-sync failed to start:', e.message) }
// Ingestion Gmail (cc@) → conversations + triage IA ; opt-in GMAIL_INGEST=on + clé /app/data/gmail_sa.json
try { require('./lib/gmail').start() }
catch (e) { log('gmail ingest failed to start:', e.message) }
// Factures fournisseurs : courriels to:factures@ (via cc@) → OCR → candidats à revoir (Purchase Invoice draft)
try { require('./lib/supplier-invoices').start() }
catch (e) { log('supplier-invoices failed to start:', e.message) }
// Scan SLA Chatwoot (émulation EE « SLA ») : convs sans 1re réponse au-delà de la cible → label + note. Opt-in CHATWOOT_SLA_SCAN=on
try { require('./lib/chatwoot').startSlaScan() }
catch (e) { log('chatwoot SLA scan failed to start:', e.message) }
// Auto-reclaim Giftbit : pool les liens cadeaux EXPIRÉS non cliqués (réallocation auto, futures campagnes). Opt-in GIFT_RECLAIM_CRON=on
try { require('./lib/campaigns').startGiftReclaimCron() }
catch (e) { log('gift reclaim cron failed to start:', e.message) }
// Géofencing des jobs : compare GPS live des techs (Traccar) aux jobs du jour → timeline En route/Arrivé/Reparti. Désactivable GEOFENCE_SCAN=off
try { require('./lib/geofence').startScan() }
catch (e) { log('geofence scan failed to start:', e.message) }
})