Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré / juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts, sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle week-end + placeholder « en attente du quart », clustering + lasso + filtre-date sur la carte, accès rapide « À assigner » (badge). Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive + timeout fetch global + authFetch durci → fin des rechargements manuels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
272 lines
21 KiB
JavaScript
272 lines
21 KiB
JavaScript
'use strict'
|
|
/**
|
|
* Évaluation client par étoiles (review-gating) — insérable dans un courriel via le marqueur {{rating}}.
|
|
*
|
|
* - Bloc 5 étoiles : chaque étoile = un lien <a> (les courriels n'exécutent pas de JS).
|
|
* Survol = remplit les étoiles à GAUCHE (truc CSS reverse-order + direction:rtl + `a:hover ~ a`).
|
|
* Marche dans l'aperçu du composeur Ops + les clients qui gardent <style>/:hover ; dégrade en étoiles
|
|
* cliquables ailleurs (le clic redirige toujours).
|
|
* - Clic : GET /rate?t=<token>&s=<1-5> → enregistre + redirige.
|
|
* s == 5 → page d'avis Google (GOOGLE_REVIEW_URL)
|
|
* s < 5 → page interne « merci + qu'est-ce qu'on pourrait améliorer ? » (capte le mécontentement EN PRIVÉ).
|
|
* - Le marqueur {{rating}} est remplacé à l'ENVOI (côté hub) par le bloc, avec un token par destinataire.
|
|
*/
|
|
const crypto = require('crypto')
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
const cfg = require('./config')
|
|
const { log, json, parseBody, readJsonFile, writeJsonFile, lookupCustomersByEmail } = require('./helpers')
|
|
const sse = require('./sse')
|
|
|
|
const FILE = '/app/data/ratings.json'
|
|
const TPL_DIR = path.join(__dirname, '..', 'templates')
|
|
const pub = () => cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'
|
|
const googleUrl = () => cfg.GOOGLE_REVIEW_URL || 'https://search.google.com/local/writereview?placeid='
|
|
|
|
function load () { const d = readJsonFile(FILE, {}); return (d && typeof d === 'object' && !Array.isArray(d)) ? d : {} }
|
|
function save (m) { writeJsonFile(FILE, m) }
|
|
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]))
|
|
|
|
// Crée un jeton d'évaluation (1 par destinataire) → mappe vers {customer, conv, email}.
|
|
function newRatingToken ({ customer = '', conv = '', email = '', name = '', lang = '' } = {}) {
|
|
const token = crypto.randomBytes(9).toString('base64url')
|
|
const m = load()
|
|
m[token] = { customer, conv, email, name, lang: normLang(lang), created: new Date().toISOString(), stars: null, stars_ts: null, comment: null, comment_ts: null }
|
|
save(m)
|
|
return token
|
|
}
|
|
|
|
// Bloc « étoiles cliquables » compatible courriel (technique lang-hack + entités Unicode ★/☆ — aucune police externe).
|
|
// Sélecteur d'évaluation COURRIEL : 5 LIGNES (5★ en haut → 1★ en bas), chaque ligne = un choix cliquable complet
|
|
// (pastille + N étoiles colorées + (5-N) grises). Style Gigafibre : 5★ = vert marque #00C853, dégradé jusqu'au rouge.
|
|
// Plus lisible/robuste que le survol (chaque ligne statique + explicite, AUCUNE animation requise — demande user).
|
|
// Couleur de fond sur le <td> (rendu fiable Outlook) + lien sur toute la pastille/les étoiles → /rate?t=&s=N.
|
|
function ratingBlock (token) {
|
|
const base = `${pub()}/rate?t=${encodeURIComponent(token)}`
|
|
const COLOR = { 5: '#00C853', 4: '#7CB342', 3: '#FBC02D', 2: '#FB8C00', 1: '#E53935' } // dégradé vert→rouge (5★ = vert Gigafibre)
|
|
const EMPTY = '#D7DEE3'
|
|
const SF = "font-family:'Segoe UI Symbol','Apple Symbols','Noto Sans Symbols','Arial Unicode MS',Arial,sans-serif"
|
|
const row = (n) => {
|
|
const href = `${base}&s=${n}`
|
|
const title = `${n} étoile${n > 1 ? 's' : ''} sur 5`
|
|
const dot = `<td width="22" style="padding:0 10px 0 0"><a href="${href}" target="_blank" rel="noopener" title="${title}" style="display:block;width:15px;height:15px;border:2px solid #c4cdd5;border-radius:50%;font-size:1px;line-height:1px;text-decoration:none"> </a></td>`
|
|
let sq = ''
|
|
for (let k = 1; k <= 5; k++) {
|
|
const bg = k <= n ? COLOR[n] : EMPTY
|
|
sq += `<td bgcolor="${bg}" width="40" height="40" align="center" valign="middle" style="background:${bg};border-radius:8px"><a href="${href}" target="_blank" rel="noopener" title="${title}" style="display:block;width:40px;height:40px;line-height:40px;text-align:center;color:#ffffff;font-size:22px;text-decoration:none;${SF}">★</a></td>`
|
|
}
|
|
return `<tr>${dot}${sq}</tr>`
|
|
}
|
|
const rows = [5, 4, 3, 2, 1].map(row).join('')
|
|
return `<table role="presentation" cellpadding="0" cellspacing="4" border="0" style="border-collapse:separate;margin:6px auto"><tbody>${rows}</tbody></table>`
|
|
}
|
|
|
|
// Remplace le marqueur {{rating}} / {{etoiles}} dans le HTML sortant par un bloc tokenisé pour CE destinataire.
|
|
async function expandRatingMarker (html, conv = {}) {
|
|
const s = String(html || '')
|
|
if (!/\{\{\s*(rating|etoiles|évaluation|evaluation)\s*\}\}/i.test(s)) return s
|
|
// Une demande d'évaluation s'adresse à un client CONNU → on garantit le lien à la fiche.
|
|
// Si la conversation n'est pas liée (conv.customer vide), on résout la fiche par COURRIEL.
|
|
let customer = conv.customer || ''
|
|
let name = conv.customerName || ''
|
|
if (!customer && conv.email) {
|
|
try { const r = await lookupCustomersByEmail(conv.email, 1); if (r && r[0]) { customer = r[0].name; if (!name) name = r[0].customer_name || '' } } catch (e) { /* lookup best-effort */ }
|
|
}
|
|
const token = newRatingToken({ customer, conv: conv.token || '', email: conv.email || '', name })
|
|
return s.replace(/\{\{\s*(rating|etoiles|évaluation|evaluation)\s*\}\}/gi, ratingBlock(token))
|
|
}
|
|
|
|
// Page brandée Gigafibre (vert #00C853, encre #1B2E24, Plus Jakarta Sans) — même esprit que gigafibre.ca.
|
|
function shell (title, bodyHtml) {
|
|
return `<!doctype html><html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>${esc(title)}</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root{--g:#00C853;--ink:#1B2E24;--muted:#64748B;--line:#e6ebe8}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;font-family:'Plus Jakarta Sans',-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:var(--ink);background:linear-gradient(165deg,#E6F9EE 0%,#F5FAF7 40%,#ffffff 100%);min-height:100vh}
|
|
.wrap{max-width:560px;margin:0 auto;padding:7vh 20px 40px}
|
|
.brand{display:flex;align-items:center;gap:9px;font-weight:800;font-size:1.4rem;letter-spacing:-.6px;margin-bottom:26px}
|
|
.brand .dot{width:12px;height:12px;border-radius:50%;background:var(--g);box-shadow:0 0 0 4px rgba(0,200,83,.18)}
|
|
.brand b{color:var(--g)}
|
|
.card{background:#fff;border:1px solid #eef2f0;border-radius:22px;box-shadow:0 18px 50px rgba(27,46,36,.10);padding:34px 30px}
|
|
h1{font-size:1.5rem;font-weight:800;letter-spacing:-.5px;margin:0 0 10px}
|
|
p{color:var(--muted);line-height:1.6;font-size:1rem;margin:0 0 8px}
|
|
.stars{font-size:2.1rem;letter-spacing:5px;color:#f5b50a;margin:2px 0 16px}
|
|
textarea{width:100%;border:1.5px solid var(--line);border-radius:14px;padding:14px 16px;font:inherit;font-size:1rem;min-height:130px;resize:vertical;background:#fcfdfc;transition:border-color .15s,box-shadow .15s}
|
|
textarea:focus{outline:none;border-color:var(--g);box-shadow:0 0 0 4px rgba(0,200,83,.15)}
|
|
button{margin-top:16px;background:var(--g);color:#06351d;border:0;border-radius:14px;padding:14px 26px;font:inherit;font-weight:700;font-size:1rem;cursor:pointer;box-shadow:0 8px 20px rgba(0,200,83,.28);transition:filter .15s,transform .08s}
|
|
button:hover{filter:brightness(1.05)}button:active{transform:translateY(1px)}
|
|
.ok{color:var(--g);font-weight:700;margin-top:16px;font-size:1.05rem}
|
|
.foot{text-align:center;color:#9aa7a0;font-size:.8rem;margin-top:22px}
|
|
</style></head>
|
|
<body><div class="wrap">
|
|
<div class="brand"><img src="https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png" alt="TARGO" style="height:34px;width:auto;display:block" /></div>
|
|
<div class="card">${bodyHtml}</div>
|
|
<div class="foot">TARGO — merci pour votre confiance</div>
|
|
</div></body></html>`
|
|
}
|
|
|
|
function feedbackPage (token, stars) {
|
|
const filled = '★'.repeat(Math.max(0, stars)) + '☆'.repeat(Math.max(0, 5 - stars))
|
|
return shell('Merci pour votre évaluation', `
|
|
<div class="stars">${filled}</div>
|
|
<h1>Merci pour votre évaluation !</h1>
|
|
<p>On vise toujours le 5 étoiles. En quelques mots, qu'est-ce qui n'a pas été à la hauteur — ou comment pourrait-on faire mieux ? Votre réponse va directement à notre équipe.</p>
|
|
<form id="rf" onsubmit="return sendComment(event)">
|
|
<textarea id="rc" name="comment" placeholder="Votre commentaire (optionnel, mais très apprécié)…"></textarea>
|
|
<button type="submit">Envoyer mon commentaire</button>
|
|
</form>
|
|
<div id="done" class="ok" style="display:none">✓ Merci ! C'est transmis à notre équipe — on vous revient au besoin.</div>
|
|
<script>
|
|
function sendComment(e){e.preventDefault();var f=document.getElementById('rf');var c=(f.comment.value||'').trim();if(!c){f.comment.focus();return false;}var b=f.querySelector('button');b.disabled=true;fetch('/rate/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:${JSON.stringify(token)},comment:c})}).then(function(){f.style.display='none';document.getElementById('done').style.display='block';}).catch(function(){b.disabled=false;});return false;}
|
|
</script>`)
|
|
}
|
|
|
|
// ── Invitation d'évaluation bilingue (FR/EN) : renvoi courriel/SMS + page d'étoiles autonome (lien SMS) ──
|
|
// Invitation TUTOIEMENT (« tu ») — plus chaleureux/efficace que le vouvoiement.
|
|
const INVITE = {
|
|
fr: { subject: 'Ton avis compte pour nous', title: 'Ton avis compte pour nous', lead: 'Comment évaluerais-tu ton expérience avec TARGO ?',
|
|
greet: (n) => n ? `Bonjour ${n},` : 'Bonjour,',
|
|
intro: "Merci de faire confiance à TARGO. Si tu as quelques secondes pour nous évaluer, ce serait vraiment apprécié :",
|
|
outro: "C'est grâce à ta rétroaction qu'on peut toujours mieux te servir. Si quelque chose ne va pas, réponds simplement à ce courriel — on est là pour t'aider.",
|
|
foot: 'TARGO — merci pour ta confiance',
|
|
sms: "Merci d'avoir choisi TARGO ! Évalue ton expérience en quelques secondes : " },
|
|
en: { subject: 'Your feedback matters to us', title: 'Your feedback matters', lead: 'How would you rate your experience with TARGO?',
|
|
greet: (n) => n ? `Hi ${n},` : 'Hi,',
|
|
intro: 'Thanks for choosing TARGO. If you have a few seconds to rate us, it would mean a lot:',
|
|
outro: "Your feedback helps us serve you better. If something went wrong, just reply to this email — we're here to help.",
|
|
foot: 'TARGO — thank you for your trust',
|
|
sms: 'Thanks for choosing TARGO! Rate your experience in seconds: ' },
|
|
}
|
|
const INVITE_LOGO = 'https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png'
|
|
function normLang (l) { return /^en/i.test(String(l || '')) ? 'en' : 'fr' }
|
|
function firstName (n) { return String(n || '').trim().split(/\s+/)[0] || '' }
|
|
function inviteSubject (lang) { return INVITE[normLang(lang)].subject }
|
|
// Courriel d'invitation MIS EN PAGE (même charte que le transactionnel d'excuse : carte blanche 600px + logo + pied),
|
|
// rehaussé d'un filet vert TARGO et des étoiles dans un encart vert. `stars` = ratingBlock(token) OU le marqueur {{rating}}.
|
|
function inviteEmailDesign (lang, name, stars) {
|
|
const t = INVITE[normLang(lang)]
|
|
return `<!doctype html><html lang="${normLang(lang)}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>`
|
|
+ `<body style="margin:0;background:#f4f6f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">`
|
|
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 16px 28px">`
|
|
+ `<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#ffffff;border-radius:14px;overflow:hidden;border:1px solid #e2e8f0">`
|
|
+ `<tr><td style="background:#ffffff;padding:24px 28px 14px"><img src="${INVITE_LOGO}" alt="TARGO" width="132" style="width:132px;max-width:132px;height:auto;display:block;border:0"></td></tr>`
|
|
+ `<tr><td style="height:4px;background:#00C853;font-size:0;line-height:0;mso-line-height-rule:exactly"> </td></tr>`
|
|
+ `<tr><td style="padding:28px 30px 4px"><h1 style="margin:0 0 10px;font-size:21px;font-weight:800;color:#0f172a">${esc(t.title)}</h1>`
|
|
+ `<p style="margin:0 0 4px;font-size:15px;line-height:1.6;color:#475569">${esc(t.greet(firstName(name)))}</p>`
|
|
+ `<p style="margin:0;font-size:15px;line-height:1.6;color:#475569">${esc(t.intro)}</p></td></tr>`
|
|
+ `<tr><td style="padding:14px 24px 6px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#E6F9EE;border:1px solid #b6f0cf;border-radius:12px"><tr><td align="center" style="padding:14px 8px">${stars}</td></tr></table></td></tr>`
|
|
+ `<tr><td style="padding:8px 30px 26px"><p style="margin:0;font-size:14px;line-height:1.6;color:#64748b">${esc(t.outro)}</p></td></tr>`
|
|
+ `<tr><td style="background:#f8fafc;padding:16px 30px;border-top:1px solid #eef2f7;font-size:12px;color:#94a3b8">${esc(t.foot)}</td></tr>`
|
|
+ `</table></td></tr></table></body></html>`
|
|
}
|
|
// Modèle ÉDITABLE DANS UNLAYER : templates/transactional-rating-invite-<lang>.html (envoyé tel quel),
|
|
// avec marqueurs {{firstname}} (prénom du destinataire) + {{rating}} (bloc étoiles tokenisé à l'envoi).
|
|
// Repli sur le design codé en dur (inviteEmailDesign) si le fichier est absent/illisible.
|
|
// PAS de cache : une modif sauvegardée dans Unlayer (qui réécrit le .html) prend effet sans redémarrer le hub.
|
|
function loadInviteTpl (lang) {
|
|
try { return fs.readFileSync(path.join(TPL_DIR, `transactional-rating-invite-${normLang(lang)}.html`), 'utf8') } catch { return null }
|
|
}
|
|
function mergeName (html, name) {
|
|
const fn = esc(firstName(name))
|
|
return String(html).replace(/\s*\{\{\s*firstname\s*\}\}/gi, fn ? ' ' + fn : '')
|
|
}
|
|
function inviteEmailHtml (token, lang, name) {
|
|
const tpl = loadInviteTpl(lang)
|
|
if (!tpl) return inviteEmailDesign(lang, name, ratingBlock(token))
|
|
return mergeName(tpl, name).replace(/\{\{\s*rating\s*\}\}/gi, ratingBlock(token))
|
|
}
|
|
function inviteEmailMarkerHtml (lang, name) {
|
|
const tpl = loadInviteTpl(lang)
|
|
if (!tpl) return inviteEmailDesign(lang, name, '{{rating}}')
|
|
return mergeName(tpl, name) // {{rating}} laissé en place → expansé à l'envoi par expandRatingMarker
|
|
}
|
|
function inviteSmsText (token, lang) { return INVITE[normLang(lang)].sms + `${pub()}/rate/start?t=${encodeURIComponent(token)}` }
|
|
function invitePage (token, lang) { const t = INVITE[normLang(lang)]; return shell(t.lead, `<h1>${esc(t.lead)}</h1><p>${esc(t.intro)}</p>${ratingBlock(token)}`) }
|
|
// ── Relance « écrire au client » (suite à un avis, surtout mauvais) — brouillon bilingue, PAS de lien d'évaluation ──
|
|
const FOLLOWUP = {
|
|
fr: { subject: 'Suite à votre évaluation',
|
|
html: (n) => `<p>Bonjour${n ? ' ' + n : ''},</p><p>Merci d'avoir pris le temps de nous évaluer. On aimerait mieux comprendre votre expérience et voir comment on peut s'améliorer.</p><p>N'hésitez pas à répondre à ce courriel — on est là pour vous aider.</p>`,
|
|
sms: (n) => `Bonjour${n ? ' ' + n : ''}, merci pour votre évaluation. On aimerait mieux comprendre votre expérience — comment peut-on vous aider ?` },
|
|
en: { subject: 'Following up on your review',
|
|
html: (n) => `<p>Hi${n ? ' ' + n : ''},</p><p>Thank you for taking the time to rate us. We'd like to better understand your experience and see how we can improve.</p><p>Feel free to reply to this email — we're here to help.</p>`,
|
|
sms: (n) => `Hi${n ? ' ' + n : ''}, thanks for your review. We'd like to understand your experience better — how can we help?` },
|
|
}
|
|
function feedbackSubject (lang) { return FOLLOWUP[normLang(lang)].subject }
|
|
function feedbackHtml (name, lang) { return FOLLOWUP[normLang(lang)].html(name || '') }
|
|
function feedbackSms (name, lang) { return FOLLOWUP[normLang(lang)].sms(name || '') }
|
|
|
|
async function handle (req, res, method, path, url) {
|
|
try {
|
|
// GET /rate?t=&s= — enregistre la note + redirige (5 → Google, sinon → feedback interne)
|
|
if (path === '/rate' && method === 'GET') {
|
|
const token = url.searchParams.get('t') || ''
|
|
const s = Math.max(0, Math.min(5, parseInt(url.searchParams.get('s'), 10) || 0))
|
|
// Anti-prefetch : les scanners de sécurité / pré-chargeurs de liens (Gmail, Proofpoint, Slackbot…) GETtent les liens
|
|
// et FAUSSERAIENT la note. On n'enregistre PAS sur ces requêtes ; un vrai clic humain n'a pas ces signaux. (On redirige quand même.)
|
|
const ua = String(req.headers['user-agent'] || '')
|
|
const isPrefetch = /prefetch|preview/i.test(String(req.headers.purpose || req.headers['x-purpose'] || req.headers['x-moz'] || '')) ||
|
|
/bot|crawler|spider|scan|preview|proofpoint|mimecast|barracuda|googleimageproxy|google-read-aloud|slackbot|facebookexternalhit|whatsapp|bingbot|linkpreview|curl|wget|python-requests|headless/i.test(ua)
|
|
const m = load(); const rec = m[token]
|
|
if (rec && !isPrefetch) {
|
|
// Filet : si le lien fiche manque (conv non liée à l'envoi), on résout par courriel au moment du clic.
|
|
if (!rec.customer && rec.email) { try { const cr = await lookupCustomersByEmail(rec.email, 1); if (cr && cr[0]) { rec.customer = cr[0].name; if (!rec.name) rec.name = cr[0].customer_name || '' } } catch (e) { /* */ } }
|
|
const changed = rec.stars !== s
|
|
if (changed) { // re-notation permise → historique (évolution) + note courante mise à jour à chaque clic
|
|
rec.history = (rec.history || []).slice(-19)
|
|
rec.history.push({ stars: s, ts: new Date().toISOString() })
|
|
rec.stars = s; rec.stars_ts = new Date().toISOString()
|
|
}
|
|
save(m)
|
|
if (changed) {
|
|
log(`Rating ${s}★ — customer=${rec.customer || '?'} conv=${rec.conv || '?'}`)
|
|
try { sse.broadcast('outbox', 'rating', { token, stars: s, customer: rec.customer, name: rec.name, low: s < 5 }) } catch (e) { /* */ }
|
|
}
|
|
}
|
|
const dest = s >= 5 ? googleUrl() : `${pub()}/rate/feedback?t=${encodeURIComponent(token)}&s=${s}`
|
|
res.writeHead(302, { Location: dest }); return res.end()
|
|
}
|
|
// GET /rate/feedback?t=&s= — page interne (merci + commentaire)
|
|
if (path === '/rate/feedback' && method === 'GET') {
|
|
const token = url.searchParams.get('t') || ''
|
|
const s = Math.max(0, Math.min(5, parseInt(url.searchParams.get('s'), 10) || 0))
|
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(feedbackPage(token, s))
|
|
}
|
|
// GET /rate/start?t= — page d'étoiles AUTONOME (ouverte depuis le lien SMS d'invitation) ; mêmes étoiles cliquables que le courriel.
|
|
if (path === '/rate/start' && method === 'GET') {
|
|
const token = url.searchParams.get('t') || ''
|
|
const rec = load()[token]
|
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(invitePage(token, (rec && rec.lang) || 'fr'))
|
|
}
|
|
// POST /rate/comment {token, comment} — capte le commentaire (avis privé)
|
|
if (path === '/rate/comment' && method === 'POST') {
|
|
const b = await parseBody(req); const token = String(b.token || ''); const comment = String(b.comment || '').trim().slice(0, 2000)
|
|
const m = load(); const rec = m[token]
|
|
if (rec && comment) { // ne JAMAIS écraser un commentaire par du vide (soumission accidentelle / double-envoi)
|
|
rec.comment = comment; rec.comment_ts = new Date().toISOString(); save(m)
|
|
try { sse.broadcast('outbox', 'rating-comment', { token, comment, stars: rec.stars, customer: rec.customer, name: rec.name }) } catch (e) { /* */ }
|
|
}
|
|
return json(res, 200, { ok: true })
|
|
}
|
|
return json(res, 404, { error: 'not found' })
|
|
} catch (e) { log('rating handle: ' + e.message); return json(res, 500, { error: e.message }) }
|
|
}
|
|
|
|
// Liste des évaluations (récentes d'abord) — pour la vue Ops « Évaluations » (basses prioritaires).
|
|
function listRatings ({ limit = 150, customer = '' } = {}) {
|
|
const m = load()
|
|
let out = Object.entries(m)
|
|
.map(([token, r]) => ({ token, ...r }))
|
|
.filter(r => r.stars != null || r.comment) // seulement celles où le client a agi
|
|
if (customer) out = out.filter(r => r.customer === customer) // évaluations D'UN client précis (pour sa fiche)
|
|
out.sort((a, b) => String(b.stars_ts || b.comment_ts || b.created || '').localeCompare(String(a.stars_ts || a.comment_ts || a.created || '')))
|
|
const low = out.filter(r => r.stars != null && r.stars < 5).length
|
|
return { ratings: out.slice(0, limit), total: out.length, low }
|
|
}
|
|
|
|
// Supprimer une évaluation (ex. avis de test → pollution visuelle dans /Évaluations).
|
|
function deleteRating (token) { const m = load(); if (m[token]) { delete m[token]; save(m); return true } return false }
|
|
module.exports = { handle, ratingBlock, newRatingToken, expandRatingMarker, listRatings, deleteRating, inviteSubject, inviteEmailHtml, inviteEmailMarkerHtml, inviteSmsText, normLang, feedbackSubject, feedbackHtml, feedbackSms }
|