Commit 72e43622 authored by Kourser's avatar Kourser
Browse files

Un juriste peut être d'astreinte, et on peut le lui écrire

L'annuaire qualifiait une personne par trois champs de texte libre, et un seul
atteignait la communication : la mobilisation filtrait sur role_crise, une
valeur unique par personne. L'envoi de masse ne connaissait que deux portées —
les rôles de crise, ou tout l'annuaire. Personne ne pouvait être à la fois
juriste, sur le site de Brest et d'astreinte ce week-end : le modèle n'avait de
place que pour une de ces trois vérités.

Une colonne, pas une table à côté
  Le dossier de préparation est versionné par recopie de ligne et gelé au
  déclenchement. Portées par un tableau sur la ligne, les étiquettes sont
  recopiées avec elle : le dossier figé garde celles du jour J. Une table
  d'association aurait exigé de réinventer ce versionnement — et, mal faite,
  aurait montré au retour d'expérience les étiquettes d'aujourd'hui sur une
  crise d'il y a trois mois.

  Des noms, pas des identifiants : un dossier gelé qui renverrait à une
  étiquette supprimée depuis n'afficherait plus rien.

  Le vocabulaire, lui, n'est pas versionné — c'est un dictionnaire. Et il est
  fermé : « comm » et « communication » qui coexistent, c'est un ciblage qui
  rate quelqu'un à trois heures du matin. Ce n'est pas de l'administration,
  c'est la garantie que le filtre trouve.

Deux populations, un seul langage
  Les clients n'avaient nulle part où exister. L'annuaire est fait pour ceux
  qu'on mobilise : versionné, imprimé au dossier papier, compté dans
  l'indicateur de préparation. Y verser dix mille clients rendait ces trois
  choses absurdes — et la base légale d'un client relève du contrat, pas de
  l'organisation de crise.

  D'où une seconde population, hors dossier versionné, avec sa base légale et sa
  désinscription. Les deux partagent le vocabulaire d'étiquettes : un seul
  langage de ciblage, « et » et « sauf », deux rangées de boutons plutôt qu'un
  langage d'expression. On compose une liste sous pression, on ne rédige pas une
  requête.

Un modèle qu'on envoie, et non qu'on recopie
  Le kit portait déjà des « modèles de communication » — en pièces jointes : un
  communiqué en PDF qu'on télécharge et qu'on retape pendant que le téléphone
  sonne. Ils deviennent des messages qu'on charge, qu'on adapte et qu'on envoie.

  Gelés avec le dossier, donc relisibles à la date d'une crise ancienne. Ce qui
  part est archivé ; l'original ne bouge pas, il servira à la crise suivante.
  L'envoi refuse de partir tant qu'il reste une variable que personne n'a
  renseignée : un « {{contact}} » dans un message lu par des clients coûte plus
  cher que la minute qu'il fait gagner.

Ce que le RGPD impose ici, et qui n'est pas du confort
  Une étiquette décrit quelqu'un. L'écran de création refuse ce qui relèverait
  de l'article 9 — santé, appartenance syndicale, opinions, origine — et le
  refus s'explique au lieu de bloquer.

  Écrire à ses clients pendant une crise cyber, c'est souvent l'obligation de
  notification de l'article 34, pas une lettre d'information. Une désinscription
  écarte donc des communications de confort et ne tient pas devant une
  notification obligatoire — mais c'est une case qui se coche en conscience,
  jamais par défaut. Et dans les deux cas la personne reste affichée avec son
  motif : un destinataire retiré en silence est pire qu'un destinataire absent.

Le plafond d'envoi passe de 2 000 à 10 000. Il ne protégeait plus de la même
chose : tant qu'on n'écrivait qu'à l'annuaire, deux mille signalait une erreur
de sélection ; depuis qu'on écrit à ses clients, le nombre légitime se compte en
milliers. Le vrai garde-fou reste le décompte qu'on recopie à la main.

Éprouvé : dix-neuf assertions exécutées sur les fonctions pures — le croisement
« juriste ET astreinte » dans les deux sens, le « sauf », la résolution des
variables, et le refus de l'article 9. Vingt-quatre garanties écrites, dont
« on écrit à ses clients sans écrire aux autres ».

