Commit e93626f7 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Mobiliser, c'est avoir besoin de cinq personnes et s'arrêter quand on les a

Quatre manques du comparatif avec le cahier des charges dérivé de F24, et ils
tiennent tous à la même chose : Kastell savait notifier, pas mobiliser.

── L'objectif chiffré

On relançait et on escaladait, sans jamais s'arrêter sur un succès : la notion
« j'ai besoin de cinq personnes sur douze » n'existait pas. On mobilisait
douze personnes, huit répondaient, et les quatre autres continuaient de
recevoir des rappels pendant que leurs suppléants étaient escaladés pour rien.

Ce qui compte comme atteint est l'accusé de présence, non la réponse
« j'arrive ». Les deux se ressemblent et ne se recouvrent pas : répondre depuis
l'application pose l'accusé, mais cliquer depuis le lien du SMS le pose *sans*
poser de réponse. Compter les réponses aurait rendu l'objectif inatteignable
pour qui n'a pas l'application — c'est-à-dire sur le chemin principal. Je m'en
suis aperçu avant de livrer, pas après.

Un objectif supérieur au nombre de sollicités est refusé à la source : un seuil
inatteignable ferait relancer indéfiniment, ce qu'on corrige précisément.

── Les mobilisations préparées

Lancer demandait cinq décisions à chaud : le texte, la cible, le seuil, la
relance, l'escalade. Cinq décisions prises à la minute où l'on en a le moins le
temps, et où l'on rédige le plus mal — c'est celle où l'on écrit
« mobilisation » tout seul.

Ces cinq décisions ne dépendent pas de l'heure. Le scénario « cellule cyber,
nuit » ne change pas parce qu'il est trois heures du matin ; seul le fait qu'il
faille l'appliquer change. Elles se prennent donc à froid, versionnées et
gelées au déclenchement comme le reste du dossier.

Le modèle appliqué reste modifiable avant l'envoi, et c'est délibéré : une
mobilisation préparée est une avance de rédaction, pas un automatisme.

── Les réponses personnalisées et typées

Cinq réponses étaient écrites dans le schéma. Elles couvrent bien les deux
campagnes que Kastell savait faire, et mal tout le reste : un exercice
d'évacuation veut « sorti du bâtiment », une astreinte veut « je prends ».

Ce qui compte n'est pourtant pas le libellé, c'est le type. Positive compte
pour l'objectif et arrête la recherche ; négative cesse les rappels et
sollicite le suppléant tout de suite ; neutre cesse les rappels sans rien
compter ni escalader. Le moteur ne lit que le type, et un libellé peut donc
dire n'importe quoi sans rien casser.

Les cinq réponses existantes reçoivent le type qui reproduit *exactement* leur
comportement d'avant : une migration qui améliore le comportement pendant
qu'elle change le modèle rend les deux impossibles à déboguer séparément.

Un cas a demandé une décision. « J'ai besoin d'aide » est classé neutre, non
parce que la situation le serait, mais parce qu'elle n'appelle ni décompte
d'objectif ni remplaçant : elle ouvre un secours, qui est un autre geste sur un
autre écran. La classer négative aurait fait solliciter son suppléant pour
venir en cellule de crise, ce qu'elle ne demandait précisément pas.

Le lien reçu par SMS rend désormais les choix au lieu de supposer « oui ». Sans
cela la fonction n'aurait servi qu'aux porteurs de l'application, alors que le
lien est le chemin de tous les autres. Avec le jeu intégré, un clic vaut
toujours « j'arrive » : ce geste a été conçu pour être le plus court possible.

Le texte vocal qui associe chaque touche à sa réponse est produit. Le recueil
de la touche frappée suppose un sous-système de téléphonie — collecte DTMF,
rappel du fournisseur — écarté avec la voie SIP au lot L12. Je livre le script,
pas un canal que je n'ai pas.

Les trois actions rapides de la notification système restent les intégrées :
elles sont enregistrées auprès du système d'exploitation au démarrage, et ne se
déclinent pas par campagne. Les réponses personnalisées s'affichent dans
l'écran de l'application et sur le lien.

── Les alertes multilingues

Une entreprise qui a des sites en Allemagne et en Pologne n'alerte pas en
français. Elle le fait pourtant, faute de mieux, et le retard se mesure : on
relit trois fois, on demande à un collègue, et on répond quatre minutes plus
tard — ou pas.

Cinq versions au plus. La règle qui compte est la seconde : sans langue
déclarée, ou dans une langue qu'on n'a pas rédigée, on reçoit la version par
défaut — jamais rien. Un message qu'on lit mal vaut infiniment mieux qu'un
silence.

