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>
93 lines
6.6 KiB
JavaScript
93 lines
6.6 KiB
JavaScript
'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 }
|