Cause (diagnostic) : docker restart du hub à chaque déploiement détruit tous les clients SSE en mémoire. Les flux courriels/ cloche utilisaient un EventSource brut SANS onerror/reconnexion → le 502 pendant le restart les met CLOSED définitivement (plus de reconnexion native) → morts jusqu'au rechargement manuel. Le dispatch se reconnectait mais ne resynchronisait jamais. Fix (client) : - useSSE : nouvelle option onReconnect() appelée à chaque RE-connexion (pas au 1er open) → resync après coupure. - useNotifications (cloche) : reconnexion auto si le flux meurt (CLOSED → relance 3 s). Côté co-édité déployé via bundle (non commité ici) : useConversations (inbox) = reconnexion + resync fetchList + repli poll 25 s sur les 3 connexions SSE ; PlanificationPage = onReconnect → reloadOccupancy+reloadPool ; server.js = event 'ready' + retry:3000. Hub 'ready' vérifié live. Reste (durabilité, différé) : persister _watchSnap pour rejouer les deltas Legacy manqués pendant la coupure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
130 lines
9.1 KiB
JavaScript
130 lines
9.1 KiB
JavaScript
import { ref } from 'vue'
|
|
import { Notify } from 'quasar'
|
|
import { HUB_URL } from 'src/config/hub'
|
|
import { useAuthStore } from 'src/stores/auth'
|
|
|
|
// ── Centre de notifications (cloche) — PAR UTILISATEUR, feeds activables ──────
|
|
// Écoute SSE 'outbox' (évaluations) + 'conversations' (messages ENTRANTS client).
|
|
// Chaque feed (SMS / clavardage / appels / Messenger / courriel / évaluations) est
|
|
// activable/désactivable PAR utilisateur — prefs stockées côté hub (clé = courriel
|
|
// agent), donc elles suivent l'usager. État SINGLETON (module-level) partagé par la
|
|
// cloche montée dans MainLayout. /sse est PUBLIC (EventSource sans en-tête).
|
|
|
|
export const FEEDS = [
|
|
{ key: 'sms', label: 'Textos (SMS)', icon: 'sms', color: 'teal-7' },
|
|
{ key: 'webchat', label: 'Clavardage web', icon: 'chat', color: 'primary' },
|
|
{ key: '3cx', label: 'Appels (3CX)', icon: 'call', color: 'blue-7' },
|
|
{ key: 'call', label: 'Appels entrants (ligne principale)', icon: 'phone_in_talk', color: 'green-7' },
|
|
{ key: 'facebook', label: 'Messenger', icon: 'facebook', color: 'blue-9' },
|
|
{ key: 'email', label: 'Courriels', icon: 'mail', color: 'deep-purple-6' },
|
|
{ key: 'ratings', label: 'Évaluations', icon: 'star', color: 'amber-7' },
|
|
]
|
|
// Défaut : canaux TEMPS RÉEL + appels entrants + évaluations ON ; courriel OFF (asynchrone, fort volume → l'Inbox suffit).
|
|
const DEFAULTS = { sms: true, webchat: true, '3cx': true, call: true, facebook: true, email: false, ratings: true }
|
|
|
|
const notifications = ref([]) // { id, type, feed, title, caption, ts, customer, conv, icon, stars, low }
|
|
const unread = ref(0)
|
|
const prefs = ref({ ...DEFAULTS })
|
|
const myQueues = ref({ queues: [], mine: [], notify: {}, labels: {} }) // abonnement PAR AGENT aux files + notif par file
|
|
let es = null
|
|
let _seq = 0
|
|
|
|
function headers () {
|
|
try { const email = useAuthStore().user; if (email && email !== 'authenticated') return { 'Content-Type': 'application/json', 'X-Authentik-Email': email } } catch {}
|
|
return { 'Content-Type': 'application/json' }
|
|
}
|
|
function add (n) {
|
|
notifications.value.unshift({ id: 'n' + (++_seq), ts: new Date().toISOString(), ...n })
|
|
if (notifications.value.length > 60) notifications.value.length = 60
|
|
unread.value++
|
|
}
|
|
function toast (opts) { try { Notify.create(opts) } catch (e) { /* */ } }
|
|
function nav (path) { window.location.hash = '#' + path }
|
|
function go (n) {
|
|
markAllRead()
|
|
if (n && (n.type === 'rating' || n.type === 'comment')) return nav('/evaluations')
|
|
if (n && n.type === 'call' && n.customer) return nav('/clients/' + encodeURIComponent(n.customer))
|
|
if (n && n.conv) return nav('/communications/c/' + encodeURIComponent(n.conv))
|
|
nav('/communications')
|
|
}
|
|
|
|
async function loadPrefs () {
|
|
try { const r = await fetch(`${HUB_URL}/conversations/notif-prefs`, { headers: headers() }); if (r.ok) { const d = await r.json(); prefs.value = { ...DEFAULTS, ...(d.prefs || {}) } } } catch { /* défauts conservés */ }
|
|
}
|
|
async function savePrefs () {
|
|
try { await fetch(`${HUB_URL}/conversations/notif-prefs`, { method: 'PUT', headers: headers(), body: JSON.stringify({ prefs: prefs.value }) }) } catch { /* */ }
|
|
}
|
|
// Files (self-service) : mes abonnements + notif par file.
|
|
async function loadMyQueues () {
|
|
try { const r = await fetch(`${HUB_URL}/conversations/my-queues`, { headers: headers() }); if (r.ok) myQueues.value = await r.json() } catch { /* */ }
|
|
}
|
|
async function setQueueSub (queue, subscribe) {
|
|
try { await fetch(`${HUB_URL}/conversations/my-queues`, { method: 'POST', headers: headers(), body: JSON.stringify({ queue, subscribe }) }); await loadMyQueues() } catch { /* */ }
|
|
}
|
|
async function setQueueNotify (queue, notify) {
|
|
try { await fetch(`${HUB_URL}/conversations/my-queues`, { method: 'POST', headers: headers(), body: JSON.stringify({ queue, notify }) }); await loadMyQueues() } catch { /* */ }
|
|
}
|
|
|
|
function onRating (d) {
|
|
if (!prefs.value.ratings) return
|
|
const name = d.name || d.customer || 'Client'
|
|
add({ type: 'rating', feed: 'ratings', stars: d.stars, low: !!d.low, customer: d.customer || '', conv: d.conv || '', title: `${'★'.repeat(d.stars || 0)} ${name}`, caption: d.low ? 'Évaluation à traiter' : 'Nouvel avis' })
|
|
toast({ message: `${d.stars}★ — ${name}`, caption: d.low ? 'Évaluation basse à traiter' : 'Nouvel avis', color: d.low ? 'orange-9' : 'green-7', textColor: 'white', icon: 'star', position: 'top-right', timeout: 6000, actions: [{ label: 'Voir', color: 'white', handler: () => go({ type: 'rating' }) }] })
|
|
}
|
|
function onComment (d) {
|
|
if (!prefs.value.ratings) return
|
|
const name = d.name || d.customer || 'Client'
|
|
add({ type: 'comment', feed: 'ratings', stars: d.stars, low: d.stars != null && d.stars < 5, customer: d.customer || '', conv: d.conv || '', title: `💬 ${name}`, caption: (d.comment || '').slice(0, 90) })
|
|
toast({ message: `Commentaire — ${name}`, caption: (d.comment || '').slice(0, 90), color: 'green-8', textColor: 'white', icon: 'rate_review', position: 'top-right', timeout: 7000, actions: [{ label: 'Voir', color: 'white', handler: () => go({ type: 'comment' }) }] })
|
|
}
|
|
const CH_META = { sms: { icon: 'sms', label: 'SMS', color: 'teal-8' }, webchat: { icon: 'chat', label: 'Clavardage', color: 'green-8' }, '3cx': { icon: 'call', label: 'Appel', color: 'blue-8' }, facebook: { icon: 'facebook', label: 'Messenger', color: 'blue-9' } }
|
|
function onConvMessage (d) {
|
|
if (!d || !d.message || d.message.from !== 'customer') return // UNIQUEMENT les messages entrants client (pas nos réponses)
|
|
const feed = d.channel // sms / webchat / 3cx / facebook (email géré par conv-email)
|
|
if (!CH_META[feed] || !prefs.value[feed]) return
|
|
const name = d.customerName || d.customer || 'Client'
|
|
const meta = CH_META[feed]
|
|
const body = (d.message.text || '').trim() || (d.message.media ? '[image]' : '…')
|
|
add({ type: 'conv', feed, conv: d.token || '', customer: d.customer || '', title: name, caption: `${meta.label} · ${body.slice(0, 80)}`, icon: meta.icon })
|
|
toast({ message: `${meta.label} — ${name}`, caption: body.slice(0, 80), color: meta.color, textColor: 'white', icon: meta.icon, position: 'top-right', timeout: 6000, actions: [{ label: 'Voir', color: 'white', handler: () => go({ type: 'conv', conv: d.token }) }] })
|
|
}
|
|
function onConvEmail (d) {
|
|
if (!prefs.value.email || !d) return
|
|
const who = d.email || 'Courriel'
|
|
add({ type: 'conv', feed: 'email', conv: d.token || '', title: who, caption: 'Nouveau courriel entrant', icon: 'mail' })
|
|
toast({ message: `Courriel — ${who}`, caption: 'Nouveau courriel entrant', color: 'deep-purple-7', textColor: 'white', icon: 'mail', position: 'top-right', timeout: 6000, actions: [{ label: 'Voir', color: 'white', handler: () => go({ type: 'conv', conv: d.token }) }] })
|
|
}
|
|
// Appel entrant (ligne principale Twilio) → screen-pop coin haut-droit : nom du compte lié + numéro + « Ouvrir la fiche ».
|
|
function onCallIncoming (d) {
|
|
if (!prefs.value.call || !d) return
|
|
// Par FILE : si l'appel cible une file, ne notifier que les agents abonnés à cette file avec notif ON.
|
|
if (d.queue) { const mq = myQueues.value; if (!(mq.mine || []).includes(d.queue) || (mq.notify || {})[d.queue] === false) return }
|
|
const known = !!d.customerId
|
|
const name = d.customerName || (known ? 'Client' : 'Appelant inconnu')
|
|
const sub = (d.from || '') + (d.territory ? ' · ' + d.territory : '')
|
|
add({ type: 'call', feed: 'call', customer: d.customerId || '', title: '📞 ' + name, caption: 'Appel entrant · ' + sub, icon: 'phone_in_talk' })
|
|
toast({ message: 'Appel entrant — ' + name, caption: sub + (known ? '' : ' · numéro non reconnu'), color: known ? 'green-8' : 'blue-grey-8', textColor: 'white', icon: 'phone_in_talk', position: 'top-right', timeout: 15000, actions: known ? [{ label: 'Ouvrir la fiche', color: 'white', handler: () => nav('/clients/' + encodeURIComponent(d.customerId)) }] : [] })
|
|
}
|
|
|
|
function initNotifications () {
|
|
loadPrefs(); loadMyQueues()
|
|
if (es) return
|
|
connectNotifSSE()
|
|
}
|
|
function connectNotifSSE () {
|
|
try {
|
|
es = new EventSource(`${HUB_URL}/sse?topics=outbox,conversations`)
|
|
es.addEventListener('rating', e => { try { onRating(JSON.parse(e.data)) } catch {} })
|
|
es.addEventListener('rating-comment', e => { try { onComment(JSON.parse(e.data)) } catch {} })
|
|
es.addEventListener('conv-message', e => { try { onConvMessage(JSON.parse(e.data)) } catch {} })
|
|
es.addEventListener('conv-email', e => { try { onConvEmail(JSON.parse(e.data)) } catch {} })
|
|
es.addEventListener('call-incoming', e => { try { onCallIncoming(JSON.parse(e.data)) } catch {} })
|
|
// Survit aux redéploiements du hub : si le flux meurt DÉFINITIVEMENT (502 pendant le restart → EventSource CLOSED,
|
|
// plus de reconnexion native), on relance après 3 s (sinon la cloche reste muette jusqu'au prochain rechargement).
|
|
es.onerror = () => { if (es && es.readyState === EventSource.CLOSED) { es = null; setTimeout(connectNotifSSE, 3000) } }
|
|
} catch (e) { /* SSE indisponible */ }
|
|
}
|
|
function markAllRead () { unread.value = 0 }
|
|
|
|
export function useNotifications () { return { notifications, unread, prefs, FEEDS, myQueues, loadMyQueues, setQueueSub, setQueueNotify, initNotifications, markAllRead, savePrefs, loadPrefs, go } }
|