Commit bf1e72a4 authored by Kourser's avatar Kourser
Browse files

« Êtes-vous en sécurité ? », posé à un effectif

C'est la première question d'une crise industrielle, sanitaire ou d'un attentat,
et la seule à laquelle une main courante ne répond pas. Le produit savait
mobiliser une cellule ; il ne savait pas recenser des gens.

Bâti sur la mobilisation, pas à côté
  Mêmes canaux, mêmes relances, même lien à usage unique, même journal. Trois
  choses seulement changent, et ce sont les trois qui comptent.

  Le vocabulaire des réponses. « J'arrive » n'a de sens que pour une
  mobilisation, « en sécurité » que pour un recensement. Une seule colonne, deux
  vocabulaires, et le genre dit lequel proposer — offrir les cinq réponses à
  tout le monde ferait répondre « j'arrive » à quelqu'un qui voulait dire qu'il
  allait bien.

  Les mots de l'envoi. Un courriel de mobilisation annonce que le suppléant sera
  sollicité ; un recensement dit qu'on peut demander de l'aide par le même lien
  et que quelqu'un le verra tout de suite.

  La population visée. Un recensement s'adresse aux salariés, aux visiteurs, aux
  sous-traitants — pas à l'annuaire de crise. Un recensement qui ne joindrait
  que la cellule ne recenserait rien.

Deux décisions, et leurs raisons
  Les désinscrits sont recensés. Se désinscrire d'une lettre d'information n'est
  pas renoncer à ce qu'on demande si l'on va bien, et ce serait le pire moment
  pour respecter la lettre plutôt que l'intention.

  La réponse au lien passe en POST. Un aperçu de lien dans une messagerie
  déclencherait « je suis en sécurité » à la place de la personne — c'est
  précisément la réponse qu'on n'a pas le droit de deviner.

  « Besoin d'aide » est enregistré même après un « en sécurité » : une situation
  se dégrade, et refuser le second message parce qu'il en existe un premier
  serait le défaut le plus grave que cet écran puisse avoir.

L'écran affiche les demandes d'aide avant les nombres, et le nombre de sans
réponse avant celui des gens en sécurité : c'est l'ordre dans lequel on agit. La
couverture est agrégée par étiquette — « ce groupe est-il debout » plutôt que
« qui a répondu ».

Non éprouvé : la migration 0026 n'a jamais été appliquée, et pnpm verif n'a pas
tourné. Aucune garantie n'accompagne ce lot.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 65ed3a1e
Loading
Loading
Loading
Loading
+44 −0
Changes for apps/api/src/db/migrations/0026_recensement.sql: 44 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- Le recensement de sécurité (EF-1103 étendu)
--
-- « Êtes-vous en sécurité ? », posé à tout un effectif, avec le tableau de qui
-- a répondu quoi. C'est la question qui se pose en premier dans une crise
-- industrielle, sanitaire ou d'attentat — et la seule à laquelle une main
-- courante ne répond pas.
--
-- Bâti sur la mobilisation plutôt qu'à côté : mêmes canaux, mêmes relances,
-- même lien de réponse à usage unique, même journal. Ce qui change tient en
-- trois choses — le genre, le vocabulaire des réponses, et le fait qu'on
-- s'adresse aussi à la population de diffusion, pas seulement à l'annuaire de
-- crise. Un recensement qui ne joindrait que la cellule ne recenserait rien.
-- ═══════════════════════════════════════════════════════════════════════════

alter table mobilisation add column genre text not null default 'mobilisation'
  check (genre in ('mobilisation', 'recensement'));

-- On recense des salariés, des clients, des visiteurs — pas seulement les
-- fiches de l'annuaire de crise.
alter table mobilisation_envoi add column destinataire_id uuid;

