gigafibre-fsm/services/targo-hub/lib/coupon-triage.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

93 lines
6.6 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'
// ─────────────────────────────────────────────────────────────────────────────
// coupon-triage.js — Levier 1 : qu'une demande de coupons hotspot ne « dorme » plus.
// Courriel reçu → détecte l'intention → RÉSOUT la ferme par le COURRIEL de l'expéditeur
// (pas le nom : ex. Chantaltrottier67@gmail.com → Jardins Lefort Inc) → crée un ticket
// suivi (file support) → ALERTE (n8n → Google Chat). Zéro dépendance à F.
// L'ÉMISSION des coupons (camping_customer_batch) reste manuelle/F pour l'instant (Levier 2).
// ─────────────────────────────────────────────────────────────────────────────
const https = require('https')
const { json, log, parseBody, lookupCustomersByEmail } = require('./helpers')
const COUPON_RX = /coupon|hotspot|code\s*d['e]?\s*acc[èe]s|acc[èe]s\s*wifi|wifi\s*(temporaire|invit)|forfait\s*camping|travailleur|prépay|prepaid|carte\s*temps/i
const QTY_RX = /(\d{1,3})\s*(coupons?|codes?|acc[èe]s|travailleurs?|cartes?|forfaits?)/i
// Détecte l'intention « coupons » + une quantité éventuelle dans le sujet/corps.
function detectCoupon (subject, body) {
const txt = `${subject || ''}\n${body || ''}`
const m = txt.match(QTY_RX)
return { isCoupon: COUPON_RX.test(txt), qty: m ? parseInt(m[1], 10) : null }
}
// Alerte vers n8n (qui poste dans Google Chat) ou un webhook Google Chat direct.
// Configurable : N8N_COUPON_WEBHOOK ou GCHAT_WEBHOOK. Sans config → loggé + sauté (pas d'échec).
async function alertWebhook (payload) {
const url = process.env.N8N_COUPON_WEBHOOK || process.env.GCHAT_WEBHOOK || ''
if (!url) { log('coupon-triage: aucun webhook (N8N_COUPON_WEBHOOK/GCHAT_WEBHOOK) — alerte sautée'); return { alerted: false, reason: 'no_webhook' } }
const body = JSON.stringify({ text: payload.text, ...payload }) // Google Chat lit {text} ; n8n lit tout
return new Promise((resolve) => {
try {
const u = new URL(url)
const req = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: 'POST', timeout: 8000, headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } },
res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve({ alerted: res.statusCode < 400, status: res.statusCode })) })
req.on('error', e => { log('coupon alert err: ' + e.message); resolve({ alerted: false, error: e.message }) })
req.on('timeout', () => req.destroy(new Error('timeout')))
req.write(body); req.end()
} catch (e) { resolve({ alerted: false, error: e.message }) }
})
}
// Triage d'une demande. dryRun=true → détecte + résout, sans rien créer.
async function triage ({ from = '', subject = '', body = '', agent = '', dryRun = false } = {}) {
const det = detectCoupon(subject, body)
const matches = from ? (await lookupCustomersByEmail(from).catch(() => [])) : []
const farm = (matches && matches.length) ? matches[0] : null
const out = {
detected: det.isCoupon, qty: det.qty, from,
farm: farm ? { name: farm.name, customer_name: farm.customer_name } : null,
matches: (matches || []).length,
}
if (!det.isCoupon) { out.note = 'non détecté comme demande de coupons'; return out }
if (dryRun) { out.dry_run = true; return out }
// 1) ticket suivi (file support ; signale si la ferme n'est pas résolue)
const title = `Coupons hotspot${det.qty ? ` ×${det.qty}` : ''}${farm ? `${farm.customer_name}` : ''}`.slice(0, 120)
const desc = `Demande de coupons hotspot${det.qty ? ` (×${det.qty})` : ''} reçue de ${from}.\n` +
(farm ? `Ferme facturable résolue par courriel : ${farm.customer_name} (${farm.name}).` : '⚠ Ferme NON résolue par le courriel expéditeur — à identifier (mentionnée dans le message ?).') +
`\n\n--- message ---\n${String(body || '').slice(0, 800)}`
let ticket = {}
try { ticket = await require('./ticket-collab').createTicket({ title, category: 'Support', priority: 'Medium', status: 'Open', customer: farm ? farm.name : undefined, customer_name: farm ? farm.customer_name : undefined, queue: 'Supports', description: desc, agent }) }
catch (e) { log('coupon-triage createTicket: ' + e.message); ticket = { ok: false, error: e.message } }
// 2) alerte (n8n → Google Chat)
const text = `🎟️ Demande de coupons${det.qty ? ` ×${det.qty}` : ''}${farm ? farm.customer_name : 'ferme à IDENTIFIER (' + from + ')'}` +
`${ticket.name ? ` · ticket ${ticket.name}` : ''}${farm ? '' : ' · ⚠ courriel non résolu'}`
const alert = await alertWebhook({ text, from, qty: det.qty, farm: out.farm, ticket: ticket.name || null })
return { ...out, ticket: ticket.name || null, ticket_ok: ticket.ok !== false, alert }
}
// Hook AUTO depuis l'inbox (ingestEmail) : la conversation est déjà le suivi (client résolu + file) →
// on NE crée PAS de ticket séparé, on MARQUE la conv « coupons » + on ALERTE (Google Chat via n8n) pour ne pas la laisser dormir.
async function onEmail ({ from = '', subject = '', body = '', conv = null } = {}) {
const det = detectCoupon(subject, body)
if (!det.isCoupon) return { detected: false }
if (conv) { conv.couponRequest = { qty: det.qty, ts: new Date().toISOString() }; if (!conv.queue) conv.queue = 'Supports' }
const farmName = (conv && (conv.customerName || conv.customer)) || from
const link = (conv && conv.token) ? `\n${process.env.OPS_BASE_URL || 'https://ops.gigafibre.ca'}/#/conversations?c=${conv.token}` : ''
const text = `🎟️ Demande de coupons${det.qty ? ` ×${det.qty}` : ''}${farmName}${(conv && conv.customer) ? '' : ' · ⚠ ferme à identifier'}${link}`
const alert = await alertWebhook({ text, from, qty: det.qty, customer: (conv && conv.customer) || null, conv: (conv && conv.token) || null })
log(`coupon-triage onEmail: ${from} qty=${det.qty} alerted=${alert.alerted}`)
return { detected: true, qty: det.qty, alerted: alert.alerted }
}
async function handle (req, res, method, p) {
if (p === '/coupons/triage' && method === 'POST') {
try { const b = await parseBody(req); return json(res, 200, await triage({ from: b.from, subject: b.subject, body: b.body, agent: req.headers['x-authentik-email'] || b.agent || '', dryRun: !!b.dryRun })) }
catch (e) { return json(res, 500, { error: e.message }) }
}
return json(res, 404, { error: 'route coupons inconnue' })
}
module.exports = { handle, triage, onEmail, detectCoupon, alertWebhook }