fix(ops): staff recon — alias-aware employee, dedupe Authentik accounts, link-not-duplicate

- resolveEmployeeForEmail + /auth/staff now resolve the Employee through the
  unified identity's aliases: louis@targo.ca finds HR-EMP-4 (under
  louispaul@targointernet.com). Fixes "no employee linked" for Louis-Paul.
- /auth/staff collapses multiple Authentik accounts with the same email into one
  row (username `joseph` + `joseph@...` → one), flags dup_accounts for cleanup.
- provision LINKS an existing Employee (match user_id → company_email → name)
  instead of creating a duplicate — fixes the Aurélie HR-EMP-107 doublon risk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-10 13:20:09 -04:00
parent b43f092da3
commit 148cb9a320
2 changed files with 44 additions and 16 deletions

View File

@ -88,6 +88,7 @@ async function tryDelete (r) {
<q-item-label class="text-weight-medium">{{ r.name }} <q-item-label class="text-weight-medium">{{ r.name }}
<q-chip v-if="!r.is_active" dense size="sm" color="grey-4" text-color="grey-8" icon="logout">inactif</q-chip> <q-chip v-if="!r.is_active" dense size="sm" color="grey-4" text-color="grey-8" icon="logout">inactif</q-chip>
<q-chip v-if="r.needs_group" dense size="sm" color="amber-2" text-color="amber-9" icon="group_off">sans groupe</q-chip> <q-chip v-if="r.needs_group" dense size="sm" color="amber-2" text-color="amber-9" icon="group_off">sans groupe</q-chip>
<q-chip v-if="r.dup_accounts" dense size="sm" color="red-2" text-color="red-9" icon="content_copy">{{ r.dup_accounts }} comptes Authentik<q-tooltip>Comptes en double pour ce courriel : {{ (r.usernames || []).join(' · ') }} à nettoyer (garder un seul)</q-tooltip></q-chip>
</q-item-label> </q-item-label>
<q-item-label caption> <q-item-label caption>
{{ r.email }} {{ r.email }}

View File

@ -96,8 +96,12 @@ async function resolveEmployeeForEmail (email) {
if (!e) return null if (!e) return null
try { try {
const erp = require('./erp') const erp = require('./erp')
let rows = await erp.list('Employee', { filters: [['user_id', '=', e]], fields: EMPLOYEE_FIELDS, limit: 1 }) // ALIAS-AWARE : le compte de session (louis@targo.ca) et l'Employee (sous louispaul@targointernet.com)
if (!rows.length) rows = await erp.list('Employee', { filters: [['company_email', '=', e]], fields: EMPLOYEE_FIELDS, limit: 1 }) // sont la même personne → on cherche l'Employee sous N'IMPORTE QUEL courriel de l'identité unifiée.
let emails = [e]
try { const id = require('./identity').resolveIdentity(e); if (id) emails = [...new Set([id.primary_email, ...(id.alias_emails || []), e].filter(Boolean))] } catch (x) {}
let rows = await erp.list('Employee', { filters: [['user_id', 'in', emails]], fields: EMPLOYEE_FIELDS, limit: 1 })
if (!rows.length) rows = await erp.list('Employee', { filters: [['company_email', 'in', emails]], fields: EMPLOYEE_FIELDS, limit: 1 })
return rows[0] || null return rows[0] || null
} catch (err) { log('resolveEmployeeForEmail ' + e + ': ' + err.message); return null } } catch (err) { log('resolveEmployeeForEmail ' + e + ': ' + err.message); return null }
} }
@ -248,18 +252,33 @@ async function handle (req, res, method, path, url) {
const sysSet = new Set(sysU.map(u => String(u.name).toLowerCase())) const sysSet = new Set(sysU.map(u => String(u.name).toLowerCase()))
const empByEmail = {}; for (const e of emps) { if (e.user_id) empByEmail[String(e.user_id).toLowerCase()] = e; if (e.company_email && !empByEmail[String(e.company_email).toLowerCase()]) empByEmail[String(e.company_email).toLowerCase()] = e } const empByEmail = {}; for (const e of emps) { if (e.user_id) empByEmail[String(e.user_id).toLowerCase()] = e; if (e.company_email && !empByEmail[String(e.company_email).toLowerCase()]) empByEmail[String(e.company_email).toLowerCase()] = e }
const techByEmp = {}; for (const t of techs) { if (t.employee) techByEmp[t.employee] = t } const techByEmp = {}; for (const t of techs) { if (t.employee) techByEmp[t.employee] = t }
const rows = akUsers.map(u => { // DÉ-DUP par courriel : plusieurs comptes Authentik pour la même personne (username `joseph` + `joseph@…`)
const email = String(u.email || '').toLowerCase() // → UNE ligne, en listant les usernames en double à nettoyer. Actif si au MOINS un compte actif.
const groups = (u.groups || []).map(pk => gmap.get(pk)).filter(n => OPS_GROUPS.includes(n)) const byEmail = new Map()
const emp = empByEmail[email] || null for (const u of akUsers) {
const tech = emp ? techByEmp[emp.name] : null const email = String(u.email || '').toLowerCase(); if (!email) continue
const g = byEmail.get(email) || { email, usernames: [], is_active: false, name: '', groupsSet: new Set() }
g.usernames.push(u.username)
g.is_active = g.is_active || !!u.is_active
if (!g.name && u.name) g.name = u.name
for (const pk of (u.groups || [])) { const n = gmap.get(pk); if (OPS_GROUPS.includes(n)) g.groupsSet.add(n) }
byEmail.set(email, g)
}
const rows = [...byEmail.values()].map(g => {
const email = g.email
const id = idlib.resolveIdentity(email) const id = idlib.resolveIdentity(email)
const hasSys = sysSet.has(email) // Employee résolu via l'identité (alias) : louis@targo.ca → Employee sous louispaul@targointernet.com.
const idEmails = id ? [id.primary_email, ...(id.alias_emails || []), email] : [email]
let emp = null; for (const e of idEmails) { if (empByEmail[String(e).toLowerCase()]) { emp = empByEmail[String(e).toLowerCase()]; break } }
const tech = (emp && techByEmp[emp.name]) || (id && id.tech_id ? { technician_id: id.tech_id } : null)
const hasSys = idEmails.some(e => sysSet.has(String(e).toLowerCase()))
const groups = [...g.groupsSet]
let status = 'ok' let status = 'ok'
if (!u.is_active || (id && id.active === false)) status = 'departed' // inactif Authentik ou identité en départ if (!g.is_active || (id && id.active === false)) status = 'departed'
else if (!hasSys) status = 'orphan' // actif Authentik mais SANS System User = non provisionné (ex. Karim Takougang) else if (!hasSys) status = 'orphan'
const needs_group = u.is_active && hasSys && !groups.length // provisionné mais sans groupe OPS (info, pas orphelin) const needs_group = g.is_active && hasSys && !groups.length
return { email, username: u.username, name: u.name || (id && id.label) || email, is_active: u.is_active, groups, has_system_user: hasSys, employee: emp ? emp.name : '', tech_id: tech ? (tech.technician_id || tech.name) : '', identity_key: id ? id.key : '', identity_active: id ? id.active !== false : null, label: id ? id.label : '', status, needs_group } const dup_accounts = g.usernames.length > 1 ? g.usernames.length : 0
return { email, username: g.usernames[0], usernames: g.usernames, dup_accounts, name: g.name || (id && id.label) || email, is_active: g.is_active, groups, has_system_user: hasSys, employee: emp ? emp.name : '', tech_id: tech ? (tech.technician_id || tech.name) : '', identity_key: id ? id.key : '', identity_active: id ? id.active !== false : null, label: id ? id.label : '', status, needs_group }
}).sort((a, b) => (a.status === 'orphan' ? 0 : 1) - (b.status === 'orphan' ? 0 : 1) || String(a.name).localeCompare(b.name)) }).sort((a, b) => (a.status === 'orphan' ? 0 : 1) - (b.status === 'orphan' ? 0 : 1) || String(a.name).localeCompare(b.name))
return json(res, 200, { staff: rows }) return json(res, 200, { staff: rows })
} }
@ -301,13 +320,21 @@ async function handle (req, res, method, path, url) {
else if (ex[0].user_type !== 'System User') { await erp.update('User', email, { user_type: 'System User' }); out.steps.system_user = 'promoted' } else if (ex[0].user_type !== 'System User') { await erp.update('User', email, { user_type: 'System User' }); out.steps.system_user = 'promoted' }
else out.steps.system_user = 'exists' else out.steps.system_user = 'exists'
} catch (e) { out.steps.system_user_err = e.message } } catch (e) { out.steps.system_user_err = e.message }
// 3. Employee (lien user_id). // 3. Employee — LIER l'existant plutôt que dupliquer (évite le doublon craint : Aurélie a déjà HR-EMP-107 non lié).
let empName = b.employee || '' let empName = b.employee || ''
if (b.create_employee !== false) { if (b.create_employee !== false) {
try { try {
const ex = await erp.list('Employee', { filters: [['user_id', '=', email]], fields: ['name'], limit: 1 }) if (empName) { // l'admin a choisi un Employee précis → le lier
if (ex.length) { empName = ex[0].name; out.steps.employee = 'exists' } const cur = await erp.list('Employee', { filters: [['name', '=', empName]], fields: ['name', 'user_id'], limit: 1 })
if (cur.length && !cur[0].user_id) { await erp.update('Employee', empName, { user_id: email, company_email: email }); out.steps.employee = 'linked' } else out.steps.employee = cur.length ? 'exists' : 'introuvable'
} else {
let ex = await erp.list('Employee', { filters: [['user_id', '=', email]], fields: ['name', 'user_id'], limit: 1 })
if (!ex.length) ex = await erp.list('Employee', { filters: [['company_email', '=', email]], fields: ['name', 'user_id'], limit: 1 })
if (!ex.length && label) ex = await erp.list('Employee', { filters: [['employee_name', '=', label]], fields: ['name', 'user_id'], limit: 2 }) // match par NOM (ex. Aurélie Bourdon = HR-EMP-107)
if (ex.length === 1) { empName = ex[0].name; if (!ex[0].user_id) { await erp.update('Employee', empName, { user_id: email, company_email: email }); out.steps.employee = 'linked' } else out.steps.employee = 'exists' }
else if (ex.length > 1) { out.steps.employee = 'ambigu(' + ex.length + ') — choisir/lier manuellement, non créé' } // ne PAS deviner
else { const r = await erp.create('Employee', { employee_name: label, company_email: email, user_id: email, status: 'Active' }); empName = (r && (r.name || (r.data && r.data.name))) || ''; out.steps.employee = 'created' } else { const r = await erp.create('Employee', { employee_name: label, company_email: email, user_id: email, status: 'Active' }); empName = (r && (r.name || (r.data && r.data.name))) || ''; out.steps.employee = 'created' }
}
} catch (e) { out.steps.employee_err = e.message } } catch (e) { out.steps.employee_err = e.message }
} }
// 4. Dispatch Technician (optionnel). // 4. Dispatch Technician (optionnel).