-- ── Le vocabulaire des réponses s'élargit ──────────────────────────────────
--
-- « en sécurité » et « besoin d'aide » n'ont de sens que pour un recensement,
-- comme « j'arrive » n'en a que pour une mobilisation. Une seule colonne, deux
-- vocabulaires : la contrainte les accepte tous, le genre dit lequel lire.
do $$
declare nom text;
begin
  select conname into nom from pg_constraint
   where conrelid = 'mobilisation_envoi'::regclass and contype = 'c'
     and pg_get_constraintdef(oid) like '%j_arrive%';
  if nom is not null then
    execute format('alter table mobilisation_envoi drop constraint %I', nom);
  end if;
end $$;

alter table mobilisation_envoi add constraint mobilisation_envoi_reponse_check
  check (reponse in ('j_arrive', 'indisponible', 'suppleant',
                     'en_securite', 'besoin_aide'));

create index mobilisation_recensements on mobilisation (crise_id)
  where genre = 'recensement';
+200 −5
Changes for apps/api/src/domaine/mobilisation.ts: 200 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -39,6 +39,8 @@ export interface Cible {
  telephone?: string | null | undefined;
  /** Identité de l'annuaire vers qui escalader si personne ne répond. */
  suppleantId?: string | null | undefined;
  /** Une fiche de la population de diffusion : salarié, client, visiteur. */
  destinataireId?: string | null | undefined;
}