Les rappels et les escalades suivent la langue de la personne, celle du
suppléant comprise : un rappel dans une autre langue que le premier message se
lit comme un message différent.

La colonne « message » reste le texte de la langue par défaut et les
traductions s'ajoutent à côté. Tout ce qui lit une mobilisation continue de
lire ce qu'il lisait, sans migration de données. Déplacer le texte dans une
table de traductions aurait été plus élégant et moins juste : cela rendait
possible l'état « mobilisation sans aucun texte », qui ne doit pas exister.

La langue voyage jusqu'à la passerelle, dont le gabarit demande la voix à son
fournisseur. Kastell ne synthétise pas : prétendre choisir la voix serait
prétendre connaître un catalogue qu'il ne connaît pas.

── Deux choses trouvées en chemin

L'étoile d'une vue est développée à sa création, pas à sa lecture : ajouter une
colonne à « personne » ne l'ajoutait pas à « personne_courante ». Le défaut ne
se voyait pas à la migration — il se voyait à la première escalade, quand le
suppléant est lu par la vue et que la colonne manque.

Et une régression de ma migration précédente : j'avais rétréci « excuses » au
seul « indisponible », perdant « appelez mon suppléant » — qui est
littéralement quelqu'un qui ne vient pas. Le passage au décompte par type la
répare.

Ce fichier de fournisseurs porte aussi le cas « generique_voix » qui manquait
à la fabrique : la passerelle d'appel vocal figurait au catalogue et ne pouvait
pas être construite, donc jamais enregistrée.

Trente-huit garanties neuves.

Co-Authored-By: Claude (RCA)
parent 8a833247
Loading
Loading
Loading
Loading
+18 −2
Changes for apps/api/src/canaux/fournisseurs.ts: 18 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -19,6 +19,15 @@ export interface Envoi {
  destinataire: string;
  objet?: string | undefined;
  texte: string;
  /**
   * La langue de ce texte (RG-293).
   *
   * Kastell ne synthétise pas la voix : ce sont les passerelles qui le font.
   * Il transmet donc la langue, et le gabarit de l'organisation décide de la
   * voix qu'elle demande à son fournisseur. Prétendre choisir la voix ici
   * serait prétendre connaître un catalogue qu'on ne connaît pas.
   */
  langue?: string | undefined;
}

