feat(ops): champ d'assignation MULTIFONCTION (À / CC / #) partagé

Fusionne l'assignation en UN autosuggest + chips à 3 rôles, réutilisé sur le
volet tâche (PlanificationPage) ET le détail ticket (IssueDetail).

- Nouveau composant partagé AssignmentField.vue : chips distinctes À (assigné /
  lead) · CC (assistant / renfort) · # (follower). Autosuggest par défaut =
  techniciens ; 1er choisi = assigné, 2e+ = assistant. Mini-liens « Assist » et
  « #follow » étendent la liste à TOUS les utilisateurs (assistant ou follower
  depuis n'importe qui). Le suivi (follower) est géré dans le composant via
  /conversations/follow (identique sur toutes les surfaces).
- TechSelect : props optionnelles searchFn (recherche async « tous les
  utilisateurs ») + noOptionLabel — amélioration du composant partagé, pas de fork.
- PlanificationPage : le champ To/Cc devient AssignmentField (assigné→jdAssignTech,
  assistant→addAssistant, désassigner→jdUnassign) ; adaptateurs jdAssignee /
  jdAssistants / jdFieldAssist.
- IssueDetail : remplace le multiselect _assign par AssignmentField (1er _assign =
  À, suivants = CC ; frappe assign_to) ; pool par défaut = utilisateurs assignables.