export interface Lancement {
@@ -47,6 +49,15 @@ export interface Lancement {
  cibles: Cible[];
  relanceMinutes?: number;
  escaladeMinutes?: number;
  /**
   * Ce qu'on demande.
   *
   * Une mobilisation demande « pouvez-vous venir ? » ; un recensement demande
   * « êtes-vous en sécurité ? ». Ce ne sont pas les mêmes mots, pas les mêmes
   * réponses, et surtout pas la même urgence — envoyer l'un en croyant envoyer
   * l'autre serait la faute la plus coûteuse de cet écran.
   */
  genre?: "mobilisation" | "recensement";
}

export interface Resultat {
@@ -63,6 +74,7 @@ async function expedier(
  // appelée une fois par destinataire, et la crise ne change pas de nature
  // entre deux envois.
  exercice = false,
  genre: "mobilisation" | "recensement" = "mobilisation",
): Promise<{ canaux: Canal[]; echecs: { canal: string; motif: string }[]; jeton: string }> {
  const jeton = engendrer();
  const lien = `${urlPublique}/mobilisation?jeton=${jeton}`;
@@ -73,9 +85,15 @@ async function expedier(
    try {
      await envoyerCourriel({
        destinataire: cible.email,
        sujet: "Mobilisation de crise — réponse attendue",
        texte:
          `${cible.nom},\n\n${message}\n\n`
        sujet: genre === "recensement"
          ? "Êtes-vous en sécurité ? — réponse attendue"
          : "Mobilisation de crise — réponse attendue",
        texte: genre === "recensement"
          ? `${cible.nom},\n\n${message}\n\n`
            + `Dites-nous si vous êtes en sécurité :\n${lien}\n\n`
            + `Un seul clic suffit. Si vous avez besoin d'aide, dites-le par ce même\n`
            + `lien : quelqu'un le verra tout de suite.`
          : `${cible.nom},\n\n${message}\n\n`
            + `Confirmez que vous êtes joignable :\n${lien}\n\n`
            + `Un seul clic suffit. Sans réponse de votre part, votre suppléant sera\n`
            + `sollicité — ce n'est pas un reproche, c'est la procédure.`,
@@ -141,6 +159,7 @@ export async function lancer(
    charge: {
      mobilisation_id: mobilisationId, message: l.message,
      destinataires: l.cibles.length, canaux: [],
      genre: l.genre ?? "mobilisation",
    },
    ctx,
  }));
@@ -157,7 +176,7 @@ export async function lancer(
  for (const cible of l.cibles) {
    const envoiId = randomUUID();
    const expedition = await expedier(sql, l.criseId, mobilisationId, envoiId, cible,
      l.message, urlPublique, exercice);
      l.message, urlPublique, exercice, l.genre ?? "mobilisation");
    for (const c of expedition.canaux) canauxGlobaux.add(c);
    for (const e of expedition.echecs) echecs.push({ nom: cible.nom, ...e });

@@ -173,6 +192,7 @@ export async function lancer(
      await tx`
        update mobilisation_envoi
           set personne_id = ${cible.personneId ?? null}, tiers_id = ${cible.tiersId ?? null},
               destinataire_id = ${cible.destinataireId ?? null},
               compte_id = ${cible.compteId ?? null}, email = ${cible.email ?? null},
               telephone = ${cible.telephone ?? null},
               escalade_vers = ${cible.suppleantId ?? null},
@@ -185,6 +205,50 @@ export async function lancer(
}

/** Confirmer, c'est un clic depuis un lien : le destinataire n'a pas de compte. */
/**
 * Répondre à un recensement depuis le lien, sans compte ni appareil enrôlé.
 *
 * C'est le chemin qui compte : on recense des gens qui n'ont pas l'application
 * et souvent pas de compte. Un clic depuis un SMS, et rien d'autre.
 *
 * « Besoin d'aide » est enregistré même si la personne avait déjà répondu
 * « en sécurité » : une situation se dégrade, et refuser le second message
 * parce qu'il en existe un premier serait le défaut le plus grave que cet
 * écran puisse avoir.
 */
export async function repondreParLien(
  sql: Sql, jeton: string, reponse: "en_securite" | "besoin_aide",
): Promise<{ nom: string; reponse: string; dejaRepondu: boolean } | null> {
  const [e] = await sql<{ crise_id: string; id: string; mobilisation_id: string;
    nom: string; envoye_at: Date; reponse: string | null; genre: string }[]>`
    select e.crise_id, e.id, e.mobilisation_id, e.nom, e.envoye_at, e.reponse, m.genre
      from mobilisation_envoi e
      join mobilisation m on m.crise_id = e.crise_id and m.id = e.mobilisation_id
     where e.empreinte_jeton = ${empreinteJeton(jeton)}`;
  if (!e) return null;
  if (e.reponse === reponse) return { nom: e.nom, reponse, dejaRepondu: true };

  const delai = Math.max(0, Math.round((Date.now() - e.envoye_at.getTime()) / 1000));
  await sql.begin(async (tx) => {
    await enregistrer(tx, {
      criseId: e.crise_id, type: "mobilisation.repondue",
      charge: {
        mobilisation_id: e.mobilisation_id, envoi_id: e.id, nom: e.nom,
        reponse, canal: "lien", delai_secondes: delai, notifie_at: null,
      },
      ctx: { acteurId: null, origine: "externe" },
    });
    await tx`
      update mobilisation_envoi set reponse = ${reponse}, repondu_at = now()
       where crise_id = ${e.crise_id} and id = ${e.id}`;
  });
  await publier({
    criseId: e.crise_id, seq: null, genre: "mobilisation.repondue",
    charge: { mobilisation_id: e.mobilisation_id, envoi_id: e.id, nom: e.nom, reponse },
  });
  return { nom: e.nom, reponse, dejaRepondu: false };
}

export async function accuser(
  sql: Sql, jeton: string, canal = "lien",
): Promise<{ nom: string; delaiSecondes: number } | null> {
@@ -220,7 +284,16 @@ export async function accuser(
// Répondre depuis un téléphone (EM-11, EM-14)
// ═══════════════════════════════════════════════════════════════════════════

export type Reponse = "j_arrive" | "indisponible" | "suppleant";
/**
 * Deux vocabulaires, une colonne.
 *
 * « J'arrive » n'a de sens que pour une mobilisation, « en sécurité » que pour
 * un recensement. C'est le genre de la sollicitation qui dit lequel proposer —
 * offrir les cinq réponses à tout le monde ferait répondre « j'arrive » à
 * quelqu'un qui voulait dire qu'il allait bien.
 */
export type Reponse = "j_arrive" | "indisponible" | "suppleant"
  | "en_securite" | "besoin_aide";

export interface Sollicitation {
  criseId: string;
@@ -576,6 +649,128 @@ export async function relancerEtEscalader(
 *
 * Les deux se combinent : un rôle et des étiquettes filtrent ensemble.
 */
/**
 * Les cibles d'un recensement, prises dans la population de diffusion.
 *
 * Un recensement de sécurité ne s'adresse pas à la cellule de crise : il
 * s'adresse aux gens. Salariés, visiteurs, sous-traitants — ceux qui sont sur
 * un site quand quelque chose arrive, et dont on n'a aucune raison d'avoir la
 * fiche dans un annuaire de crise.
 *
 * Une personne désinscrite est tout de même recensée. Se désinscrire d'une
 * lettre d'information n'est pas renoncer à ce qu'on demande si l'on va bien —
 * et ce serait le pire moment pour respecter la lettre plutôt que l'intention.
 */
export async function ciblesDepuisDiffusion(
  sql: Sql, criseId: string,
  filtre?: { et?: string[] | undefined; ou?: string[] | undefined;
             sauf?: string[] | undefined } | undefined,
): Promise<Cible[]> {
  const [c] = await sql<{ organisation_id: string }[]>`
    select organisation_id from crise where id = ${criseId}`;
  if (!c) return [];

  const lignes = await sql<{ id: string; nom: string; organisme: string | null;
    email: string | null; telephone: string | null; etiquettes: string[] }[]>`
    select id, nom, organisme, email, telephone, etiquettes
      from destinataire
     where organisation_id = ${c.organisation_id} and not supprime
     order by nom
     limit 10000`;

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

  return lignes.filter((l) => porte(l.etiquettes))
    .filter((l) => l.email !== null || l.telephone !== null)
    .map((l) => ({
      destinataireId: l.id,
      nom: l.organisme ? `${l.nom} (${l.organisme})` : l.nom,
      email: l.email, telephone: l.telephone,
    }));
}

export interface EtatRecensement {
  mobilisation_id: string;
  message: string;
  lancee_at: Date;
  close_at: Date | null;
  interroges: number;
  en_securite: number;
  besoin_aide: number;
  sans_reponse: number;
  /** Ceux qui ont demandé de l'aide, nommément : c'est la seule liste qui
   *  compte dans les dix minutes qui suivent. */
  a_secourir: { nom: string; repondu_at: Date | null }[];
}

/**
 * Où en est le recensement.
 *
 * Trois nombres, et une liste. Les nombres disent l'avancement ; la liste dit
 * quoi faire. On affiche « besoin d'aide » avant « en sécurité » parce que
 * c'est dans cet ordre qu'on agit — un tableau de bord qui commence par les
 * bonnes nouvelles fait chercher les mauvaises.
 */
export async function etatRecensement(
  sql: Sql, criseId: string, mobilisationId: string,
): Promise<EtatRecensement | null> {
  const [m] = await sql<{ id: string; message: string; lancee_at: Date;
    close_at: Date | null }[]>`
    select id, message, lancee_at, close_at from mobilisation
     where crise_id = ${criseId} and id = ${mobilisationId} and genre = 'recensement'`;
  if (!m) return null;

  const [c] = await sql<{ interroges: string; en_securite: string; besoin_aide: string;
    sans_reponse: string }[]>`
    select count(*) as interroges,
           count(*) filter (where reponse = 'en_securite') as en_securite,
           count(*) filter (where reponse = 'besoin_aide') as besoin_aide,
           count(*) filter (where repondu_at is null) as sans_reponse
      from mobilisation_envoi
     where crise_id = ${criseId} and mobilisation_id = ${mobilisationId}`;

  const aSecourir = await sql<{ nom: string; repondu_at: Date | null }[]>`
    select nom, repondu_at from mobilisation_envoi
     where crise_id = ${criseId} and mobilisation_id = ${mobilisationId}
       and reponse = 'besoin_aide'
     order by repondu_at`;

  return {
    mobilisation_id: m.id, message: m.message, lancee_at: m.lancee_at,
    close_at: m.close_at,
    interroges: Number(c!.interroges), en_securite: Number(c!.en_securite),
    besoin_aide: Number(c!.besoin_aide), sans_reponse: Number(c!.sans_reponse),
    a_secourir: aSecourir,
  };
}

/** La couverture par étiquette : « la communication est-elle debout ? ». */
export async function couvertureParEtiquette(
  sql: Sql, criseId: string, mobilisationId: string,
): Promise<{ etiquette: string; interroges: number; repondus: number }[]> {
  return sql<{ etiquette: string; interroges: number; repondus: number }[]>`
    select e.etiquette,
           count(*)::int as interroges,
           count(*) filter (where m.repondu_at is not null)::int as repondus
      from mobilisation_envoi m
      join lateral (
        select unnest(coalesce(d.etiquettes, p.etiquettes, '{}')) as etiquette
          from (select 1) as _
          left join destinataire d on d.id = m.destinataire_id
          left join personne p on p.id = m.personne_id and p.jusqu_a_version is null
      ) e on true
     where m.crise_id = ${criseId} and m.mobilisation_id = ${mobilisationId}
     group by e.etiquette
     order by e.etiquette`;
}

export async function ciblesDepuisAnnuaire(
  sql: Sql, criseId: string, roles?: string[],
  filtre?: { et?: string[] | undefined; ou?: string[] | undefined;
+3 −2
Changes for apps/api/src/noyau/projections.ts: 3 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -88,9 +88,10 @@ const projecteurs: Partial<Record<TypeEvenement, Projecteur>> = {
  "mobilisation.lancee": async (tx, ev) => {
    const ch = relire("mobilisation.lancee", ev.charge);
    await tx`
      insert into mobilisation (crise_id, id, lancee_par, lancee_at, seq_lancement, message)
      insert into mobilisation (crise_id, id, lancee_par, lancee_at, seq_lancement,
                                message, genre)
      values (${ev.crise_id}, ${ch.mobilisation_id}, ${ev.acteur_id}, ${ev.occurred_at},
              ${ev.seq}, ${ch.message})`;
              ${ev.seq}, ${ch.message}, ${ch.genre ?? "mobilisation"})`;
  },

  "mobilisation.envoyee": async (tx, ev) => {
+86 −0
Changes for apps/api/src/routes/mobilisation.ts: 86 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -48,6 +48,66 @@ export function enregistrerRoutesMobilisation(app: FastifyInstance, env: Env, sq
    };
  });

  /**
   * Le recensement de sécurité (EF-1103 étendu).
   *
   * « Êtes-vous en sécurité ? », posé à un effectif plutôt qu'à une cellule.
   * Une route à part, et non une option de la mobilisation : ce ne sont ni les
   * mêmes mots, ni les mêmes réponses, ni la même urgence. Se tromper de
   * bouton, ici, ferait répondre « j'arrive » à quelqu'un qui voulait dire
   * qu'il allait bien.
   */
  app.post("/crises/:criseId/recensements", acces, async (req, reply) => {
    const c = z.object({
      message: z.string().trim().min(10,
        "Dites où, et ce qu'on attend : « êtes-vous en sécurité » sans lieu ne se répond pas."),
      /** Sur la population de diffusion, par étiquettes. */
      et: z.array(z.string()).optional(),
      sauf: z.array(z.string()).optional(),
      /** Y joindre aussi l'annuaire de crise. */
      avecAnnuaire: z.boolean().default(false),
      relanceMinutes: z.number().int().min(1).max(240).default(15),
    }).parse(req.body);

    const id = criseId(req);
    const filtre = (c.et || c.sauf) ? { et: c.et, sauf: c.sauf } : undefined;
    const cibles = [
      ...await mobilisation.ciblesDepuisDiffusion(sql, id, filtre),
      ...(c.avecAnnuaire
        ? await mobilisation.ciblesDepuisAnnuaire(sql, id, undefined, filtre)
        : []),
    ];
    if (cibles.length === 0) {
      return reply.code(400).send({
        code: "aucune_cible",
        message: "Personne ne correspond. Un recensement sans destinataire ne recense rien.",
      });
    }

    const r = await mobilisation.lancer(sql, {
      criseId: id, message: c.message, cibles,
      relanceMinutes: c.relanceMinutes, escaladeMinutes: 480,
      genre: "recensement",
    }, env.KASTELL_URL_PUBLIQUE, ctx(req));

    return reply.code(201).send({
      ...r,
      note: "Les personnes désinscrites sont incluses : se désinscrire d'une lettre "
        + "d'information n'est pas renoncer à ce qu'on demande si l'on va bien.",
    });
  });

  app.get("/crises/:criseId/recensements/:sousId", acces, async (req, reply) => {
    const etat = await mobilisation.etatRecensement(sql, criseId(req), sousId(req));
    if (!etat) {
      return reply.code(404).send({ code: "introuvable", message: "Recensement inconnu." });
    }
    return reply.send({
      ...etat,
      couverture: await mobilisation.couvertureParEtiquette(sql, criseId(req), sousId(req)),
    });
  });

  app.post("/crises/:criseId/mobilisations", acces, async (req, reply) => {
    const c = z.object({
      message: z.string().trim().min(10,
@@ -99,6 +159,32 @@ export function enregistrerRoutesMobilisation(app: FastifyInstance, env: Env, sq

  // Sans session : celui qu'on mobilise n'a pas forcément de compte, et à 3 h
  // du matin il ne se connectera pas pour dire qu'il est là.
  /**
   * Répondre à un recensement, sans compte, depuis le lien reçu.
   *
   * En POST, et non en GET : un aperçu de lien dans une messagerie déclencherait
   * « je suis en sécurité » à la place de la personne, et c'est précisément la
   * réponse qu'on n'a pas le droit de deviner.
   */
  app.post("/recensement", async (req, reply) => {
    const c = z.object({
      jeton: z.string().min(10),
      reponse: z.enum(["en_securite", "besoin_aide"]),
    }).parse(req.body);
    const r = await mobilisation.repondreParLien(sql, c.jeton, c.reponse);
    if (!r) {
      return reply.code(404).send({
        code: "lien_invalide", message: "Ce lien n'est pas reconnu.",
      });
    }
    return reply.send({
      message: r.reponse === "besoin_aide"
        ? `C'est enregistré, ${r.nom}. Quelqu'un a votre nom sous les yeux.`
        : `Merci ${r.nom}, c'est noté.`,
      dejaRepondu: r.dejaRepondu,
    });
  });

  app.get("/mobilisation", async (req, reply) => {
    const { jeton } = z.object({ jeton: z.string().min(10) }).parse(req.query);
    const r = await mobilisation.accuser(sql, jeton, "lien");
+8 −0
Changes for apps/web/src/coque/Coque.tsx: 8 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -29,6 +29,7 @@ import { Kanban } from "../ecrans/Kanban.js";
import { VeilleExterne } from "../ecrans/VeilleExterne.js";
import { Directs } from "../ecrans/Directs.js";
import { CanauxCrise } from "../ecrans/CanauxCrise.js";
import { Recensement } from "../ecrans/Recensement.js";
import { ChoixLangue, useT } from "../langues/index.js";

interface SalleListee {
@@ -425,6 +426,11 @@ export function Coque({ criseId }: { criseId: string }) {
              onClick={() => aller(`/crises/${criseId}/documents`)}>
              {t("Documents")}
            </button>
            <button className={`item${module === "recensement" ? " actif" : ""}`}
              aria-current={module === "recensement" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/recensement`)}>
              {t("Recensement de sécurité")}
            </button>
            <button className={`item${module === "mobilisation" ? " actif" : ""}`}
              aria-current={module === "mobilisation" ? "page" : undefined}
              onClick={() => aller(`/crises/${criseId}/mobilisation`)}>
@@ -475,6 +481,8 @@ export function Coque({ criseId }: { criseId: string }) {
          <Directs criseId={criseId} />
        ) : module === "canaux" ? (
          <CanauxCrise criseId={criseId} />
        ) : module === "recensement" ? (
          <Recensement criseId={criseId} organisationId={crise?.organisation_id ?? ""} />
        ) : module === "documents" ? (
          <Documents criseId={criseId} />
        ) : module === "preparation" ? (
Loading