/* Alertes unifiées dracodev — Fusion #69 (chantier 2). * * Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque * ouverture du panneau (et au chargement, pour la pastille), le module * interroge les API de notifications réellement exposées par chaque * application, avec les identifiants du navigateur (credentials: * "include"). Aucune alerte n'est inventée : une source injoignable * ou non authentifiée affiche son état réel (« Connexion requise ») * avec un lien profond vers sa page native, jamais du vide déguisé. * * Sources réelles (audit #69, 2026-10-28) : * - Flowdeck : GET {base}/api/notifications?limit=50 * -> { notifications: [{id, ntype, title, message, url, is_read, * created_at}], unread } ; active = is_read = 0 ; lien = base + url. * - TikTak : GET {base}/api/notifications * -> [{id, kind, title, body, task_id, is_read, created_at}] ; * active = is_read = 0 ; lien = base + "/?task=" + task_id, sinon * base + "/notifications". * - Kanbante : GET {base}/api/events?limit=100 * -> { events: [{repo_full_name, issue_number, event_type, * from_column, to_column, created_at}] } ; AUCUN état lu/non lu * n'existe dans Kanbante, donc tout y est historique et rien n'y * est compté comme actif ; lien = base + "/board/" + repo. * - ObsiGate : AUCUNE notification entrante consultable. Ses * /api/notify/* sont des canaux d'envoi sortants (admin, secrets * masqués), pas un journal lisible par l'utilisateur. La section * ObsiGate le dit et renvoie vers l'accueil d'ObsiGate. * * Résolution des bases (même ordre que barre-apps v0.1.6) : * 1. window.DRACO_BAR.urls déjà résolue par la barre, sinon * window.DRACO_BAR_CONFIG.urls (config explicite de l'hôte) ; * 2. dérivation -dev par l'hôte courant (hôte -dev dracodev.net) ; * 3. href rendu de l'onglet .draco-apptab[data-app] ; * 4. défaut officiel (production). * ObsiGate n'a pas de .draco-apptab dans tous les cas : l'étape 3 y * lit aussi window.DRACO_URLS (injecté par son backend). * Aucun nom en -dev n'est un défaut : la dérivée -dev ne s'applique * que sur un hôte -dev, exactement comme la barre. * * Le fichier ne touche à AUCUN fichier ui/barre-apps.* : il consomme * seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts * pour la pastille (repli : mise à jour directe du badge). * Insertion des données : textContent uniquement, jamais innerHTML * avec une valeur venue du serveur. */ (function (global) { "use strict"; var DEFAULT_URLS = { obsigate: "https://og.dracodev.net/", flowdeck: "https://flowdeck.dracodev.net/", tiktak: "https://tiktak.dracodev.net/", kanbante: "https://kanbante.dracodev.net/" }; var DEV_URLS = { obsigate: "https://og-dev.dracodev.net/", flowdeck: "https://flowdeck-dev.dracodev.net/", tiktak: "https://tiktak-dev.dracodev.net/", kanbante: "https://kanbante-dev.dracodev.net/" }; var APP_LABELS = { obsigate: "ObsiGate", flowdeck: "Flowdeck", tiktak: "TikTak", kanbante: "Kanbante" }; var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"]; var EVENT_LABELS = { created: "créée", moved: "déplacée", closed: "fermée", reopened: "rouverte", commented: "commentée" }; function isDevHost(hostname) { if (!hostname) return false; var h = String(hostname).toLowerCase(); if (h.slice(-13) !== ".dracodev.net") return false; var first = h.split(".")[0]; return first.length > 4 && first.slice(-4) === "-dev"; } function cleanUrl(u) { if (!u || typeof u !== "string") return null; if (u.indexOf("{{") !== -1) return null; if (!u.replace(/\s/g, "")) return null; return u; } function withSlash(u) { return u.charAt(u.length - 1) !== "/" ? u + "/" : u; } function joinUrl(base, path) { var b = withSlash(base); if (!path) return b; if (/^https?:\/\//.test(path)) return path; return b + String(path).replace(/^\//, ""); } function parseDate(s) { if (!s) return null; var t = new Date(String(s).indexOf("T") === -1 && String(s).indexOf("Z") === -1 ? String(s).replace(" ", "T") + "Z" : s); return isNaN(t.getTime()) ? null : t; } function fmtDate(s) { var t = parseDate(s); if (!t) return s ? String(s).slice(0, 16) : ""; try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); } } /* Résolution des bases. `env` permet les tests sans navigateur : * { hostname, barUrls, configUrls, tabHrefs, dracoUrls }. */ function resolveUrls(env) { env = env || {}; var hostname = env.hostname; if (hostname === undefined) { try { hostname = (global.location && global.location.hostname) || ""; } catch (e) { hostname = ""; } } var dev = isDevHost(hostname); var urls = {}; APP_ORDER.forEach(function (app) { var u = cleanUrl(env.barUrls && env.barUrls[app]) || cleanUrl(env.configUrls && env.configUrls[app]); if (!u && dev) u = DEV_URLS[app] || null; if (!u) { var href = null; if (env.tabHrefs && env.tabHrefs[app]) href = env.tabHrefs[app]; else if (typeof document !== "undefined" && document.querySelector) { var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]'); href = tab ? tab.getAttribute("href") : null; } u = cleanUrl(href); } if (!u && env.dracoUrls && env.dracoUrls[app]) u = cleanUrl(env.dracoUrls[app]); if (!u) { /* ObsiGate : window.DRACO_URLS injecté par le backend. */ try { if (global.DRACO_URLS && global.DRACO_URLS[app]) u = cleanUrl(global.DRACO_URLS[app]); } catch (e) { /* rien */ } } if (!u) u = DEFAULT_URLS[app]; urls[app] = withSlash(u); }); return urls; } function browserEnv() { var cfg = global.DRACO_BAR_CONFIG || {}; var bar = global.DRACO_BAR || {}; var tabs = {}; try { if (typeof document !== "undefined" && document.querySelectorAll) { var nodes = document.querySelectorAll(".draco-apptab[data-app]"); for (var i = 0; i < nodes.length; i++) { tabs[nodes[i].getAttribute("data-app")] = nodes[i].getAttribute("href"); } } } catch (e) { /* rien */ } return { barUrls: bar.urls || null, configUrls: cfg.urls || null, tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null }; } function normalizeFlowdeck(base, payload) { var list = (payload && payload.notifications) || []; return list.map(function (n) { return { app: "flowdeck", id: n.id, title: n.title || "(sans titre)", detail: n.message || "", date: n.created_at || "", active: !n.is_read, state: n.is_read ? "lue" : "active", kind: n.ntype || "", url: joinUrl(base, n.url || "") }; }); } function normalizeTiktak(base, payload) { var list = Array.isArray(payload) ? payload : (payload && payload.notifications) || []; return list.map(function (n) { return { app: "tiktak", id: n.id, title: n.title || "(sans titre)", detail: n.body || "", date: n.created_at || "", active: !n.is_read, state: n.is_read ? "lue" : "active", kind: n.kind || "", url: n.task_id ? withSlash(base) + "?task=" + n.task_id : joinUrl(base, "notifications") }; }); } function normalizeKanbante(base, payload) { var list = (payload && payload.events) || []; return list.map(function (e) { var label = EVENT_LABELS[e.event_type] || e.event_type || "événement"; var detail = (e.from_column ? e.from_column + " → " : "") + (e.to_column || ""); return { app: "kanbante", id: e.id, title: (e.repo_full_name || "?") + " #" + e.issue_number + " — " + label, detail: detail, date: e.created_at || "", active: false, state: label, kind: e.event_type || "", url: joinUrl(base, "board/" + (e.repo_full_name || "")) }; }); } /* Actives d'abord, puis date décroissante (sans date en dernier). */ function sortAlerts(list) { return list.slice().sort(function (a, b) { if (a.active !== b.active) return a.active ? -1 : 1; var da = parseDate(a.date), db = parseDate(b.date); if (da && db) return db - da; if (da) return -1; if (db) return 1; return 0; }); } function countActive(list) { var n = 0; list.forEach(function (a) { if (a.active) n++; }); return n; } /* ---- Rendu (navigateur seulement) ---- */ var panel = null, listEl = null, badgeTimer = null; function el(tag, cls, text) { var d = document.createElement(tag); if (cls) d.className = cls; if (text !== undefined && text !== null) d.textContent = text; return d; } function ensurePanel() { if (panel) return panel; var ov = el("div", "draco-alertes-overlay"); ov.setAttribute("role", "dialog"); ov.setAttribute("aria-label", "Alertes unifiées"); var box = el("div", "draco-alertes-box"); var head = el("div", "draco-alertes-head"); head.appendChild(el("h2", "", "Alertes unifiées")); var note = el("p", "draco-alertes-note", "Agrégation à la demande, pas une fédération temps réel : " + "les alertes sont relues dans chaque application à l'ouverture."); var close = el("button", "draco-alertes-close", "✕"); close.setAttribute("aria-label", "Fermer"); close.addEventListener("click", closePanel); head.appendChild(close); box.appendChild(head); box.appendChild(note); listEl = el("div", "draco-alertes-list"); box.appendChild(listEl); ov.appendChild(box); ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); }); document.addEventListener("keydown", function (ev) { if (ev.key === "Escape") closePanel(); }); document.body.appendChild(ov); panel = ov; return ov; } function closePanel() { if (panel) panel.style.display = "none"; } function renderSection(title, items, fallback) { var sec = el("section", "draco-alertes-section"); sec.appendChild(el("h3", "", title + " (" + items.length + ")")); if (!items.length) { sec.appendChild(el("p", "draco-alertes-empty", fallback)); return sec; } items.forEach(function (a) { var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : "")); var main = el("div", "draco-alertes-main"); var link = el("a", "draco-alertes-title", a.title); link.href = a.url; main.appendChild(link); if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail)); var meta = el("div", "draco-alertes-meta", APP_LABELS[a.app] + (a.kind ? " · " + a.kind : "") + " · " + (a.date ? fmtDate(a.date) : "sans date") + " · " + a.state); main.appendChild(meta); row.appendChild(main); sec.appendChild(row); }); return sec; } function render(results, urls) { ensurePanel(); listEl.textContent = ""; var all = []; APP_ORDER.forEach(function (app) { (results[app] && results[app].items || []).forEach(function (a) { all.push(a); }); }); var actives = sortAlerts(all.filter(function (a) { return a.active; })); var history = sortAlerts(all.filter(function (a) { return !a.active; })); listEl.appendChild(el("h3", "draco-alertes-group", "Alertes actives")); APP_ORDER.forEach(function (app) { var items = actives.filter(function (a) { return a.app === app; }); if (results[app] && results[app].error) { var sec = renderSection(APP_LABELS[app], [], results[app].error); listEl.appendChild(sec); } else if (app === "obsigate") { listEl.appendChild(renderSection(APP_LABELS[app], [], "ObsiGate n'expose aucune notification entrante consultable " + "(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs).")); } else if (items.length) { listEl.appendChild(renderSection(APP_LABELS[app], items, "")); } }); if (!actives.length) { listEl.appendChild(el("p", "draco-alertes-empty", "Aucune alerte active dans les applications joignables.")); } listEl.appendChild(el("h3", "draco-alertes-group", "Historique")); APP_ORDER.forEach(function (app) { if (results[app] && results[app].error) return; /* déjà signalé ci-dessus */ if (app === "obsigate") { var link = el("div", "draco-alertes-empty"); var a = el("a", "", "Ouvrir ObsiGate"); a.href = urls.obsigate; link.appendChild(a); var sec = el("section", "draco-alertes-section"); sec.appendChild(el("h3", "", "ObsiGate (0)")); sec.appendChild(link); listEl.appendChild(sec); return; } var items = history.filter(function (x) { return x.app === app; }); listEl.appendChild(renderSection(APP_LABELS[app], items, "Aucun historique disponible pour cette application.")); }); } function fetchJson(url, timeoutMs) { var ctrl = null, timer = null; try { if (typeof AbortController !== "undefined") { ctrl = new AbortController(); timer = setTimeout(function () { ctrl.abort(); }, timeoutMs || 8000); } } catch (e) { ctrl = null; } var opts = { credentials: "include" }; if (ctrl) opts.signal = ctrl.signal; return fetch(url, opts).then(function (r) { if (timer) clearTimeout(timer); if (!r.ok) { var err = new Error("HTTP " + r.status); err.status = r.status; throw err; } return r.json(); }).catch(function (e) { if (timer) clearTimeout(timer); throw e; }); } function loadAll() { var urls = resolveUrls(browserEnv()); var jobs = { flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50") .then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }), tiktak: fetchJson(urls.tiktak + "api/notifications") .then(function (p) { return { items: normalizeTiktak(urls.tiktak, p) }; }), kanbante: fetchJson(urls.kanbante + "api/events?limit=100") .then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; }) }; var keys = Object.keys(jobs); return Promise.all(keys.map(function (k) { return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) { var msg = (e && e.status === 401) ? "Connexion requise sur " + APP_LABELS[k] + " : ouvrez sa page native pour vous y connecter, puis rouvrez ce panneau." : "Application injoignable depuis cet hôte (" + (e && e.message ? e.message : "erreur réseau") + ")."; return [k, { items: [], error: msg }]; }); })).then(function (pairs) { var results = { obsigate: { items: [] } }; pairs.forEach(function (kv) { results[kv[0]] = kv[1]; }); return { urls: urls, results: results }; }); } function setBadge(n) { try { if (global.DRACO_BAR && typeof global.DRACO_BAR.setCounts === "function") { global.DRACO_BAR.setCounts({ alertes: n }); return; } } catch (e) { /* repli ci-dessous */ } try { document.querySelectorAll('.draco-badge[data-badge="alertes"]').forEach(function (b) { b.textContent = n; b.hidden = !(n > 0); }); } catch (e) { /* rien */ } } function refreshBadge() { return loadAll().then(function (r) { var all = []; APP_ORDER.forEach(function (app) { (r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); }); }); setBadge(countActive(all)); return countActive(all); }).catch(function () { /* pastille optionnelle : la barre vit sans */ }); } function openPanel() { ensurePanel(); panel.style.display = "flex"; listEl.textContent = ""; listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…")); loadAll().then(function (r) { render(r.results, r.urls); var all = []; APP_ORDER.forEach(function (app) { (r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); }); }); setBadge(countActive(all)); }).catch(function () { render({ obsigate: { items: [] } }, resolveUrls(browserEnv())); }); } var api = { open: openPanel, close: closePanel, refreshBadge: refreshBadge, _test: { resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck, normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante, sortAlerts: sortAlerts, countActive: countActive, isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS } }; global.DRACO_ALERTES = api; if (typeof module !== "undefined" && module.exports) module.exports = api._test; /* Pastille au chargement (après les scripts defer de la barre). */ if (typeof document !== "undefined" && document.addEventListener) { document.addEventListener("DOMContentLoaded", function () { if (badgeTimer) clearTimeout(badgeTimer); badgeTimer = setTimeout(refreshBadge, 300); }); } })(typeof window !== "undefined" ? window : globalThis);