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 } }