- Hub (conversation.js) : POST /conversations/follow accepte un courriel cible
  (abonner n'importe quel utilisateur) ; nouveau GET /conversations/followers
  ?doctype&name → liste des abonnés (pastilles #).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-08 16:49:57 -04:00
parent d15dec5e6c
commit 2f81cff892
5 changed files with 307 additions and 34 deletions

View File

@ -0,0 +1,213 @@
<template>
<!-- Champ d'assignation MULTIFONCTION (façon destinataires courriel), partagé entre le volet
« tâche » (PlanificationPage) et le détail ticket (IssueDetail). UN seul autosuggest + des
chips par rôle : À = assigné (lead) · CC = assistant (renfort) · # = follower (abonné).
Autosuggest par défaut = TECHNICIENS. 1er choisi = assigné ; 2e+ = assistant.
Mini-liens « Assist » / « #follow » étendent l'autosuggest à TOUS les utilisateurs
(ajouter un assistant OU un follower depuis n'importe qui).
L'assigné/assistant sont délégués au parent (câblage propre à chaque surface) ; le suivi
(follower) est géré ici via /conversations/follow (identique partout). -->
<div class="asg-field">
<div class="asg-chips row items-center q-gutter-xs">
<!-- À : assigné / lead -->
<q-chip v-if="assignee && (assignee.name || assignee.id)" dense removable
@remove="$emit('unassign')" color="indigo-6" text-color="white" class="text-weight-bold asg-chip">
<q-avatar color="indigo-9" text-color="white" size="20px">{{ ini(assignee.name || assignee.id) }}</q-avatar>
À&nbsp;·&nbsp;{{ assignee.name || assignee.id }}
</q-chip>
<q-chip v-else dense outline color="grey-7" text-color="grey-8" icon="person_off">Non assigné</q-chip>
<!-- CC : assistants (renfort) -->
<template v-if="canAssist">
<q-chip v-for="a in assistants" :key="'cc-' + (a.id || a.name)" dense removable
@remove="$emit('remove-assistant', a.id)" outline color="blue-grey-6" text-color="blue-grey-8" class="asg-chip">
<q-avatar color="blue-grey-6" text-color="white" size="20px">{{ ini(a.name || a.id) }}</q-avatar>
CC&nbsp;·&nbsp;{{ a.name || a.id }}
<q-tooltip class="bg-grey-9" style="font-size:11px">Assistant (renfort)</q-tooltip>
</q-chip>
<q-spinner v-if="assistLoading" size="14px" color="blue-grey-6" />
</template>
<!-- # : followers (abonnés n'importe quel utilisateur) -->
<q-chip v-for="f in followers" :key="'f-' + f" dense removable
@remove="removeFollower(f)" color="teal-1" text-color="teal-9" class="asg-chip">
<q-avatar color="teal-6" text-color="white" size="20px">{{ ini(followerLabel(f)) }}</q-avatar>
#&nbsp;·&nbsp;{{ followerLabel(f) }}
<q-tooltip class="bg-grey-9" style="font-size:11px">Follower (abonné aux mises à jour) · {{ f }}</q-tooltip>
</q-chip>
<q-spinner v-if="followBusy" size="14px" color="teal-6" />
</div>
<!-- Autosuggest unique : techs par défaut ; étendu à tous les utilisateurs en mode Assist/#follow -->
<TechSelect
:model-value="null"
:options="techOptions"
:search-fn="scope === 'users' ? userSearch : null"
:no-option-label="scope === 'users' ? 'Aucun utilisateur' : 'Aucun technicien'"
:label="fieldLabel"
style="min-width:170px;flex:1" class="q-mt-xs"
@update:model-value="onPick" />
<!-- Mini-liens : basculent l'autosuggest sur TOUS les utilisateurs pour ajouter un assistant / un follower -->
<div class="asg-links row items-center q-gutter-md q-mt-xs">
<a class="asg-link" :class="{ active: scope === 'users' && intent === 'assist' }"
v-if="canAssist" @click="setScope('assist')">
<q-icon name="group_add" size="14px" /> Assist
<q-tooltip class="bg-grey-9" style="font-size:11px">Ajouter un assistant (CC) depuis n'importe quel utilisateur</q-tooltip>
</a>
<a class="asg-link" :class="{ active: scope === 'users' && intent === 'follow' }"
@click="setScope('follow')">
<q-icon name="visibility" size="14px" /> #follow
<q-tooltip class="bg-grey-9" style="font-size:11px">Ajouter un follower (abonné aux mises à jour) n'importe quel utilisateur</q-tooltip>
</a>
<a v-if="scope === 'users'" class="asg-link asg-link-reset" @click="resetScope">
<q-icon name="close" size="13px" /> techniciens
</a>
<q-space />
<slot name="right" />
</div>
<div class="asg-hint text-caption text-grey-6 q-mt-xs">{{ hint }}</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
import { useAuthStore } from 'src/stores/auth'
import { initials as ini, shortAgent } from 'src/composables/useFormatters'
import TechSelect from 'src/components/shared/TechSelect.vue'
const props = defineProps({
doctype: { type: String, required: true }, // pour le suivi : 'Dispatch Job' | 'Issue'
docName: { type: String, default: '' },
assignee: { type: Object, default: null }, // { id, name } chip « À » (propriété du parent)
assistants: { type: Array, default: () => [] }, // [{ id, name }] chips « CC »
techOptions: { type: Array, default: () => [] }, // [{ label, value, capable }] pool par défaut
canAssist: { type: Boolean, default: true }, // rôle assistant disponible ?
assistLoading: { type: Boolean, default: false },
})
const emit = defineEmits(['assign', 'assist', 'unassign', 'remove-assistant', 'followers-changed'])
const $q = useQuasar()
const _authUser = useAuthStore().user
const me = (_authUser && _authUser !== 'authenticated') ? _authUser : ''
// Portée de l'autosuggest : 'techs' (défaut) | 'users' (Assist/#follow)
const scope = ref('techs')
const intent = ref('assist') // en mode users : 'assist' | 'follow'
function setScope (which) { intent.value = which; scope.value = 'users' }
function resetScope () { scope.value = 'techs'; intent.value = 'assist' }
const fieldLabel = computed(() => {
if (scope.value === 'users') return intent.value === 'follow' ? 'Ajouter un follower (#)…' : 'Ajouter un assistant (CC)…'
return props.assignee && (props.assignee.name || props.assignee.id)
? (props.canAssist ? 'Ajouter un assistant (CC)…' : 'Réassigner…')
: 'Assigner à… (À)'
})
const hint = computed(() => {
if (scope.value === 'users') return intent.value === 'follow'
? 'Choisir un utilisateur = labonner aux mises à jour (follower #).'
: 'Choisir un utilisateur = lajouter en renfort (assistant CC).'
if (!props.canAssist) return 'Choisir un technicien = lassigner. « Assist » / « #follow » ouvrent la liste complète.'
return props.assignee && (props.assignee.name || props.assignee.id)
? 'Ajout = assistant (CC). « #follow » pour abonner quelquun. Retirer « À » = désassigner.'
: 'Choisir un tech = lassigner (À) ; les ajouts suivants = assistants (CC).'
})
// Sélection dans l'autosuggest route selon la portée + l'intention.
function onPick (value) {
if (!value) return
const opt = findOption(value)
const label = opt ? opt.label : String(value)
if (scope.value === 'users') {
if (intent.value === 'follow') addFollower(value, label)
else emit('assist', { value, label })
resetScope()
return
}
// Mode techs : 1er choix = assigné ; sinon = assistant (si dispo).
if (props.assignee && (props.assignee.name || props.assignee.id) && props.canAssist) emit('assist', { value, label })
else emit('assign', value)
}
function findOption (value) {
if (scope.value === 'users') return userOptCache.find(o => o.value === value)
return (props.techOptions || []).find(o => o.value === value)
}
// Recherche « tous les utilisateurs » (Authentik via hub, comme InterveneDialog)
let userOptCache = []
async function userSearch (val) {
const q = String(val || '').trim()
try {
const r = await fetch(`${HUB_URL}/auth/users?search=${encodeURIComponent(q)}`)
if (!r.ok) return []
const d = await r.json()
userOptCache = (d.users || d || [])
.filter(u => u && u.email)
.map(u => ({ label: u.name || u.email, value: u.email, sub: u.email }))
return userOptCache
} catch { return [] }
}
// Followers (# abonnés). Géré ICI : identique sur toutes les surfaces via /conversations/follow.
const followers = ref([]) // courriels
const followerNames = ref({}) // email libellé connu (semé aux ajouts)
const followBusy = ref(false)
function followerLabel (email) { return followerNames.value[email] || shortAgent(email) || email }
const followHdr = () => (me ? { 'Content-Type': 'application/json', 'X-Authentik-Email': me } : { 'Content-Type': 'application/json' })
async function loadFollowers () {
followers.value = []
if (!props.docName || !props.doctype) return
try {
const r = await fetch(`${HUB_URL}/conversations/followers?doctype=${encodeURIComponent(props.doctype)}&name=${encodeURIComponent(props.docName)}`).then(x => x.json())
followers.value = (r.followers || [])
} catch { /* le hub peut ne pas être déployé (aperçu) → dégrade proprement */ }
}
async function addFollower (email, label) {
const e = String(email || '').toLowerCase()
if (!e || !props.docName || followers.value.includes(e)) return
followBusy.value = true
try {
const r = await fetch(`${HUB_URL}/conversations/follow`, { method: 'POST', headers: followHdr(), body: JSON.stringify({ doctype: props.doctype, name: props.docName, email: e, follow: true }) }).then(x => x.json())
if (r.ok) {
if (!followers.value.includes(e)) followers.value.push(e)
if (label) followerNames.value[e] = String(label).split(' · ')[0].trim()
emit('followers-changed', followers.value.slice())
$q.notify({ type: 'positive', icon: 'visibility', message: followerLabel(e) + ' suit désormais cet élément', timeout: 2000 })
} else $q.notify({ type: 'negative', message: r.error || 'Échec', timeout: 2500 })
} catch (err) { $q.notify({ type: 'negative', message: err.message, timeout: 2500 }) } finally { followBusy.value = false }
}
async function removeFollower (email) {
const e = String(email || '').toLowerCase()
if (!e || !props.docName) return
followBusy.value = true
try {
const r = await fetch(`${HUB_URL}/conversations/follow`, { method: 'POST', headers: followHdr(), body: JSON.stringify({ doctype: props.doctype, name: props.docName, email: e, follow: false }) }).then(x => x.json())
if (r.ok) {
followers.value = followers.value.filter(x => x !== e)
emit('followers-changed', followers.value.slice())
$q.notify({ type: 'info', message: 'Follower retiré', timeout: 1600 })
} else $q.notify({ type: 'negative', message: r.error || 'Échec', timeout: 2500 })
} catch (err) { $q.notify({ type: 'negative', message: err.message, timeout: 2500 }) } finally { followBusy.value = false }
}
watch(() => [props.doctype, props.docName], loadFollowers)
onMounted(loadFollowers)
defineExpose({ reloadFollowers: loadFollowers })
</script>
<style scoped>
.asg-field { min-width: 0; }
.asg-chips { flex-wrap: wrap; }
.asg-chip :deep(.q-chip__content) { white-space: nowrap; }
.asg-links { flex-wrap: wrap; }
.asg-link {
display: inline-flex; align-items: center; gap: 3px;
font-size: 0.72rem; font-weight: 600; color: #64748b; cursor: pointer; user-select: none;
}
.asg-link:hover { color: #4338ca; text-decoration: underline; }
.asg-link.active { color: #4338ca; text-decoration: underline; }
.asg-link-reset { color: #94a3b8; }
.asg-hint { line-height: 1.3; }
</style>

View File

@ -11,7 +11,7 @@
:label="label" :style="style" behavior="menu"
@filter="onFilter">
<template #no-option>
<q-item><q-item-section class="text-grey-6">Aucun technicien</q-item-section></q-item>
<q-item><q-item-section class="text-grey-6">{{ noOptionLabel }}</q-item-section></q-item>
</template>
</q-select>
</template>
@ -23,11 +23,21 @@ const props = defineProps({
options: { type: Array, default: () => [] }, // [{label, value}]
label: { type: String, default: 'Technicien' },
style: { type: String, default: 'min-width:200px' },
// Recherche ASYNCHRONE optionnelle : `async (val) => [{label, value}]`. Fournie remplace le filtrage
// local sur `options` (ex. autosuggest « tous les utilisateurs » du champ d'assignation). Absente filtre local.
searchFn: { type: Function, default: null },
noOptionLabel: { type: String, default: 'Aucun technicien' },
})
defineEmits(['update:modelValue'])
const filtered = ref(props.options)
watch(() => props.options, (v) => { filtered.value = v }, { immediate: true })
function onFilter (val, update) {
if (props.searchFn) {
// Recherche distante : on lance la promesse puis on pousse le résultat dans le menu (patron q-select remote).
Promise.resolve(props.searchFn(val)).then(opts => update(() => { filtered.value = Array.isArray(opts) ? opts : [] }))
.catch(() => update(() => { filtered.value = [] }))
return
}
update(() => {
const n = (val || '').toLowerCase()
filtered.value = !n ? props.options

View File

@ -45,6 +45,15 @@
<span v-else class="text-caption text-grey-6">Non assigné</span>
</div>
<!-- Assignation MULTIFONCTION (À = assigné · CC = assistant · # = follower) champ PARTAGÉ, réutilisé depuis PlanificationPage.
Pool par défaut = utilisateurs assignables du ticket (le ticket assigne des Users, pas des tech_id) ; « Assist » / « #follow » ouvrent tous les utilisateurs. -->
<div class="assign-block q-mb-sm">
<div class="info-block-title" style="margin-bottom:4px">Assignation</div>
<AssignmentField doctype="Issue" :doc-name="docName"
:assignee="issueAssignee" :assistants="issueAssistants" :tech-options="userOptions" :can-assist="true"
@assign="assignUser" @assist="p => assignUser(p.value)" @unassign="unassignFirst" @remove-assistant="removeAssignee" />
</div>
<div class="issue-field-grid">
<div class="if-row">
<span class="if-label">Statut</span>
@ -80,21 +89,6 @@
<span class="if-label">Soumis par</span>
<span class="if-value">{{ doc.raised_by }}</span>
</div>
<div class="if-row">
<span class="if-label">Assigné à</span>
<q-select v-model="assignees" dense borderless multiple use-chips emit-value map-options
:options="userOptions" option-value="value" option-label="label"
input-class="editable-input" style="min-width:140px;flex:1"
:loading="loadingUsers"
@update:model-value="saveAssignees">
<template #selected-item="scope">
<q-chip dense removable size="sm" color="green-1" text-color="green-8"
@remove="scope.removeAtIndex(scope.index)">
{{ scope.opt.label || scope.opt }}
</q-chip>
</template>
</q-select>
</div>
</div>
<!-- Tags SOURCE UNIQUE (Dispatch Tag compétences roster) via TagEditor partagé.
@ -306,6 +300,7 @@ import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
import TaskNode from 'src/components/shared/TaskNode.vue'
import TagEditor from 'src/components/shared/TagEditor.vue'
import AssignmentField from 'src/components/shared/AssignmentField.vue'
const props = defineProps({
doc: { type: Object, required: true },
@ -444,6 +439,31 @@ async function loadUsers () {
loadingUsers.value = false
}
// 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 }
})
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 }
}))
async function assignUser (email) {
const e = String(email || '').trim(); if (!e || assignees.value.includes(e)) return
const next = [...assignees.value, e]
assignees.value = next
await saveAssignees(next)
}
async function removeAssignee (email) {
const e = String(email || '').trim(); if (!e) return
const next = assignees.value.filter(x => x !== e)
assignees.value = next
await saveAssignees(next)
}
function unassignFirst () { const e = assignees.value[0]; if (e) return removeAssignee(e) }
async function saveAssignees (newList) {
const old = parseAssign(props.doc._assign)
const added = newList.filter(u => !old.includes(u))

View File

@ -1401,22 +1401,18 @@
<div class="col" style="min-width:0">
<div class="text-h6" style="line-height:1.25;word-break:break-word">{{ jobDetail.subject }}</div>
<div class="text-caption text-grey-7"><template v-if="jobDetail.customer">{{ jobDetail.customer }}</template><template v-if="jobDetail.lid"> · Ticket #{{ jobDetail.lid }}</template><template v-if="jobDetail.dept"> · {{ jobDetail.dept }}</template></div>
<!-- Assignation façon courriel To/Cc UN champ : À = assigné (principal) · Cc = assistants (renfort, créneau grisé). -->
<div class="q-mt-xs jd-assign">
<div class="row items-center q-gutter-xs">
<q-chip v-if="jobDetail.techName" dense removable @remove="jdUnassign" color="indigo-6" text-color="white" class="text-weight-bold jd-tech-chip"><q-avatar color="indigo-9" text-color="white" size="20px">{{ initials(jobDetail.techName) }}</q-avatar>À&nbsp;·&nbsp;{{ jobDetail.techName }}</q-chip>
<q-chip v-else dense outline color="grey-7" text-color="grey-8" icon="person_off">Non assigné</q-chip>
<template v-if="jobDetail.canTeam">
<q-chip v-for="a in jobDetail.team" :key="a.tech_id" dense removable @remove="jdRemoveAssistant(a.tech_id)" outline color="blue-grey-6" text-color="blue-grey-8"><q-avatar color="blue-grey-6" text-color="white" size="20px">{{ initials(jdAssistantName(a)) }}</q-avatar>Cc&nbsp;·&nbsp;{{ jdAssistantName(a) }}<q-tooltip class="bg-grey-9" style="font-size:11px">Assistant (renfort) son créneau est réservé et grisé (anti double-booking)</q-tooltip></q-chip>
<q-spinner v-if="jobDetail.teamLoading" size="14px" color="blue-grey-6" />
</template>
<q-space />
<!-- Assignation MULTIFONCTION UN champ : À = assigné (lead) · CC = assistant (renfort, créneau grisé) · # = follower (abonné).
Champ partagé AssignmentField (réutilisé sur le détail ticket). « Assist » / « #follow » étendent l'autosuggest à tous les utilisateurs. -->
<AssignmentField class="q-mt-xs jd-assign"
doctype="Dispatch Job" :doc-name="jobDetail.name"
:assignee="jdAssignee" :assistants="jdAssistants"
:tech-options="jdTeamOptions" :can-assist="jobDetail.canTeam" :assist-loading="jobDetail.teamLoading"
@assign="jdAssignTech" @assist="jdFieldAssist" @unassign="jdUnassign" @remove-assistant="jdRemoveAssistant">
<template #right>
<TicketStatusControl v-if="jobDetail.canTeam" doctype="Dispatch Job" :docname="jobDetail.name" :status="jobDetail.status" :scheduled-date="jobDetail.iso"
@changed="p => { if (p && p.status) jobDetail.status = p.status; reloadOccupancy(); reloadPool && reloadPool() }" />
</div>
<TechSelect v-model="jobDetail.assignTech" :options="jdTeamOptions" :label="jobDetail.techName ? (jobDetail.canTeam ? 'Ajouter un assistant (Cc)…' : 'Réassigner…') : 'Assigner à… (À)'" style="min-width:170px;flex:1" class="q-mt-xs" @update:model-value="v => v && jdAssignOrAssist(v)" />
<div v-if="jobDetail.canTeam" class="text-caption text-grey-6 q-mt-xs">{{ jobDetail.techName ? 'Ajout = assistant (Cc / renfort) : son créneau est réservé et grisé. Retirer « À » = désassigner.' : 'Choisir un tech = lassigner (À). Les ajouts suivants deviennent des assistants (Cc).' }}</div>
</div>
</template>
</AssignmentField>
</div>
<q-space /><q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
@ -1959,6 +1955,7 @@ import { messageIdentity, priorityMeta, PRIORITY_LEVELS } from 'src/composables/
import { useSla, SLA_BADGE } from 'src/composables/useSla' // SLA existant (politiques éditables) réutilisé sur les jobs (échéance = création + résolution)
import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-Email) pour l'envoi de réponses client
import TechSelect from 'src/components/shared/TechSelect.vue'
import AssignmentField from 'src/components/shared/AssignmentField.vue'
import LeaveDialog from 'src/components/planif/LeaveDialog.vue' // Congés & disponibilités (extrait décomposition #4)
import TechSyncDialog from 'src/components/planif/TechSyncDialog.vue' // Synchroniser les techniciens (extrait décomposition #4)
import ShiftTypesDialog from 'src/components/planif/ShiftTypesDialog.vue' // Types de shift (extrait décomposition #4)
@ -2485,6 +2482,22 @@ function jdAssignOrAssist (techId) {
else jdAssignTech(techId) // sinon → assigner / réassigner (vaut aussi pour les tickets legacy, sans assistant)
jobDetail.assignTech = null // vider le champ après le choix
}
// Adaptateurs pour AssignmentField (champ multifonction partagé)
// Chips lues depuis jobDetail (le parent reste propriétaire de la vérité) ; les followers sont gérés par le composant.
const jdAssignee = computed(() => (jobDetail.techName || jobDetail.techId) ? { id: jobDetail.techId, name: jobDetail.techName } : null)
const jdAssistants = computed(() => (jobDetail.team || []).map(a => ({ id: a.tech_id, name: jdAssistantName(a) })))
// Ajout d'un assistant depuis le champ : { value=id, label } un tech (mode techs) OU un utilisateur (mode « Assist »/tous). Nom robuste.
async function jdFieldAssist ({ value, label } = {}) {
const id = value; if (!id || !jobDetail.name) return
const tech = (techs.value || []).find(t => t.id === id)
const name = (tech && tech.name) || (label && String(label).split(' · ')[0].trim()) || String(id)
try {
await roster.addAssistant(jobDetail.name, { tech_id: String(id), tech_name: name, duration_h: 0, pinned: 1 })
const r = await roster.getJobTeam(jobDetail.name); jobDetail.team = r.assistants || []
await reloadOccupancy()
$q.notify({ type: 'positive', icon: 'group_add', message: name + ' ajouté en renfort', timeout: 2400 })
} catch (e) { err(e) }
}
// Retirer le chip « À » (assigné) renvoyer le job au pool (non assigné).
async function jdUnassign () {
if (!jobDetail.name) return

View File

@ -518,6 +518,14 @@ function saveFollows (all) {
writeJsonFile('/app/data/ticket_follows.json', tf)
}
function followsFor (all, email, doctype) { return (email && all[email] && all[email][doctype]) || [] }
// Qui suit ce doc ? Balaie tous les abonnés → liste des courriels qui suivent (doctype,name). Sert aux pastilles « # follower ».
function followersOf (all, doctype, name) {
const out = []
for (const [email, byType] of Object.entries(all || {})) {
if (byType && Array.isArray(byType[doctype]) && byType[doctype].includes(name)) out.push(email)
}
return out
}
// Règles d'inbox définies par l'agent (« Filtrer comme ceci ») : [{ id, field:'from'|'subject', contains, action:'mask'|'allow' }]. « allow » prime sur « mask ».
function loadInboxRules () { return readJsonFile('/app/data/inbox_rules.json', []) }
function matchInboxRule (email, subject) {
@ -1311,13 +1319,22 @@ async function handle (req, res, method, p, url) {
}
try {
const b = await parseBody(req); const dt = String(b.doctype || '').trim(); const nm = String(b.name || '').trim()
if (!email || !dt || !nm) return json(res, 400, { error: 'email + doctype + name requis' })
const arr = new Set(followsFor(all, email, dt))
// Cible = courriel explicite (abonner N'IMPORTE QUEL utilisateur — champ d'assignation « # follower ») OU, à défaut, l'identité de l'appelant.
const target = String(b.email || email || '').toLowerCase()
if (!target || !dt || !nm) return json(res, 400, { error: 'email + doctype + name requis' })
const arr = new Set(followsFor(all, target, dt))
if (b.follow === false) arr.delete(nm); else arr.add(nm)
;(all[email] || (all[email] = {}))[dt] = [...arr]; saveFollows(all)
return json(res, 200, { ok: true, doctype: dt, follows: all[email][dt], following: arr.has(nm) })
;(all[target] || (all[target] = {}))[dt] = [...arr]; saveFollows(all)
return json(res, 200, { ok: true, doctype: dt, name: nm, email: target, follows: all[target][dt], following: arr.has(nm) })
} catch (e) { return json(res, 500, { error: e.message }) }
}
// Qui suit ce doc : GET ?doctype=X&name=Y → { followers: [courriels] }. Alimente les pastilles « # » du champ d'assignation.
if (p === '/conversations/followers' && method === 'GET') {
const dt = String(url.searchParams.get('doctype') || '').trim()
const nm = String(url.searchParams.get('name') || '').trim()
if (!dt || !nm) return json(res, 400, { error: 'doctype + name requis' })
return json(res, 200, { doctype: dt, name: nm, followers: followersOf(loadFollows(), dt, nm) })
}
// Règles d'inbox (« Filtrer comme ceci ») : masquer/afficher par expéditeur ou sujet — l'agent choisit ce qui est masqué.
if (p === '/conversations/inbox-rules' && (method === 'GET' || method === 'POST')) {
if (method === 'GET') return json(res, 200, { rules: loadInboxRules() })