From dec8321823b96d92f5a27d15e9fee9662d1b1eed Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 10 Jul 2026 12:22:48 -0400 Subject: [PATCH] feat(ops): unified staff identity (label + email aliases + tech link + departed flag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One person = one identity { label, primary_email (SSO login), alias_emails[], tech_id, active, kind }. Every email + tech ID resolves to it. Hub: lib/identity.js (NEW, SOURCE UNIQUE) — resolver + secured endpoints (/identity/map|resolve read; upsert/alias/merge/delete admin-only). server.js mounts /identity. auth.getDisplayNameByEmail consults the identity label first (any alias → full name everywhere). Seed: Louis-Paul Bourdon, Louis Morneau. SPA: composables/useIdentity + api/identity. IssueDetail dedupes assignment by identity (m'ajouter + search = same person → no double chip), shows full names, and hides departed people (active:false) from the picker. IdentityManager.vue (Settings → Identités tab): edit label/aliases/tech link, merge duplicates, block/reactivate (departed = non-destructive: hidden from pickers, history kept). Co-Authored-By: Claude Opus 4.8 --- apps/ops/src/api/identity.js | 17 ++ .../src/components/shared/IdentityManager.vue | 170 +++++++++++++ .../shared/detail-sections/IssueDetail.vue | 240 +++++++++++------- apps/ops/src/composables/useIdentity.js | 49 ++++ apps/ops/src/pages/SettingsPage.vue | 7 + services/targo-hub/lib/auth.js | 2 + services/targo-hub/lib/identity.js | 126 +++++++++ services/targo-hub/server.js | 1 + 8 files changed, 521 insertions(+), 91 deletions(-) create mode 100644 apps/ops/src/api/identity.js create mode 100644 apps/ops/src/components/shared/IdentityManager.vue create mode 100644 apps/ops/src/composables/useIdentity.js create mode 100644 services/targo-hub/lib/identity.js diff --git a/apps/ops/src/api/identity.js b/apps/ops/src/api/identity.js new file mode 100644 index 0000000..eaac6f0 --- /dev/null +++ b/apps/ops/src/api/identity.js @@ -0,0 +1,17 @@ +// API identité unifiée — appelle le hub /identity/* (écritures réservées admin côté hub). +import { HUB_URL as HUB } from 'src/config/hub' + +async function j (path, init) { + const r = await fetch(HUB + path, init) + const d = await r.json().catch(() => ({})) + if (!r.ok) throw new Error(d.error || ('Identity API ' + r.status)) + return d +} +const post = (path, body) => j(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }) + +export const getIdentityMap = () => j('/identity/map') +// upsert : { key?, label, primary_email, alias_emails?, tech_id?, employee? } +export const upsertIdentity = (body) => post('/identity', body) +export const setAlias = (key, email, remove = false) => post('/identity/' + encodeURIComponent(key) + '/alias', { email, remove }) +export const mergeIdentity = (into, from) => post('/identity/merge', { into, from }) +export const deleteIdentity = (key) => j('/identity/' + encodeURIComponent(key), { method: 'DELETE' }) diff --git a/apps/ops/src/components/shared/IdentityManager.vue b/apps/ops/src/components/shared/IdentityManager.vue new file mode 100644 index 0000000..73731dd --- /dev/null +++ b/apps/ops/src/components/shared/IdentityManager.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/apps/ops/src/components/shared/detail-sections/IssueDetail.vue b/apps/ops/src/components/shared/detail-sections/IssueDetail.vue index 45aa655..fbeb5c4 100644 --- a/apps/ops/src/components/shared/detail-sections/IssueDetail.vue +++ b/apps/ops/src/components/shared/detail-sections/IssueDetail.vue @@ -49,7 +49,7 @@
Assignation
@@ -232,6 +232,13 @@
{{ initials(m.who) }} {{ m.who }} + + + + {{ m.public ? 'Client' : 'Interne' }} + {{ m.public ? 'Visible par le client — cliquer pour repasser en interne' : 'Note interne (privée) — cliquer pour rendre visible au client (sans courriel)' }} + {{ formatDateTime(m.when) }}
@@ -264,39 +271,21 @@ placeholder="Écrire une réponse ou une note…" :input-style="{ fontSize: '0.85rem', minHeight: '50px' }" @keydown.ctrl.enter="sendReply" @keydown.meta.enter="sendReply" /> -
- - Ajoute une note au fil — non envoyée au client - - - Envoyer par courriel ; la réponse du destinataire reviendra dans ce ticket - + +
+ + + + {{ replyToClient ? 'Envoyer au client (visible + courriel)' : 'Note interne (privée)' }} + + {{ replyToClient ? '⚠️ Visible par le CLIENT : marqué public + courriel avec lien vers le ticket (sa réponse revient au fil)' : 'Reste PRIVÉ — non visible par le client. Cochez pour envoyer au client.' }} + +
- - - - - -
Envoyer par courriel
- -
- - - - -
Un lien vers le ticket est ajouté. La réponse du destinataire s'ajoutera automatiquement à ce ticket.
-
- - - - -
-
- { const n = (userName.value || '').trim(); return (n ? n.split(/\s+/)[0] : (authStore.user || '').split('@')[0]) || 'Agent' }) const senderFull = computed(() => ((userName.value || '').trim() || (authStore.user || '').split('@')[0] || 'Agent')) const sendingReply = ref(false) +const replyToClient = ref(false) // case à cocher : OFF = note interne (défaut sûr) · ON = public + envoi au client +const EMAIL_RE = /.+@.+\..+/ const showCreateDialog = ref(false) const showProjectWizard = ref(false) const availReasonOpen = ref(false) @@ -402,11 +394,43 @@ function initials (name) { return ((p[0]?.[0] || '') + (p[1]?.[0] || '')).toUpperCase() || '?' } -// Fusionne échanges (Communication) + notes (Comment) en UN fil chronologique +// ── Visibilité PAR MESSAGE, surchargeable au clic (Interne privé ↔ Public client) ── +// Défaut par doctype : Communication = échange CLIENT (public) · Comment = note INTERNE (privé). Mais on peut +// BASCULER un message précis : la surcharge explicite est stockée au hub (overlay, clé = docname du message) car +// les Communication mêlent notes internes ET courriels → on ne peut PAS trancher au doctype seul. « Rendre public » +// = RELABEL uniquement, AUCUN courriel envoyé ; le client voit les messages publics via le portail/lien à jeton. +const msgPublic = ref({}) // surcharges explicites { [docname]: true|false } +const togglingMsg = ref('') +async function loadMsgVisibility () { + try { + const r = await fetch(`${HUB_URL}/conversations/msg-visibility`).then(x => x.json()) + msgPublic.value = (r && r.public) || {} + } catch { /* hub indispo (aperçu) → on garde le défaut par doctype */ } +} +async function toggleMsgVisibility (m) { + if (!m || !m.name || togglingMsg.value) return + const next = !m.public + togglingMsg.value = m.name + try { + const r = await fetch(`${HUB_URL}/conversations/msg-visibility`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: m.name, public: next }), + }).then(x => x.json()) + if (r && r.ok) { + msgPublic.value = { ...msgPublic.value, [m.name]: next } + Notify.create({ type: 'positive', icon: next ? 'visibility' : 'lock', message: next ? 'Rendu PUBLIC — visible par le client (aucun courriel envoyé)' : 'Repassé en INTERNE (privé)', timeout: 2200 }) + } else Notify.create({ type: 'negative', message: (r && r.error) || 'Échec', timeout: 2500 }) + } catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 2500 }) } + finally { togglingMsg.value = '' } +} + +// Fusionne échanges (Communication) + notes (Comment) en UN fil chronologique. +// Visibilité = surcharge explicite (msgPublic) SI présente, sinon défaut par doctype (Communication=public, Comment=interne). const thread = computed(() => { const items = [] - for (const c of props.comms || []) items.push({ id: 'e-' + c.name, who: c.sender_full_name || c.sender || c.owner || 'Système', when: c.creation, content: c.content, kind: 'comm' }) - for (const c of props.comments || []) items.push({ id: 'c-' + c.name, who: c.comment_by || 'Système', when: c.creation, content: c.content, kind: 'note' }) + const ov = msgPublic.value + for (const c of props.comms || []) items.push({ id: 'e-' + c.name, name: c.name, who: c.sender_full_name || c.sender || c.owner || 'Système', when: c.creation, content: c.content, kind: 'comm', public: (c.name in ov) ? !!ov[c.name] : true }) + for (const c of props.comments || []) items.push({ id: 'c-' + c.name, name: c.name, who: c.comment_by || 'Système', when: c.creation, content: c.content, kind: 'note', public: (c.name in ov) ? !!ov[c.name] : false }) return items.sort((a, b) => String(a.when || '').localeCompare(String(b.when || ''))) }) @@ -495,20 +519,36 @@ async function loadUsers () { } catch { userOptions.value = [] } loadingUsers.value = false } +// Identité unifiée : nom complet + fusion des comptes-alias d'une même personne. +const ident = useIdentity() +// Options SANS doublon d'identité : un seul choix par personne (les alias courriel sont fusionnés), +// libellé = nom canonique (identité) sinon full_name. Recalcule quand la carte d'identité est chargée. +const unifiedUserOptions = computed(() => { + const seen = new Set(); const out = [] + for (const o of userOptions.value) { + if (ident.activeOf(o.value) === false) continue // personne partie → non assignable (identité gardée pour l'historique) + const k = ident.keyOf(o.value) + if (seen.has(k)) continue + seen.add(k) + out.push({ label: ident.labelOf(o.value) || o.label, value: o.value }) + } + return out +}) +const displayName = (email) => ident.labelOf(email) || (userOptions.value.find(u => u.value === email) || {}).label || email // ── Adaptateurs pour AssignmentField (champ multifonction partagé) ── // Le ticket assigne des Users via frappe _assign : 1er = assigné (À), suivants = assistants (CC). Followers gérés par le composant (doctype Issue). const issueAssignee = computed(() => { const e = assignees.value[0]; if (!e) return null - const o = userOptions.value.find(u => u.value === e) - return { id: e, name: (o && o.label) || e } + return { id: e, name: displayName(e) } }) -const issueAssistants = computed(() => assignees.value.slice(1).map(e => { - const o = userOptions.value.find(u => u.value === e) - return { id: e, name: (o && o.label) || e } -})) +const issueAssistants = computed(() => assignees.value.slice(1).map(e => ({ id: e, name: displayName(e) }))) async function assignUser (email) { - const e = String(email || '').trim(); if (!e || assignees.value.includes(e)) return + const e = String(email || '').trim(); if (!e) return + // Dé-dup par IDENTITÉ, pas par courriel : « m'ajouter » (louis@…) + recherche « Louis-Paul » (louispaul@…) + // = la même personne → on n'ajoute pas de doublon (ni le même compte deux fois). + const k = ident.keyOf(e) + if (assignees.value.some(x => ident.keyOf(x) === k)) { Notify.create({ type: 'info', message: displayName(e) + ' est déjà sur ce ticket', timeout: 1800 }); return } const next = [...assignees.value, e] assignees.value = next await saveAssignees(next) @@ -548,7 +588,7 @@ async function saveAssignees (newList) { } } -onMounted(() => { loadUsers(); loadTagCatalog() }) +onMounted(() => { loadUsers(); loadTagCatalog(); loadMsgVisibility() }) // ── Intervention sur site / dispatch : modules montés pour la tâche dispatch PRINCIPALE du ticket ── // Principale = une tâche avec coordonnées OU billet legacy (aspect terrain) ; sinon la 1re racine ; sinon la 1re. @@ -601,9 +641,10 @@ async function onJobUnassignLead () { emit('dispatch-updated', j.name, { assigned_tech: '' }) } catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }) } } -watch(showOnsite, v => { if (v) loadJobTechs() }) // Bascule persistée par ticket (localStorage — pas de champ ERPNext requis). Défaut = auto-détection. +// ⚠️ showOnsite DOIT être déclaré AVANT tout watch qui le référence (sinon ReferenceError TDZ au setup → composant blanc). const showOnsite = ref(false) +watch(showOnsite, v => { if (v) loadJobTechs() }) const onsiteKey = () => 'ops-ticket-onsite-' + (props.docName || '') watch([primaryJob, () => props.docName], () => { let stored = null @@ -802,73 +843,79 @@ function onJobUpdated (jobName, data) { // ── Reply ── +// Courriel du client (destinataire de « Envoyer au client ») : raised_by si c'est un courriel, sinon email_id du compte. +async function resolveClientEmail () { + const rb = props.doc?.raised_by + if (rb && EMAIL_RE.test(rb)) return rb + if (props.doc?.customer) { + try { + const rows = await listDocs('Customer', { filters: { name: props.doc.customer }, fields: ['email_id'], limit: 1 }) + const e = rows?.[0]?.email_id + if (e && EMAIL_RE.test(e)) return e + } catch { /* pas d'email → visible au portail seulement */ } + } + return '' +} + +// UNE seule action. Défaut (case décochée) = NOTE INTERNE (privée). Cochée = PUBLIC + courriel au client. +// La visibilité est écrite EXPLICITEMENT dans l'overlay (interne=false / public=true) → corrige le bug « note interne +// affichée Client » (le défaut par doctype classait toute Communication en public). async function sendReply () { if (!replyContent.value?.trim() || sendingReply.value) return + const isPublic = replyToClient.value + const text = replyContent.value.trim() sendingReply.value = true try { - await createDoc('Communication', { + const created = await createDoc('Communication', { communication_type: 'Communication', communication_medium: 'Other', sent_or_received: 'Sent', subject: props.title || props.docName, - content: replyContent.value.trim(), + content: text, sender: authStore.user || '', - sender_full_name: senderFirst.value, + sender_full_name: senderFull.value, // nom réel de l'agent (jamais le courriel brut) reference_doctype: 'Issue', reference_name: props.docName, }) + const name = created && (created.name || (created.data && created.data.name)) + // Visibilité explicite (source unique = overlay hub) → rend correctement Interne/Client dès le rechargement. + if (name) { + try { + await fetch(`${HUB_URL}/conversations/msg-visibility`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, public: isPublic }) }) + msgPublic.value = { ...msgPublic.value, [name]: isPublic } + } catch { /* best-effort */ } + } + // Public → envoi au client (lien vers le ticket ; sa réponse revient au fil si l'ingestion courriel est active). + let emailed = false + if (isPublic) { + const to = await resolveClientEmail() + if (to) { + try { + const res = await fetch(`${HUB_URL}/conversations/ticket-email`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ issue: props.docName, to, message: text, link: window.location.href, agentName: senderFull.value }), + }) + const d = await res.json(); emailed = !!(d && d.ok) + } catch { /* le message reste public/visible même si le courriel échoue */ } + } + } replyContent.value = '' + replyToClient.value = false // retour au défaut sûr (interne) après envoi emit('reply-sent', props.docName) - Notify.create({ type: 'positive', message: 'Reponse envoyee', timeout: 2000 }) + Notify.create({ + type: 'positive', + icon: isPublic ? (emailed ? 'send' : 'visibility') : 'lock', + message: isPublic + ? (emailed ? 'Envoyé au client (courriel + fil du ticket)' : 'Publié — visible par le client au portail (aucune adresse courriel au dossier)') + : 'Note interne ajoutée (privée)', + timeout: 2800, + }) } catch { - Notify.create({ type: 'negative', message: 'Erreur: reponse non envoyee', timeout: 3000 }) + Notify.create({ type: 'negative', message: 'Erreur : réponse non enregistrée', timeout: 3000 }) } finally { sendingReply.value = false } } - -// ── Envoi par courriel (aller-retour : la réponse revient au ticket) ── - -const showEmailDialog = ref(false) -const emailTo = ref('') -const emailCc = ref('') -const emailMessage = ref('') -const sendingEmail = ref(false) - -function openEmailDialog () { - emailTo.value = (props.doc?.raised_by && /.+@.+\..+/.test(props.doc.raised_by)) ? props.doc.raised_by : '' - emailCc.value = '' - emailMessage.value = replyContent.value?.trim() || '' - showEmailDialog.value = true -} - -async function sendTicketEmail () { - if (!emailTo.value || sendingEmail.value) return - sendingEmail.value = true - try { - const res = await fetch(`${HUB_URL}/conversations/ticket-email`, { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - issue: props.docName, to: emailTo.value.trim(), cc: emailCc.value.trim(), - message: emailMessage.value.trim(), link: window.location.href, - agentName: senderFull.value, // sortie courriel → expéditeur = prénom+nom de l'agent (pas « louis ») - }), - }) - const d = await res.json() - if (d.ok) { - Notify.create({ type: 'positive', message: 'Courriel envoyé — la réponse reviendra à ce ticket', timeout: 2800 }) - showEmailDialog.value = false - replyContent.value = '' - emit('reply-sent', props.docName) - } else { - Notify.create({ type: 'negative', message: d.error || 'Échec de l’envoi', timeout: 3500 }) - } - } catch (e) { - Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3500 }) - } finally { - sendingEmail.value = false - } -} diff --git a/apps/ops/src/composables/useIdentity.js b/apps/ops/src/composables/useIdentity.js new file mode 100644 index 0000000..091db7c --- /dev/null +++ b/apps/ops/src/composables/useIdentity.js @@ -0,0 +1,49 @@ +// useIdentity — identité unifiée du personnel (SOURCE UNIQUE, miroir de lib/identity.js du hub). +// Charge une fois /identity/map (courriel→{key,label}, tech→{key,label}) et expose de quoi +// DÉ-DUPLIQUER (un courriel alias + le tech_id + le user SSO = la même personne = même `key`) +// et AFFICHER le nom complet (label) partout au lieu du username/courriel. +import { ref } from 'vue' +import { HUB_URL as HUB } from 'src/config/hub' + +const emails = ref({}) // 'courriel' → { key, label } +const techs = ref({}) // 'TECH-xxx' → { key, label } +const list = ref([]) // identités éditables (pour l'UI de gestion) +let _loading = null +const norm = (e) => String(e || '').trim().toLowerCase() + +async function ensure () { + if (_loading) return _loading + _loading = fetch(HUB + '/identity/map') + .then(r => r.ok ? r.json() : { emails: {}, techs: {}, list: [] }) + .then(d => { emails.value = d.emails || {}; techs.value = d.techs || {}; list.value = d.list || []; return d }) + .catch(() => ({ emails: {}, techs: {}, list: [] })) + return _loading +} +function reload () { _loading = null; return ensure() } + +// Clé d'identité pour un courriel OU un tech_id ; repli = le courriel normalisé (identité « inconnue » mais stable). +function keyOf (input) { + if (!input) return '' + const s = String(input) + return (emails.value[norm(s)] && emails.value[norm(s)].key) || (techs.value[s] && techs.value[s].key) || norm(s) +} +// Nom complet (label) pour un courriel/tech_id ; '' si l'identité n'a pas de label → l'appelant garde son repli. +function labelOf (input) { + if (!input) return '' + const s = String(input) + const hit = emails.value[norm(s)] || techs.value[s] + return (hit && hit.label) || '' +} +// Deux entrées (courriels/tech) = la même personne ? +function sameId (a, b) { return keyOf(a) === keyOf(b) } +// Actif ? départ (active:false) = masqué des sélecteurs mais l'identité reste (historique). Inconnu → actif. +function activeOf (input) { + if (!input) return true + const s = String(input); const hit = emails.value[norm(s)] || techs.value[s] + return hit ? hit.active !== false : true +} + +export function useIdentity () { + ensure() + return { ensure, reload, emails, techs, list, keyOf, labelOf, sameId, activeOf } +} diff --git a/apps/ops/src/pages/SettingsPage.vue b/apps/ops/src/pages/SettingsPage.vue index 678b2d8..73e5782 100644 --- a/apps/ops/src/pages/SettingsPage.vue +++ b/apps/ops/src/pages/SettingsPage.vue @@ -25,8 +25,14 @@ + + +
+ +
+
@@ -809,6 +815,7 @@ import { useUserGroups } from 'src/composables/useUserGroups' import { useAvatar } from 'src/composables/useAvatar' import UserAvatar from 'src/components/shared/UserAvatar.vue' import EmployeeEditDialog from 'src/components/shared/EmployeeEditDialog.vue' +import IdentityManager from 'src/components/shared/IdentityManager.vue' // gestion des identités unifiées (label + alias courriel + lien tech + fusion) import { listDocs } from 'src/api/erp' import QueueTeamsCard from 'src/components/settings/QueueTeamsCard.vue' import DepartmentSubscriptions from 'src/components/settings/DepartmentSubscriptions.vue' diff --git a/services/targo-hub/lib/auth.js b/services/targo-hub/lib/auth.js index aa2343c..fe287ca 100644 --- a/services/targo-hub/lib/auth.js +++ b/services/targo-hub/lib/auth.js @@ -66,6 +66,8 @@ const nameCache = new Map() // email → { name, ts } async function getDisplayNameByEmail (email) { const key = String(email || '').trim().toLowerCase() if (!key) return '' + // Identité unifiée d'abord : un alias (louispaul@…) résout au LABEL canonique (« Louis-Paul Bourdon »). + try { const lbl = require('./identity').labelForEmail(key); if (lbl) return lbl } catch (e) { /* store absent → Authentik */ } const hit = nameCache.get(key) if (hit && Date.now() - hit.ts < 300000) return hit.name let name = '' diff --git a/services/targo-hub/lib/identity.js b/services/targo-hub/lib/identity.js new file mode 100644 index 0000000..8a04a51 --- /dev/null +++ b/services/targo-hub/lib/identity.js @@ -0,0 +1,126 @@ +'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 } + +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 } diff --git a/services/targo-hub/server.js b/services/targo-hub/server.js index 76db21d..cd763e7 100644 --- a/services/targo-hub/server.js +++ b/services/targo-hub/server.js @@ -226,6 +226,7 @@ const server = http.createServer(async (req, res) => { if (path.startsWith('/conversations')) return conversation.handle(req, res, method, path, url) if (path === '/prefs') return require('./lib/user-prefs').handle(req, res, method, path) // préférences d'affichage par utilisateur (filtres/tri/densité) if (path === '/avatars' || path.startsWith('/avatars/')) return require('./lib/avatars').handle(req, res, method, path) // photos de profil par utilisateur + if (path.startsWith('/identity')) return require('./lib/identity').handle(req, res, method, path, url) // identité unifiée (courriels alias + label + lien tech) ; écritures admin if (path.startsWith('/sla/')) return require('./lib/sla').handle(req, res, method, path) // politiques SLA (réponse/résolution par file+priorité), éditables dans Settings if (path.startsWith('/chatwoot/')) return require('./lib/chatwoot').handle(req, res, method, path, url) // Dashboard App Chatwoot : fiche client ERP 360 (panel iframe + lookup protégé par clé) if (path.startsWith('/billing/')) return require('./lib/proration').handle(req, res, method, path) // moteur de prorata (aperçu lignes proratées par type) — gaté staff