Non éprouvé : la migration 0024 n'a jamais été appliquée et pnpm verif n'a pas
tourné, faute de base sur la machine de travail. C'est sur la reprise des trois
anciens champs que le risque porte.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent eec76a85
Loading
Loading
Loading
Loading
Loading
+94 −10
Changes for apps/api/src/canaux/masse.ts: 94 added lines, 10 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@ import type { Sql } from "../db/client.js";
import { journaliser } from "../noyau/administration.js";
import * as annuaire from "../preparation/annuaire.js";
import * as tiers from "../preparation/tiers.js";
import * as destinataires from "../preparation/destinataires.js";
import { fournisseurActif } from "./organisation.js";

/**
@@ -21,17 +22,74 @@ import { fournisseurActif } from "./organisation.js";
 * parti et ce qui a échoué. Jamais les identifiants de la passerelle.
 */

/** Au-delà, c'est probablement une erreur de sélection plutôt qu'une intention. */
export const PLAFOND = 2000;
/**
 * Le plafond d'un envoi.
 *
 * Il ne protège plus de la même chose qu'avant. Tant qu'on n'écrivait qu'à
 * l'annuaire de crise, deux mille destinataires signalaient une erreur de
 * sélection. Depuis qu'on écrit aussi à ses clients, le nombre légitime se
 * compte en milliers — et le vrai garde-fou n'est plus le plafond, c'est le
 * décompte qu'on recopie à la main avant de partir.
 *
 * Ce qui reste borné ici, c'est ce qu'une passerelle encaisse d'un coup. Au
 * delà, c'est un envoi à découper, et le message le dit.
 */
export const PLAFOND = 10_000;

/** Envois simultanés. Assez pour ne pas traîner, assez peu pour ne pas être coupé. */
const FRONT = 5;

/**
 * Le ciblage par étiquettes : trois opérateurs, et pas un de plus.
 *
 * « Et », « ou », « sauf ». Au-delà on écrit des requêtes — ce qu'on ne
 * demande pas à quelqu'un qui gère une crise à trois heures du matin. Ces
 * trois-là suffisent à dire « les juristes de Brest, sauf les absents », et
 * c'est le genre de phrase qu'on prononce vraiment.
 */
export interface Filtre {
  /** Porte toutes ces étiquettes à la fois. */
  et?: string[] | undefined;
  /** Porte au moins une de ces étiquettes. */
  ou?: string[] | undefined;
  /** N'en porte aucune de celles-ci. */
  sauf?: string[] | undefined;
}

export function correspond(portees: string[], f: Filtre): boolean {
  const a = new Set(portees);
  if (f.et && f.et.length > 0 && !f.et.every((e) => a.has(e))) return false;
  if (f.ou && f.ou.length > 0 && !f.ou.some((e) => a.has(e))) return false;
  if (f.sauf && f.sauf.some((e) => a.has(e))) return false;
  return true;
}

const filtreVide = (f?: Filtre): boolean =>
  !f || ((f.et?.length ?? 0) === 0 && (f.ou?.length ?? 0) === 0 && (f.sauf?.length ?? 0) === 0);