export interface Fournisseur {
@@ -151,7 +160,7 @@ export function brevo(id: { apiKey: string },
 * une crise, attendre une version n'est pas une option.
 *
 * Les substitutions disponibles dans le gabarit : {{destinataire}}, {{texte}},
 * {{objet}}, {{expediteur}}.
 * {{objet}}, {{expediteur}}, {{langue}}.
 */
export function generique(
  id: { jeton: string },
@@ -167,7 +176,9 @@ export function generique(
        .replaceAll("{{destinataire}}", e.destinataire)
        .replaceAll("{{texte}}", JSON.stringify(e.texte).slice(1, -1))
        .replaceAll("{{objet}}", JSON.stringify(e.objet ?? "").slice(1, -1))
        .replaceAll("{{expediteur}}", r.expediteur ?? "");
        .replaceAll("{{expediteur}}", r.expediteur ?? "")
        // RG-293 : la langue voyage jusqu'à la passerelle, qui choisit la voix.
        .replaceAll("{{langue}}", e.langue ?? "fr");

      const entete = r.entete?.trim() || "authorization";
      const valeur = entete.toLowerCase() === "authorization"
@@ -306,6 +317,11 @@ export function construire(
      return generique(identifiants as never, reglages as never, "sms");
    case "generique_courriel":
      return generique(identifiants as never, reglages as never, "courriel");
    // La passerelle vocale était au catalogue — donc proposée à l'écran — mais
    // absente d'ici : l'enregistrer levait « Fournisseur inconnu ». Le bouton
    // existait, le canal n'a jamais pu être branché.
    case "generique_voix":
      return generique(identifiants as never, reglages as never, "voix");
    default:
      throw new Error(`Fournisseur inconnu : ${fournisseur}`);
  }
+12 −1
Changes for apps/api/src/canaux/masse.ts: 12 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -197,6 +197,16 @@ export interface Lancement {
  criseId?: string | null | undefined;
  /** Un essai n'écrit qu'à celui qui le lance. */
  essai?: { destinataire: string } | undefined;
  /**
   * La langue de l'envoi (RG-293).
   *
   * Elle décide de la voix de synthèse pour la diffusion vocale. Kastell ne
   * synthétise pas : il transmet la langue à la passerelle, dont le gabarit
   * demande la voix à son fournisseur. Un seul code par envoi, et c'est ce que
   * l'exigence demande — un appel vocal ne se décline pas destinataire par
   * destinataire.
   */
  langue?: string | undefined;
}

export interface Resultat {
@@ -248,7 +258,8 @@ export async function lancer(
  for (let i = 0; i < cibles.length; i += FRONT) {
    await Promise.all(cibles.slice(i, i + FRONT).map(async (d) => {
      try {
        await f.envoyer({ destinataire: d.adresse!, objet: l.objet, texte: l.message });
        await f.envoyer({ destinataire: d.adresse!, objet: l.objet, texte: l.message,
          langue: l.langue ?? "fr" });
        remis += 1;
      } catch (e) {
        echecs.push({ nom: d.nom, adresse: d.adresse,
+55 −0
Changes for apps/api/src/db/migrations/0031_objectif_mobilisation.sql: 55 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- L'objectif d'une mobilisation (EF-1103a).
--
-- Kastell relançait et escaladait, mais ne s'arrêtait jamais sur un succès :
-- il n'avait aucune notion de « j'ai besoin de cinq personnes sur douze ».
-- Concrètement, on mobilisait douze personnes, huit répondaient, et les quatre
-- autres continuaient de recevoir des relances pendant que leurs suppléants
-- étaient escaladés pour rien.
--
-- C'est la différence entre notifier et mobiliser. Notifier, c'est envoyer à
-- tout le monde et attendre. Mobiliser, c'est avoir besoin d'un nombre de
-- personnes, et s'arrêter quand on l'a.
--
-- Ce qui compte comme atteint : l'accusé de présence, pas la réponse « j'arrive ».
-- Les deux se ressemblent et ne se recouvrent pas : répondre « j'arrive » depuis
-- l'application pose l'accusé, mais cliquer « je viens » depuis le lien du SMS
-- pose l'accusé *sans* poser de réponse. Compter les réponses aurait rendu
-- l'objectif inatteignable pour qui n'a pas l'application — c'est-à-dire pour le
-- chemin principal.
alter table mobilisation
  add column objectif integer check (objectif is null or objectif > 0);

comment on column mobilisation.objectif is
  'Nombre de « j''arrive » attendus. Nul : on mobilise sans seuil d''arrêt.';

-- La vue expose l'objectif et son atteinte : l'écran de suivi ne doit pas
-- recalculer une règle d'arrêt que le serveur applique déjà.
--
-- Les deux colonnes sont ajoutées **à la fin**, et l'ordre existant est
-- conservé au caractère près : « create or replace view » ne sait qu'appendre.
-- Insérer « objectif » après « message », comme le voulait la lecture, fait
-- échouer la migration en 42P16.
create or replace view mobilisation_etat as
  select m.crise_id, m.id, m.lancee_at, m.close_at, m.message,
         count(e.*)                                                as envoyes,
         count(e.*) filter (where e.accuse_at is not null)          as accuses,
         -- Le silence, et lui seul. Quelqu'un qui a dit « je ne peux pas » a
         -- répondu : le compter parmi les sans-réponse ferait chercher une
         -- réponse qu'on a déjà.
         count(e.*) filter (where e.accuse_at is null
                              and e.repondu_at is null)             as sans_reponse,
         count(e.*) filter (where e.escalade_at is not null)        as escalades,
         count(e.*) filter (where e.vu_at is not null)              as vus,
         count(e.*) filter (where e.repondu_at is not null)         as repondus,
         count(e.*) filter (where e.reponse = 'j_arrive')           as attendus,
         count(e.*) filter (where e.reponse = 'indisponible')       as excuses,
         m.objectif                                                 as objectif,
         -- Atteint : on a le nombre demandé de présents. Sans objectif, jamais
         -- atteint — ce qui est juste : une mobilisation sans seuil n'a pas de
         -- succès à constater, elle a des réponses à lire.
         (m.objectif is not null
            and count(e.*) filter (where e.accuse_at is not null) >= m.objectif)
                                                                    as atteint
    from mobilisation m
    left join mobilisation_envoi e on e.crise_id = m.crise_id and e.mobilisation_id = m.id
   group by m.crise_id, m.id, m.lancee_at, m.close_at, m.message, m.objectif;
+49 −0
Changes for apps/api/src/db/migrations/0032_mobilisation_preparee.sql: 49 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- Mobilisations préparées (§6.11, EF-1103b).
--
-- Lancer une mobilisation demandait, à chaud, d'écrire le message, de choisir
-- les rôles, de fixer l'objectif, la relance et l'escalade. Cinq décisions à
-- prendre au moment où l'on en a le moins le temps, et où l'on rédige le plus
-- mal : c'est précisément la minute où l'on écrit « mobilisation » tout seul.
--
-- Ces cinq décisions se prennent à froid. Le scénario « cellule cyber, nuit »
-- ne change pas parce qu'il est trois heures du matin — seul le fait qu'il
-- faille l'appliquer change. La préparation porte donc le texte, la cible,
-- l'objectif et les délais ; la crise ne porte plus que le geste.
--
-- Versionné comme le reste du dossier, et gelé au déclenchement : une crise
-- relue doit montrer le modèle qui était en vigueur ce jour-là, pas celui
-- qu'on a corrigé depuis grâce à elle.
--
-- Ce que la table ne fait pas : envoyer. Un modèle appliqué reste modifiable
-- avant l'envoi, et c'est délibéré. Une mobilisation préparée est une avance
-- de rédaction, pas un automatisme — la crise qu'on traverse n'est jamais
-- exactement celle qu'on avait prévue.
create table mobilisation_type (
  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,
  supprimee        boolean not null default false,

  nom              text    not null,
  message          text    not null,
  -- Les rôles de crise visés dans l'annuaire. Vide : tout l'annuaire gelé.
  roles            jsonb   not null default '[]'::jsonb,
  -- Nul : on mobilise sans seuil d'arrêt, comme un recensement.
  objectif         integer check (objectif is null or objectif > 0),
  relance_minutes  integer not null default 10
                     check (relance_minutes between 1 and 240),
  escalade_minutes integer not null default 25
                     check (escalade_minutes between 2 and 480),
  notes            text,
  verifie_at       timestamptz,
  primary key (id, depuis_version)
);
create index mobilisation_type_courante_idx on mobilisation_type (organisation_id)
  where jusqu_a_version is null;

create view mobilisation_type_courant as
  select * from mobilisation_type where jusqu_a_version is null and not supprimee;

comment on table mobilisation_type is
  'Mobilisations préparées à froid, gelées au déclenchement, modifiables avant envoi.';
+124 −0
Changes for apps/api/src/db/migrations/0033_reponses_typees.sql: 124 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- Réponses personnalisées et typées (RG-318).
--
-- Une mobilisation ne connaissait que cinq réponses, écrites dans le schéma :
-- j'arrive, indisponible, suppléant, en sécurité, besoin d'aide. Elles couvrent
-- bien les deux campagnes que Kastell savait faire, et mal tout le reste — un
-- exercice d'évacuation veut « sorti du bâtiment », une astreinte veut « je
-- prends », une cellule veut « je me connecte à distance ». Écrire ces réponses
-- dans le schéma reviendrait à demander une migration à chaque scénario.
--
-- Ce qui compte n'est pourtant pas le libellé : c'est le **type**. Trois types,
-- et chacun décide d'une conséquence différente :
--
--   positive   la personne est acquise. Elle compte pour l'objectif, on cesse
--              de la relancer, et on ne cherche personne d'autre.
--   negative   elle a répondu, et elle ne vient pas. On cesse de la relancer —
--              la relancer serait du bruit — mais on cherche immédiatement son
--              suppléant : ne pas le faire serait une faute.
--   neutre     elle a répondu sans se prononcer. On cesse de la relancer, on ne
--              compte rien, et on n'escalade pas : c'est le cas où attendre est
--              la bonne conduite.
--
-- Le type est donc la seule chose que le moteur lit. Le libellé n'est lu que
-- par des humains, et il peut dire n'importe quoi sans rien casser.
--
-- ── Ce que cette migration ne change pas ───────────────────────────────────
--
-- Les cinq réponses existantes reçoivent le type qui reproduit exactement leur
-- comportement d'aujourd'hui. C'est délibéré : une migration qui améliore le
-- comportement pendant qu'elle change le modèle rend les deux impossibles à
-- déboguer séparément.
--
-- « besoin d'aide » reçoit le type *neutre*, et c'est le seul choix qui demande
-- une explication. Dire qu'on a besoin d'aide n'est évidemment pas neutre pour
-- la personne — mais pour le moteur de mobilisation, cela n'appelle ni décompte
-- d'objectif ni recherche de remplaçant : cela ouvre un secours, qui est un
-- autre geste, sur un autre écran. La classer « négative » aurait fait
-- solliciter son suppléant pour venir en cellule de crise, ce qui est
-- précisément ce qu'elle ne demandait pas.

-- ── Le type, porté par l'envoi ─────────────────────────────────────────────
--
-- Dénormalisé plutôt que rejoint : la vue d'état compte par type à chaque
-- rafraîchissement du cockpit, et un type figé au moment de la réponse est
-- aussi ce qui rend le rapport d'après-crise stable.
alter table mobilisation_envoi
  add column reponse_genre text
    check (reponse_genre in ('positive', 'negative', 'neutre'));

comment on column mobilisation_envoi.reponse_genre is
  'Le type de la réponse reçue : c''est lui, et non son libellé, que le moteur lit.';

-- Les réponses déjà reçues sont typées selon la table ci-dessus.
update mobilisation_envoi set reponse_genre = case reponse
    when 'j_arrive'    then 'positive'
    when 'en_securite' then 'positive'
    when 'indisponible' then 'negative'
    when 'suppleant'    then 'negative'
    when 'besoin_aide'  then 'neutre'
  end
 where reponse is not null;

-- ── La réponse n'est plus une énumération fermée ───────────────────────────
--
-- La contrainte devient une contrainte de *forme*, pas de contenu : ce que la
-- réponse a le droit de valoir est décidé par la campagne, qui gèle son jeu de
-- réponses au lancement, et vérifié dans le domaine. Une contrainte de schéma
-- ne peut pas consulter un jeu propre à chaque ligne ; prétendre le contraire
-- aurait conduit à une énumération de plus, plus longue.
--
-- La forme reste étroite : minuscules, chiffres et soulignés, trente
-- caractères. Assez pour une clé, trop peu pour y glisser une phrase.
alter table mobilisation_envoi drop constraint mobilisation_envoi_reponse_check;
alter table mobilisation_envoi add constraint mobilisation_envoi_reponse_check
  check (reponse is null or reponse ~ '^[a-z][a-z0-9_]{0,29}$');

-- Une réponse et son type vont ensemble, toujours : un envoi qui porterait
-- l'une sans l'autre serait invisible dans un décompte et présent dans
-- l'autre — le genre d'écart qu'on ne découvre qu'en relisant une crise.
alter table mobilisation_envoi add constraint reponse_et_type_ensemble
  check ((reponse is null) = (reponse_genre is null));

-- ── Le jeu de réponses, gelé par la campagne ───────────────────────────────
--
-- Nul vaut « le jeu intégré », qui dépend du genre de la campagne. Gelé sur la
-- mobilisation et non lu à chaque affichage : un jeu modifié après l'envoi
-- rendrait menteurs les messages déjà partis, et illisible le rapport.
alter table mobilisation add column reponses jsonb;

comment on column mobilisation.reponses is
  'Le jeu de réponses gelé au lancement. Nul : le jeu intégré du genre de campagne.';

-- Préparé à froid, comme les cinq autres décisions d'une mobilisation.
alter table mobilisation_type add column reponses jsonb not null default '[]'::jsonb;

-- ── Ce que le cockpit compte ───────────────────────────────────────────────
--
-- Les décomptes passent du libellé au type. Pour les cinq réponses intégrées le
-- résultat est identique, à une exception près, et c'est une correction : la
-- migration 0031 avait rétréci « excuses » au seul « indisponible », perdant
-- « appelez mon suppléant » — qui est pourtant, littéralement, quelqu'un qui ne
-- vient pas.
--
-- « atteint » n'est pas touché : il compte les accusés de présence, et le
-- domaine ne pose l'accusé que sur une réponse positive.
create or replace view mobilisation_etat as
  select m.crise_id, m.id, m.lancee_at, m.close_at, m.message,
         count(e.*)                                                as envoyes,
         count(e.*) filter (where e.accuse_at is not null)          as accuses,
         count(e.*) filter (where e.accuse_at is null
                              and e.repondu_at is null)             as sans_reponse,
         count(e.*) filter (where e.escalade_at is not null)        as escalades,
         count(e.*) filter (where e.vu_at is not null)              as vus,
         count(e.*) filter (where e.repondu_at is not null)         as repondus,
         count(e.*) filter (where e.reponse_genre = 'positive')     as attendus,
         count(e.*) filter (where e.reponse_genre = 'negative')     as excuses,
         m.objectif                                                 as objectif,
         (m.objectif is not null
            and count(e.*) filter (where e.accuse_at is not null) >= m.objectif)
                                                                    as atteint,
         count(e.*) filter (where e.reponse_genre = 'neutre')       as neutres,
         m.reponses                                                  as reponses
    from mobilisation m
    left join mobilisation_envoi e on e.crise_id = m.crise_id and e.mobilisation_id = m.id
   group by m.crise_id, m.id, m.lancee_at, m.close_at, m.message, m.objectif, m.reponses;
Loading