'use strict' // ── Identité unifiée du personnel — SOURCE UNIQUE ───────────────────────────── // Problème résolu : une même personne a plusieurs COURRIELS (louis@targo.ca + // louispaul@targointernet.com) et un enregistrement Dispatch Technician séparé, // SANS identifiant unifié → doublons d'assignation, nom système (« louis ») au // lieu du nom complet, user SSO ≠ tech. Ici : 1 personne = 1 identité canonique // { key, label, primary_email, alias_emails[], tech_id, employee } // Tous les courriels (primaire + alias) et le tech_id résolvent à CETTE identité. // // SSO partout → la clé = le courriel de login (primaire). Éditable depuis OPS, // écritures réservées aux admins (mêmes groupes que avatars/user-prefs). Store // JSON hub (data/identities.json) — pas de custom-field ERPNext (API instable). const fs = require('fs') const path = require('path') const { json, parseBody, log } = require('./helpers') const FILE = path.join(__dirname, '..', 'data', 'identities.json') const norm = (e) => String(e || '').trim().toLowerCase() const meOf = (req) => norm(req.headers['x-authentik-email']) // Admin = mêmes groupes Authentik que les autres écritures self/admin (avatars.js, user-prefs.js). function isAdmin (req) { const g = String(req.headers['x-authentik-groups'] || req.headers['x-authentik-group'] || '') return /(^|[,|;])\s*(ops-?admin|admin(istrateurs?)?|superviseurs?)\s*([,|;]|$)/i.test(g) } function load () { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')) || {} } catch { return {} } } function save (m) { try { fs.mkdirSync(path.dirname(FILE), { recursive: true }) } catch (e) {} fs.writeFileSync(FILE, JSON.stringify(m, null, 1)); _index = null; return m } // Index inverse (courriel→clé, tech→clé, nom→clé), reconstruit paresseusement, invalidé à chaque save(). let _index = null function index () { if (_index) return _index const m = load(); const byEmail = {}, byTech = {}, byName = {} for (const [key, id] of Object.entries(m)) { for (const e of [id.primary_email, ...(id.alias_emails || [])].map(norm).filter(Boolean)) byEmail[e] = key if (id.tech_id) byTech[String(id.tech_id)] = key if (id.label) byName[norm(id.label)] = key } _index = { m, byEmail, byTech, byName }; return _index } // resolveIdentity(input) : input = courriel | tech_id | nom complet → { key, ...identité } | null function resolveIdentity (input) { if (!input) return null const { m, byEmail, byTech, byName } = index() const s = String(input).trim() const key = byEmail[norm(s)] || byTech[s] || byName[norm(s)] return key ? { key, ...m[key] } : null } // Libellé alias-aware (pour getDisplayNameByEmail) : '' si inconnu → l'appelant retombe sur Authentik. function labelForEmail (email) { const id = resolveIdentity(email); return id ? id.label : '' } // Courriel canonique (primaire) pour un alias — sert à DÉ-DUPLIQUER les assignations. Repli = le courriel donné. function canonicalEmail (email) { const id = resolveIdentity(email); return id ? norm(id.primary_email) : norm(email) } // Clé d'identité pour dé-dupliquer (courriel OU tech_id) — repli = le courriel normalisé (identité « inconnue » stable). function identityKey (input) { const id = resolveIdentity(input); return id ? id.key : norm(input) } // Deux entrées (courriels/tech) = la même personne ? function sameIdentity (a, b) { return identityKey(a) === identityKey(b) } // Actif ? (départ = active:false → masqué des sélecteurs/roster mais l'identité RESTE pour l'historique). // Inconnu de la carte → true (on ne bloque pas quelqu'un qu'on ne connaît pas). function isActive (input) { const id = resolveIdentity(input); return id ? id.active !== false : true } // Écriture programmatique (utilisée par le provisioning staff) — upsert par clé (= primary_email par défaut). function upsert ({ key, label, primary_email, alias_emails, tech_id, employee, active, kind } = {}) { const primary = norm(primary_email); if (!primary || !label) return null const m = load(); const k = key || primary; const cur = m[k] || {} m[k] = { label: String(label).slice(0, 120), primary_email: primary, alias_emails: [...new Set([...(cur.alias_emails || []), ...(alias_emails || [])].map(norm).filter(e => e && e !== primary))], tech_id: tech_id || cur.tech_id || '', employee: employee || cur.employee || '', active: active === undefined ? (cur.active !== false) : (active !== false), kind: kind || cur.kind || 'employee', } save(m); return m[k] } function setActive (keyOrEmail, active) { const id = resolveIdentity(keyOrEmail); if (!id) return null const m = load(); if (!m[id.key]) return null m[id.key].active = active !== false; save(m); return m[id.key] } async function handle (req, res, method, path, url) { // ── Lecture (tout membre du personnel authentifié — annuaire interne) ── // Carte compacte pour le SPA : email→{key,label}, tech→{key,label}, + liste éditable. if (path === '/identity/map' && method === 'GET') { const { m } = index(); const emails = {}, techs = {}, list = [] for (const [key, id] of Object.entries(m)) { const label = id.label || ''; const active = id.active !== false for (const e of [id.primary_email, ...(id.alias_emails || [])].map(norm).filter(Boolean)) emails[e] = { key, label, active } if (id.tech_id) techs[String(id.tech_id)] = { key, label, active } list.push({ key, label, primary_email: norm(id.primary_email), alias_emails: (id.alias_emails || []).map(norm), tech_id: id.tech_id || '', employee: id.employee || '', active, kind: id.kind || 'employee' }) } return json(res, 200, { emails, techs, list }) } if (path === '/identity/resolve' && method === 'GET') { const q = url.searchParams return json(res, 200, { identity: resolveIdentity(q.get('email') || q.get('tech') || q.get('name') || '') }) } // ── Écriture : admins seulement ── if (method !== 'GET' && !isAdmin(req)) return json(res, 403, { error: 'réservé aux administrateurs' }) // Créer / mettre à jour une identité. Body: { key?, label, primary_email, alias_emails?, tech_id?, employee? } if (path === '/identity' && method === 'POST') { const b = await parseBody(req); const primary = norm(b.primary_email) if (!primary || !b.label) return json(res, 400, { error: 'label + primary_email requis' }) const m = load(); const key = b.key || primary m[key] = { label: String(b.label).slice(0, 120), primary_email: primary, alias_emails: [...new Set((b.alias_emails || []).map(norm).filter(e => e && e !== primary))], tech_id: b.tech_id || '', employee: b.employee || '', active: b.active !== false, // départ = false → masqué des sélecteurs, identité gardée pour l'historique kind: b.kind || 'employee', // employee | subcontractor } save(m); log('[identity] upsert ' + key + ' by ' + meOf(req)) return json(res, 200, { ok: true, key, identity: m[key] }) } // Ajouter / retirer un alias courriel. Body: { email, remove? } const mAlias = path.match(/^\/identity\/([^/]+)\/alias$/) if (mAlias && method === 'POST') { const key = decodeURIComponent(mAlias[1]); const b = await parseBody(req); const e = norm(b.email); const m = load() if (!m[key]) return json(res, 404, { error: 'identité introuvable' }) const set = new Set(m[key].alias_emails || []) if (b.remove) set.delete(e); else if (e && e !== norm(m[key].primary_email)) set.add(e) m[key].alias_emails = [...set]; save(m) return json(res, 200, { ok: true, identity: m[key] }) } // Fusionner deux identités (réconcilier user système ↔ tech en double). Body: { into, from } if (path === '/identity/merge' && method === 'POST') { const b = await parseBody(req); const m = load() if (!m[b.into] || !m[b.from]) return json(res, 404, { error: 'clé(s) introuvable(s)' }) const set = new Set([...(m[b.into].alias_emails || []), norm(m[b.from].primary_email), ...(m[b.from].alias_emails || [])] .filter(e => e && e !== norm(m[b.into].primary_email))) m[b.into].alias_emails = [...set] if (!m[b.into].tech_id && m[b.from].tech_id) m[b.into].tech_id = m[b.from].tech_id if (!m[b.into].employee && m[b.from].employee) m[b.into].employee = m[b.from].employee delete m[b.from]; save(m); log('[identity] merge ' + b.from + ' → ' + b.into + ' by ' + meOf(req)) return json(res, 200, { ok: true, identity: m[b.into] }) } const mDel = path.match(/^\/identity\/([^/]+)$/) if (mDel && method === 'DELETE') { const key = decodeURIComponent(mDel[1]); const m = load(); delete m[key]; save(m); return json(res, 200, { ok: true }) } return json(res, 404, { error: 'identity endpoint not found' }) } module.exports = { handle, resolveIdentity, labelForEmail, canonicalEmail, identityKey, sameIdentity, isActive, upsert, setActive }