export interface Selection {
  /** Toutes les fiches de l'annuaire portant un rôle de crise, ou toutes. */
  annuaire?: "tous" | "roles_de_crise" | "aucun" | undefined;
  /** Ciblage par étiquettes sur l'annuaire ; prend le pas sur « annuaire ». */
  etiquettesAnnuaire?: Filtre | undefined;
  /** Les tiers activables, par catégorie ou tous. */
  tiers?: "tous" | "aucun" | string[] | undefined;
  /**
   * La population de diffusion : clients, salariés, cercle de management.
   *
   * Séparée de l'annuaire parce qu'elle n'a ni le même usage, ni la même base
   * légale, ni le même ordre de grandeur.
   */
  diffusion?: "tous" | "aucun" | Filtre | undefined;
  /**
   * Une notification obligatoire passe outre les désinscriptions.
   *
   * L'article 34 du RGPD impose d'informer les personnes concernées d'une
   * violation de leurs données ; s'être désinscrit d'une lettre d'information
   * n'en dispense pas. C'est une case qui se coche sciemment, et l'écran
   * demande de dire laquelle des deux situations on est en train de vivre.
   */
  notificationObligatoire?: boolean | undefined;
  /** Des destinataires ajoutés à la main, hors dossier. */
  ajoutes?: string[] | undefined;
  /** Des fiches explicitement écartées de cet envoi. */
@@ -39,7 +97,7 @@ export interface Selection {
}

export interface Destinataire {
  origine: "annuaire" | "tiers" | "manuel";
  origine: "annuaire" | "tiers" | "diffusion" | "manuel";
  id: string | null;
  nom: string;
  adresse: string | null;
@@ -58,18 +116,20 @@ export async function apercu(
  sql: Sql, organisationId: string, genre: "sms" | "courriel", s: Selection,
): Promise<{ destinataires: Destinataire[]; joignables: number; injoignables: number }> {
  const exclus = new Set(s.exclus ?? []);
  const destinataires: Destinataire[] = [];
  const destinataires_: Destinataire[] = [];

  if (s.annuaire && s.annuaire !== "aucun") {
    for (const p of await annuaire.lister(sql, organisationId)) {
      if (exclus.has(p.id)) continue;
      if (s.annuaire === "roles_de_crise" && !p.role_crise) continue;
      if (!filtreVide(s.etiquettesAnnuaire)
          && !correspond(p.etiquettes ?? [], s.etiquettesAnnuaire!)) continue;
      // Le canal personnel d'abord : c'est celui qui survit à la panne du SI,
      // et c'est tout le propos du dossier de préparation.
      const adresse = genre === "sms"
        ? p.tel_perso ?? p.tel_astreinte ?? p.tel_pro ?? null
        : p.email_perso ?? p.email_pro ?? null;
      destinataires.push({
      destinataires_.push({
        origine: "annuaire", id: p.id, nom: p.nom, adresse,
        injoignable: adresse ? null
          : genre === "sms" ? "Aucun numéro renseigné." : "Aucune adresse renseignée.",
@@ -85,7 +145,7 @@ export async function apercu(
      const adresse = genre === "sms"
        ? t.numero_urgence ?? t.interlocuteur_tel ?? null
        : t.interlocuteur_email ?? null;
      destinataires.push({
      destinataires_.push({
        origine: "tiers", id: t.id, nom: t.nom, adresse,
        injoignable: adresse ? null
          : genre === "sms" ? "Aucun numéro d'urgence." : "Aucune adresse d'interlocuteur.",
@@ -93,15 +153,39 @@ export async function apercu(
    }
  }

  if (s.diffusion && s.diffusion !== "aucun") {
    const f = typeof s.diffusion === "string" ? {} : s.diffusion;
    for (const d of await destinataires.lister(sql, organisationId)) {
      if (exclus.has(d.id)) continue;
      if (!filtreVide(f) && !correspond(d.etiquettes, f)) continue;

      const adresse = genre === "sms" ? d.telephone : d.email;
      // Une désinscription écarte, elle n'efface pas — et elle ne tient pas
      // devant une notification obligatoire. Dans les deux cas la personne
      // reste visible dans l'aperçu avec son motif : un destinataire retiré en
      // silence est pire qu'un destinataire absent.
      const desinscrit = d.desinscrit_at !== null && !s.notificationObligatoire;
      destinataires_.push({
        origine: "diffusion", id: d.id,
        nom: d.organisme ? `${d.nom} (${d.organisme})` : d.nom,
        adresse,
        injoignable: desinscrit
          ? "Désinscrit des communications."
          : adresse ? null
          : genre === "sms" ? "Aucun numéro renseigné." : "Aucune adresse renseignée.",
      });
    }
  }

  for (const a of s.ajoutes ?? []) {
    const v = a.trim();
    if (v) destinataires.push({ origine: "manuel", id: null, nom: v, adresse: v, injoignable: null });
    if (v) destinataires_.push({ origine: "manuel", id: null, nom: v, adresse: v, injoignable: null });
  }

  return {
    destinataires,
    joignables: destinataires.filter((d) => d.injoignable === null).length,
    injoignables: destinataires.filter((d) => d.injoignable !== null).length,
    destinataires: destinataires_,
    joignables: destinataires_.filter((d) => d.injoignable === null).length,
    injoignables: destinataires_.filter((d) => d.injoignable !== null).length,
  };
}

+158 −0
Changes for apps/api/src/db/migrations/0024_etiquettes.sql: 158 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- Étiquettes, destinataires et modèles de message (EF-202, EF-208)
--
-- Trois manques, un seul remède : un vocabulaire commun.
--
--   1. Une personne ne portait qu'une qualification à la fois — « rôle de
--      crise » — là où un juriste peut être d'astreinte sur le site de Brest.
--      Trois vérités simultanées, un seul champ pour les dire.
--
--   2. Les clients, le cercle de management, les salariés n'avaient nulle part
--      où exister. L'annuaire est fait pour ceux qu'on mobilise : il est
--      versionné, imprimé au dossier papier et compté dans l'indicateur de
--      préparation. Y verser dix mille clients rendrait ces trois choses
--      absurdes, et la base légale d'un client n'est pas celle d'un membre de
--      cellule. D'où une seconde population, et un seul langage de ciblage.
--
--   3. Un « modèle de communication » était une pièce jointe du kit : un PDF
--      qu'on télécharge et qu'on recopie à la main pendant que le téléphone
--      sonne. Il devient ici un message qu'on charge, qu'on adapte et qu'on
--      envoie.
-- ═══════════════════════════════════════════════════════════════════════════

-- ── Le vocabulaire ─────────────────────────────────────────────────────────
--
-- Non versionné, et c'est délibéré : c'est un dictionnaire, pas un contenu.
-- Fermé, aussi — « comm » et « communication » qui coexistent, c'est un
-- ciblage qui rate quelqu'un à trois heures du matin.
create table etiquette (
  organisation_id uuid        not null references organisation(id) on delete cascade,
  nom             text        not null,
  genre           text        not null check (genre in (
                    'role', 'cellule', 'site', 'metier', 'astreinte',
                    'audience', 'autre')),
  cree_at         timestamptz not null default now(),
  primary key (organisation_id, nom)
);

-- ── La pose, sur la personne ───────────────────────────────────────────────
--
-- Une colonne de la ligne, et non une table d'association : le versionnement
-- du dossier recopie la ligne entière à chaque modification, si bien que les
-- étiquettes sont gelées avec le reste. Une crise ancienne montre les
-- étiquettes de ce jour-là, pas celles d'aujourd'hui.
--
-- Des noms, pas des identifiants : un dossier gelé qui renverrait à une
-- étiquette supprimée depuis n'afficherait plus rien.
alter table personne add column etiquettes text[] not null default '{}';
create index personne_etiquettes on personne using gin (etiquettes);

-- ── Reprise des trois anciens champs ───────────────────────────────────────
--
-- Chaque valeur distincte devient une étiquette de son genre. Un même nom
-- présent dans deux genres n'entre qu'une fois : c'est une déduplication, et
-- elle vaut mieux que deux étiquettes homonymes qu'on ne saurait plus
-- distinguer à la saisie.
insert into etiquette (organisation_id, nom, genre)
select distinct organisation_id, btrim(role_crise), 'role'
  from personne where role_crise is not null and btrim(role_crise) <> ''
on conflict do nothing;

insert into etiquette (organisation_id, nom, genre)
select distinct organisation_id, btrim(cellule), 'cellule'
  from personne where cellule is not null and btrim(cellule) <> ''
on conflict do nothing;

insert into etiquette (organisation_id, nom, genre)
select distinct organisation_id, btrim(fonction), 'metier'
  from personne where fonction is not null and btrim(fonction) <> ''
on conflict do nothing;

update personne set etiquettes = (
  select coalesce(array_agg(distinct v), '{}')
    from unnest(array[
      nullif(btrim(role_crise), ''),
      nullif(btrim(cellule), ''),
      nullif(btrim(fonction), '')
    ]) as v
   where v is not null);

-- Les anciennes colonnes restent : elles sont lues par le dossier papier et
-- par des versions déjà gelées. On ne retire pas une colonne dont d'anciennes
-- versions dépendent.

-- ── La seconde population ──────────────────────────────────────────────────
--
-- Volontairement hors du dossier versionné. Un client n'est pas quelqu'un
-- qu'on mobilise : il ne compte pas dans le score de préparation, ne s'imprime
-- pas dans le dossier papier, et sa base légale relève du contrat ou de
-- l'intérêt légitime, pas de l'organisation de crise.
create table destinataire (
  organisation_id uuid        not null references organisation(id) on delete cascade,
  id              uuid        not null default gen_random_uuid(),
  nom             text        not null,
  organisme       text,
  email           text,
  telephone       text,
  etiquettes      text[]      not null default '{}',

  -- RGPD : ce qui autorise l'envoi, et ce qui l'interdit.
  base_legale     text        not null default 'interet_legitime'
                    check (base_legale in (
                      'contrat', 'interet_legitime', 'consentement',
                      'obligation_legale')),
  -- Une désinscription ne supprime pas le contact : elle interdit les envois
  -- de confort. La notification obligatoire (RGPD art. 34) ne s'y plie pas —
  -- c'est le ciblage qui distingue, pas cette colonne.
  desinscrit_at   timestamptz,
  source          text,
  notes           text,

  cree_at         timestamptz not null default now(),
  maj_at          timestamptz not null default now(),
  supprime        boolean     not null default false,
  primary key (organisation_id, id)
);
create index destinataire_etiquettes on destinataire using gin (etiquettes);
create index destinataire_courants on destinataire (organisation_id) where not supprime;

-- ── Les modèles de message ─────────────────────────────────────────────────
--
-- Versionnés comme le reste du dossier, donc gelés au déclenchement : le
-- modèle consultable depuis une crise ancienne est celui qui était en vigueur
-- ce jour-là. À chaud on le charge, on l'adapte, on l'envoie — et c'est le
-- texte réellement parti qui entre à l'historique des envois. L'original ne
-- bouge pas : il servira à la crise suivante.
create table modele_message (
  organisation_id uuid    not null references organisation(id) on delete cascade,
  id              uuid    not null default gen_random_uuid(),
  depuis_version  integer not null,
  jusqu_a_version integer,
  supprime        boolean not null default false,

  libelle         text    not null,
  genre           text    not null check (genre in ('sms', 'courriel')),
  objet           text,
  corps           text    not null,
  -- À quoi il sert et quand s'en servir : un modèle sans mode d'emploi se
  -- choisit mal quand il en faut un tout de suite.
  usage           text,
  -- Le ciblage habituel de ce modèle, en étiquettes. Une suggestion, jamais
  -- une contrainte : c'est l'aperçu qui décide de qui reçoit.
  etiquettes      text[]  not null default '{}',

  primary key (id, depuis_version)
);

-- ── Les listes enregistrées ────────────────────────────────────────────────
--
-- Une expression de ciblage qui porte un nom. Personne ne recompose « les
-- juristes de Brest sauf les absents » sous pression.
create table liste_diffusion (
  organisation_id uuid        not null references organisation(id) on delete cascade,
  id              uuid        not null default gen_random_uuid(),
  libelle         text        not null,
  filtre          jsonb       not null,
  cree_at         timestamptz not null default now(),
  primary key (organisation_id, id)
);
+24 −2
Changes for apps/api/src/domaine/mobilisation.ts: 24 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -567,8 +567,19 @@ export async function relancerEtEscalader(
}

/** Cibles tirées du dossier de préparation, dans la version gelée par la crise. */
/**
 * Qui mobiliser, depuis l'annuaire gelé de la crise.
 *
 * Le filtre par rôle demeure — il est simple, et c'est encore ce qu'on tape le
 * plus souvent. S'y ajoute le ciblage par étiquettes, qui dit ce qu'un rôle
 * unique ne savait pas dire : « les juristes de Brest, sauf les absents ».
 *
 * Les deux se combinent : un rôle et des étiquettes filtrent ensemble.
 */
export async function ciblesDepuisAnnuaire(
  sql: Sql, criseId: string, roles?: string[],
  filtre?: { et?: string[] | undefined; ou?: string[] | undefined;
             sauf?: string[] | undefined } | undefined,
): Promise<Cible[]> {
  const [c] = await sql<{ organisation_id: string; preparation_version: number | null }[]>`
    select organisation_id, preparation_version from crise where id = ${criseId}`;
@@ -576,8 +587,9 @@ export async function ciblesDepuisAnnuaire(

  const personnes = await sql<{ id: string; nom: string; role_crise: string | null;
    email_perso: string | null; email_pro: string | null; tel_perso: string | null;
    compte_id: string | null; suppleant_id: string | null }[]>`
    select id, nom, role_crise, email_perso, email_pro, tel_perso, compte_id, suppleant_id
    compte_id: string | null; suppleant_id: string | null; etiquettes: string[] }[]>`
    select id, nom, role_crise, email_perso, email_pro, tel_perso, compte_id, suppleant_id,
           etiquettes
      from personne
     where organisation_id = ${c.organisation_id} and not supprimee
       ${c.preparation_version === null
@@ -586,9 +598,19 @@ export async function ciblesDepuisAnnuaire(
               and (jusqu_a_version is null or jusqu_a_version > ${c.preparation_version})`}
     order by nom`;

  const porte = (p: { etiquettes: string[] }): boolean => {
    if (!filtre) return true;
    const a = new Set(p.etiquettes ?? []);
    if (filtre.et && filtre.et.length > 0 && !filtre.et.every((e) => a.has(e))) return false;
    if (filtre.ou && filtre.ou.length > 0 && !filtre.ou.some((e) => a.has(e))) return false;
    if (filtre.sauf && filtre.sauf.some((e) => a.has(e))) return false;
    return true;
  };

  return personnes
    .filter((p) => !roles || roles.length === 0
      || (p.role_crise !== null && roles.includes(p.role_crise)))
    .filter(porte)
    .map((p) => ({
      personneId: p.id, compteId: p.compte_id, nom: p.nom, role: p.role_crise,
      // L'adresse personnelle d'abord : c'est celle qui survit au jour J.
+8 −0
Changes for apps/api/src/preparation/annuaire.ts: 8 added lines, 0 removed lines.
Original line number Diff line number Diff line
import type { Partiel, SaisiePersonne } from "@kastell/shared";
import type { Sql } from "../db/client.js";
import * as dossier from "./dossier.js";
import * as etiquettes from "./etiquettes.js";

/** Annuaire interne (EF-202). */

@@ -21,6 +22,9 @@ export interface Personne {
  tel_astreinte: string | null;
  notes: string | null;
  verifie_at: Date | null;
  /** Les qualifications simultanées : métier, site, astreinte, rôle, cellule.
   *  Recopiées avec la ligne à chaque version, donc gelées avec le dossier. */
  etiquettes: string[];
}

export async function lister(
@@ -32,6 +36,7 @@ export async function lister(
export async function creer(
  sql: Sql, organisationId: string, saisie: SaisiePersonne, auteurId: string | null,
): Promise<string> {
  await etiquettes.exigerConnues(sql, organisationId, saisie.etiquettes ?? []);
  return (await sql.begin(async (tx) => {
    const v = await dossier.nouvelleVersion(tx, organisationId, auteurId,
      `Ajout de ${saisie.nom} à l'annuaire`);
@@ -45,6 +50,9 @@ export async function modifier(
  sql: Sql, organisationId: string, id: string,
  changements: Partiel<SaisiePersonne>, auteurId: string | null,
): Promise<void> {
  if (changements.etiquettes) {
    await etiquettes.exigerConnues(sql, organisationId, changements.etiquettes);
  }
  await sql.begin(async (tx) => {
    const [actuelle] = await tx<{ nom: string }[]>`
      select nom from personne where id = ${id} and jusqu_a_version is null`;
+174 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading