'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/) : le token EST le secret → routes CLIENT publiques. // GET /conversations/, 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/. PUBLIC. // Les (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= 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) } })