Commit 16a3c0b3 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Le jour J est celui où l'on découvre ce que le dossier avait oublié

Les registres de continuité se tiennent à froid, et c'est le bon endroit : on
ne dresse pas l'inventaire de son système d'information le jour où plus rien ne
marche. Les deux écrans le disaient — « ce tableau se constitue à froid, dans le
dossier de préparation » — et ne proposaient rien d'autre. La cellule repartait
vers un tableur, ce qui est le contraire du produit.

Une application métier oubliée et une autorité qu'il faut prévenir s'inscrivent
désormais depuis la crise, en six champs. À trois heures du matin on note ce
qu'on sait ; le reste se complète à froid.

Trois choix, et chacun demandait une justification.

La fiche va **au dossier**, pas sur la seule crise. Une application est un fait
de l'organisation, pas de la crise : la poser en surcouche créerait deux sources
pour une même liste, et la prochaine crise repartirait sans elle. Elle paraît
aussitôt à l'écran — l'état de continuité lit le registre en vigueur, non la
version gelée.

La garde est celle de la crise, non celle du dossier. C'est l'opérationnel qui
découvre l'application oubliée à trois heures du matin ; lui refuser le geste,
c'est le renvoyer vers quelqu'un qui dort. La contrepartie est bornée : le
chemin n'ouvre que l'ajout et la modification de ces deux registres.

L'observateur en est écarté, explicitement. La garde de crise admet tous les
membres de l'organisation, celui-là compris, alors que la préparation refuse ses
écritures et qu'une garantie l'atteste. Sans cette exclusion, ce chemin aurait
été un contournement.

L'écriture et son inscription au journal tiennent dans une transaction. Un
dossier complété sans trace serait précisément ce que le retour d'expérience
chercherait et ne trouverait pas. Cela a demandé d'exposer les variantes de la
fabrique de registres qui acceptent une transaction déjà ouverte : le travail
interne était déjà transactionnel, seule l'enveloppe ne l'était pas.

L'historique du dossier dit en propres termes que c'était pendant la crise, et
le journal retient nominativement qui l'a inscrite. C'est ce que le retour
d'expérience cherche : non pas la fiche, mais la liste de ce que la préparation
avait oublié.

Dix-sept garanties, dont les trois refus — chemin froid, observateur, session
invalide — et dont aucun ne laisse d'écriture derrière lui.

Ce que ces fichiers portent aussi, et qui appartient aux lots suivants : le
schéma de l'événement « fiche déroulée », l'entrée de boucle du registre des
types de crise, et les garanties de la prise de connaissance à l'ouverture.

Co-Authored-By: Claude (RCA)
parent f2b6d74c
Loading
Loading
Loading
Loading
+48 −14
Changes for apps/api/src/preparation/continuite.ts: 48 added lines, 14 removed lines.
Original line number Diff line number Diff line
import type { Sql } from "../db/client.js";
import type { Sql, Tx } from "../db/client.js";
import type { Partiel } from "@kastell/shared";
import * as dossier from "./dossier.js";
import type { TablePreparation } from "./dossier.js";
@@ -79,35 +79,68 @@ export const dispositions =
export const mobilisations =
  registre<MobilisationType>("mobilisation_type", "les mobilisations préparées");

function registre<T extends { id: string; nom: string }>(
/**
 * La fabrique des registres versionnés du dossier.
 *
 * Exportée parce qu'elle ne sert plus à la seule continuité : les types de
 * crise s'en servent aussi, et le prochain registre le fera. Elle vit ici
 * faute d'un meilleur endroit — la déplacer demanderait de toucher quatre
 * modules pour gagner un nom de fichier.
 */
export function registre<T extends { id: string; nom: string }>(
  table: TablePreparation, quoi: string,
) {
  return {
  // Nommé plutôt que rendu directement : « creer » délègue à « creerDans », et
  // « this » dans un objet littéral dépend de la façon dont on l'appelle.
  const registre_ = {
    async lister(sql: Sql, organisationId: string, version: number | null = null): Promise<T[]> {
      return dossier.lire<T>(sql, table, organisationId, version);
    },

    async creer(
      sql: Sql, organisationId: string, saisie: Saisie<T>, auteurId: string | null,
    /**
     * Écrire dans une transaction déjà ouverte.
     *
     * Séparé de `creer` pour un seul appelant, et il le justifie : compléter le
     * dossier depuis une crise doit inscrire l'entrée *et* l'événement qui en
     * garde la trace, sans qu'un incident puisse laisser l'une sans l'autre.
     *
     * `mention` complète le résumé de version. Six mois plus tard, l'historique
     * du dossier dit alors non seulement ce qui a été ajouté, mais que la
     * préparation l'avait oublié — ce qui est l'enseignement, pas le détail.
     */
    async creerDans(
      tx: Tx, organisationId: string, saisie: Saisie<T>, auteurId: string | null,
      mention = "",
    ): Promise<string> {
      return (await sql.begin(async (tx) => {
      const v = await dossier.nouvelleVersion(tx, organisationId, auteurId,
          `Ajout de ${String((saisie as { nom?: string }).nom ?? "une fiche")} à ${quoi}`);
        `Ajout de ${String((saisie as { nom?: string }).nom ?? "une fiche")} à ${quoi}${mention}`);
      return dossier.ecrire(tx, table, organisationId, v, null,
        { ...saisie, verifie_at: new Date() } as Record<string, unknown>);
      })) as string;
    },

    async modifier(
      sql: Sql, organisationId: string, id: string,
      changements: Saisie<T>, auteurId: string | null,
    async creer(
      sql: Sql, organisationId: string, saisie: Saisie<T>, auteurId: string | null,
    ): Promise<string> {
      return (await sql.begin((tx) =>
        registre_.creerDans(tx, organisationId, saisie, auteurId))) as string;
    },

    async modifierDans(
      tx: Tx, organisationId: string, id: string,
      changements: Saisie<T>, auteurId: string | null, mention = "",
    ): Promise<void> {
      await sql.begin(async (tx) => {
      const v = await dossier.nouvelleVersion(tx, organisationId, auteurId,
          `Modification dans ${quoi}`);
        `Modification dans ${quoi}${mention}`);
      await dossier.ecrire(tx, table, organisationId, v, id,
        { ...changements } as Record<string, unknown>);
      });
    },

    async modifier(
      sql: Sql, organisationId: string, id: string,
      changements: Saisie<T>, auteurId: string | null,
    ): Promise<void> {
      await sql.begin((tx) =>
        registre_.modifierDans(tx, organisationId, id, changements, auteurId));
    },

    async retirer(
@@ -120,4 +153,5 @@ function registre<T extends { id: string; nom: string }>(
      });
    },
  };
  return registre_;
}
+106 −1
Changes for apps/api/src/routes/continuite.ts: 106 added lines, 1 removed line.
Original line number Diff line number Diff line
import { z } from "zod";
import type { FastifyInstance, FastifyRequest } from "fastify";
import { applicationSchema, dispositionSchema, mobilisationTypeSchema,
  partiePrenanteSchema, type Tuile } from "@kastell/shared";
  partiePrenanteSchema, typeCriseSchema, type Tuile } from "@kastell/shared";
import type { Sql } from "../db/client.js";
import { requerirAccesCrise, requerirMembre, REDACTEURS } from "./garde.js";
import * as continuite from "../domaine/continuite.js";
import * as registres from "../preparation/continuite.js";
import * as typesCrise from "../preparation/typesCrise.js";
import * as dispositions from "../domaine/dispositions.js";
import { enregistrer } from "../noyau/enregistrer.js";

/**
 * Continuité d'activité et parties prenantes (§6.12).
@@ -41,6 +43,8 @@ export function enregistrerRoutesContinuite(app: FastifyInstance, sql: Sql): voi
    ["cockpit", registres.dispositions, dispositionSchema],
    // Les cinq décisions d'une mobilisation, prises à froid.
    ["mobilisations", registres.mobilisations, mobilisationTypeSchema],
    // Le vocabulaire de l'organisation : les fiches réflexes s'y indexent.
    ["types-crise", typesCrise.types, typeCriseSchema],
  ] as const) {
    app.get(`/organisations/:id/preparation/${chemin}`, lecture, async (req) => {
      const { version } = z.object({
@@ -90,6 +94,107 @@ export function enregistrerRoutesContinuite(app: FastifyInstance, sql: Sql): voi
    return reply.code(201).send({ seq });
  });

  // ── Compléter le dossier depuis la crise (EF-201a) ──────────────────────
  //
  // Les registres se tiennent à froid, et c'est le bon endroit : on ne dresse
  // pas l'inventaire de son système d'information le jour où plus rien ne
  // marche. Mais c'est le jour où l'on découvre ce qui manquait, et l'écran de
  // crise le disait sans rien proposer — « ce tableau se constitue à froid »,
  // et rien d'autre. La cellule repartait vers un tableur, ce qui est le
  // contraire du produit.
  //
  // Trois choix, et chacun demande une justification.
  //
  // L'entrée va au dossier, pas sur la crise. Une application est un fait de
  // l'organisation, pas de la crise : la mettre en surcouche créerait deux
  // sources pour une même liste, et la prochaine crise repartirait sans elle.
  // Elle apparaît aussitôt, l'état de continuité lisant le registre en vigueur.
  //
  // La garde est celle de la crise, non celle du dossier. C'est l'opérationnel
  // qui découvre l'application oubliée à trois heures du matin ; lui refuser le
  // geste, c'est le renvoyer vers quelqu'un qui dort. La contrepartie est
  // assumée et bornée : le chemin n'ouvre que l'ajout et la modification de ces
  // deux registres, et chaque geste est inscrit nominativement au journal.
  //
  // L'écriture et son inscription tiennent dans une transaction. Un dossier
  // complété sans trace au journal serait précisément ce que le retour
  // d'expérience cherche et ne trouverait pas.
  for (const [chemin, registre, schema, quoi] of [
    ["applications", registres.applications, applicationSchema, "application"],
    ["parties-prenantes", registres.parties, partiePrenanteSchema, "partie_prenante"],
  ] as const) {
    app.post(`/crises/:criseId/registres/${chemin}`, acces, async (req, reply) => {
      // Un observateur ne modifie pas le dossier, et ce chemin ne doit pas
      // ouvrir ce que la préparation refuse. La garde de crise admet tous les
      // membres de l'organisation, celui-là compris : il faut donc l'écarter
      // ici, explicitement.
      if (req.role === "observateur") {
        return reply.code(403).send({
          code: "role_insuffisant",
          message: "Un observateur ne modifie pas le dossier de préparation. "
            + "C'est la même règle qu'à froid.",
        });
      }

      const saisie = schema.parse(req.body);
      const id = criseId(req);
      const [c] = await sql<{ organisation_id: string }[]>`
        select organisation_id from crise where id = ${id}`;
      if (!c) return reply.code(404).send({ code: "introuvable", message: "Crise inconnue." });

      const entreeId = await sql.begin(async (tx) => {
        const cree = await registre.creerDans(tx, c.organisation_id, saisie as never,
          moi(req), ", pendant la crise");
        await enregistrer(tx, {
          criseId: id, type: "dossier.complete",
          charge: { registre: quoi, entree_id: cree, nom: saisie.nom, geste: "ajout" },
          ctx: ctx(req),
        });
        return cree;
      });

      return reply.code(201).send({
        id: entreeId,
        note: "Inscrit au dossier de préparation, et non sur cette seule crise : "
          + "la prochaine l'aura. Le journal retient que la préparation l'avait oublié.",
      });
    });

    app.patch(`/crises/:criseId/registres/${chemin}/:sousId`,
      acces, async (req, reply) => {
        if (req.role === "observateur") {
          return reply.code(403).send({
            code: "role_insuffisant",
            message: "Un observateur ne modifie pas le dossier de préparation. "
              + "C'est la même règle qu'à froid.",
          });
        }

        const changements = schema.partial().parse(req.body);
        const id = criseId(req);
        const entreeId = sousId(req);
        const [c] = await sql<{ organisation_id: string }[]>`
          select organisation_id from crise where id = ${id}`;
        if (!c) return reply.code(404).send({ code: "introuvable", message: "Crise inconnue." });

        await sql.begin(async (tx) => {
          await registre.modifierDans(tx, c.organisation_id, entreeId,
            changements as never, moi(req), ", pendant la crise");
          await enregistrer(tx, {
            criseId: id, type: "dossier.complete",
            charge: {
              registre: quoi, entree_id: entreeId,
              nom: changements.nom ?? "(sans changement de nom)",
              geste: "modification",
            },
            ctx: ctx(req),
          });
        });

        return reply.code(204).send();
      });
  }

  // ── Composition du cockpit, à chaud ──────────────────────────────────────
  //
  // La cellule est passée en paramètre plutôt que déduite de la session : un
+188 −1
Changes for apps/api/src/verification/continuite.ts: 188 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import type { Sql } from "../db/client.js";
import * as comptes from "../auth/comptes.js";
import * as sessions from "../auth/sessions.js";
import { ouvrirCrise } from "../domaine/crise.js";
import * as continuite from "../domaine/continuite.js";
import * as registres from "../preparation/continuite.js";
@@ -14,7 +15,6 @@ const adresse = (p: string) => `${p}+${randomUUID()}@exemple.test`;
export async function verifierContinuite(
  sql: Sql, app: FastifyInstance, r: Rapport,
): Promise<void> {
  void app;
  const email = adresse("continuite");
  const inscription = await comptes.inscrire(sql, {
    email, nom: "Tugdual Le Dour", motDePasse: "marnage-de-cancale-52",
@@ -149,6 +149,193 @@ export async function verifierContinuite(
      && cnil3?.informe_par_nom === "Tugdual Le Dour");

  // ── Rejeu ────────────────────────────────────────────────────────────────
  // ── Compléter le dossier depuis la crise (EF-201a) ──────────────────────
  //
  // Le registre se tient à froid, et c'est le bon endroit. Mais c'est pendant
  // la crise qu'on découvre ce qui manquait, et l'écran ne proposait rien : la
  // cellule repartait vers un tableur.
  r.titre("Compléter le dossier pendant la crise");

  const cookiePour = async (courriel: string, mdp: string) =>
    `${sessions.NOM_COOKIE}=${(await comptes.connecterParMotDePasse(sql,
      { email: courriel, motDePasse: mdp })).jeton}`;

  // Un membre de cellule sans droit d'écriture sur le dossier : c'est lui qui
  // découvre l'application oubliée à trois heures du matin.
  const courrielMembre = adresse("continuite-membre");
  const mdpMembre = "ria-detroat-71";
  const membre = await comptes.inscrire(sql, {
    email: courrielMembre, nom: "Solenn Kervella", motDePasse: mdpMembre,
    nomOrganisation: "Organisation à part", ip: "127.0.0.1",
    urlPublique: "http://localhost:8080",
  });
  await sql`insert into membre (organisation_id, compte_id, role)
            values (${organisationId}, ${membre.compteId}, 'membre')`;
  const cookieMembre = await cookiePour(courrielMembre, mdpMembre);

  const refusAvant = await app.inject({
    method: "POST", url: `/api/organisations/${organisationId}/preparation/applications`,
    headers: { cookie: cookieMembre },
    payload: { nom: "GMAO atelier", criticite: "importante" },
  });
  r.verifie("EF-201a", "ce membre ne peut pas écrire dans le dossier par le chemin froid",
    refusAvant.statusCode === 403, `reçu ${refusAvant.statusCode}`);

  const ajout = await app.inject({
    method: "POST", url: `/api/crises/${crise.criseId}/registres/applications`,
    headers: { cookie: cookieMembre },
    payload: {
      nom: "GMAO atelier", criticite: "vitale",
      secours: "Fiches d'intervention papier, classeur au bureau de maintenance.",
      responsable: "Gaël Riou", responsable_tel: "+33600000210",
    },
  });
  r.verifie("EF-201a", "il l'inscrit en revanche depuis la crise",
    ajout.statusCode === 201, `reçu ${ajout.statusCode} — ${ajout.body.slice(0, 140)}`);
  const gmaoId = ajout.json().id;

  const apresAjout = await continuite.etat(sql, crise.criseId, organisationId);
  r.verifie("EF-201a", "elle paraît aussitôt dans l'état de continuité",
    apresAjout.some((a) => a.nom === "GMAO atelier"), String(apresAjout.length));
  r.verifie("EF-201a", "avec son secours, qui est le champ utile",
    apresAjout.find((a) => a.nom === "GMAO atelier")?.secours?.includes("papier") === true);
  r.verifie("EF-201a", "et son état est « non constaté », la seule réponse honnête",
    apresAjout.find((a) => a.nom === "GMAO atelier")?.etat === "inconnu");

  const [trace] = await sql<{ acteur_id: string | null; charge: Record<string, unknown> }[]>`
    select acteur_id, charge from evenement
     where crise_id = ${crise.criseId} and type = 'dossier.complete'
     order by seq desc limit 1`;
  r.verifie("EF-201a", "le journal de la crise retient que la préparation l'avait oublié",
    trace?.charge["geste"] === "ajout" && trace.charge["nom"] === "GMAO atelier"
      && trace.charge["registre"] === "application");
  r.verifie("EF-201a", "et il retient qui l'a inscrite",
    trace?.acteur_id === membre.compteId);

  const [version] = await sql<{ resume: string }[]>`
    select resume from preparation_version
     where organisation_id = ${organisationId} order by version desc limit 1`;
  r.verifie("EF-201a", "l'historique du dossier dit que c'était pendant la crise",
    version?.resume.includes("pendant la crise") === true, version?.resume);

  // Ce qui compte pour la fois suivante : une crise ouverte après coup l'a.
  const suivante = await ouvrirCrise(sql, {
    organisationId, intitule: "Panne électrique du site", type: "indisponibilite",
    gravite: "majeure",
  }, ctx);
  const etatSuivante = await continuite.etat(sql, suivante.criseId, organisationId);
  r.verifie("EF-201a", "et la crise suivante l'a, sans qu'on ait rien à reprendre",
    etatSuivante.some((a) => a.nom === "GMAO atelier"));

  // Corriger une fiche : le secours qui manque se découvre en le cherchant.
  const correction = await app.inject({
    method: "PATCH",
    url: `/api/crises/${crise.criseId}/registres/applications/${gmaoId}`,
    headers: { cookie: cookieMembre },
    payload: { nom: "GMAO atelier", secours: "Aucun secours : arrêt de la maintenance." },
  });
  r.verifie("EF-201a", "une fiche se corrige aussi depuis la crise",
    correction.statusCode === 204, `reçu ${correction.statusCode}`);
  const apresCorrection = await continuite.etat(sql, crise.criseId, organisationId);
  r.verifie("EF-201a", "et la correction se lit dans le tableau",
    apresCorrection.find((a) => a.nom === "GMAO atelier")?.secours?.includes("Aucun") === true);

  const [traceModif] = await sql<{ charge: Record<string, unknown> }[]>`
    select charge from evenement
     where crise_id = ${crise.criseId} and type = 'dossier.complete'
     order by seq desc limit 1`;
  r.verifie("EF-201a", "le journal distingue la correction de l'ajout",
    traceModif?.charge["geste"] === "modification");

  // Les parties prenantes suivent la même règle : le délai réglementaire ne
  // s'arrête pas pendant qu'on cherche à qui le déclarer.
  const partie = await app.inject({
    method: "POST", url: `/api/crises/${crise.criseId}/registres/parties-prenantes`,
    headers: { cookie: cookieMembre },
    payload: {
      nom: "Autorité de contrôle du secteur", nature: "autorite",
      delai_heures: 72, fondement: "RGPD art. 33",
    },
  });
  r.verifie("EF-201a", "une partie prenante oubliée s'inscrit de la même façon",
    partie.statusCode === 201, `reçu ${partie.statusCode} — ${partie.body.slice(0, 140)}`);
  const echeancesApres = await continuite.echeances(sql, crise.criseId, organisationId);
  r.verifie("EF-201a", "son compte à rebours part aussitôt",
    echeancesApres.parties.some((e) => e.nom === "Autorité de contrôle du secteur"
      && e.delai_heures === 72),
    String(echeancesApres.parties.length));

  // ── Ce que ce chemin n'ouvre pas ─────────────────────────────────────────
  const courrielObs = adresse("continuite-observateur");
  const mdpObs = "goulet-de-brest-33";
  const observateur = await comptes.inscrire(sql, {
    email: courrielObs, nom: "Maïwenn Guillou", motDePasse: mdpObs,
    nomOrganisation: "Autre organisation", ip: "127.0.0.1",
    urlPublique: "http://localhost:8080",
  });
  await sql`insert into membre (organisation_id, compte_id, role)
            values (${organisationId}, ${observateur.compteId}, 'observateur')`;

  const refusObservateur = await app.inject({
    method: "POST", url: `/api/crises/${crise.criseId}/registres/applications`,
    headers: { cookie: await cookiePour(courrielObs, mdpObs) },
    payload: { nom: "Tentative d'observateur", criticite: "secondaire" },
  });
  r.verifie("EF-201a", "un observateur ne complète pas le dossier, ici pas plus qu'à froid",
    refusObservateur.statusCode === 403, `reçu ${refusObservateur.statusCode}`);

  const refusEtranger = await app.inject({
    method: "POST", url: `/api/crises/${crise.criseId}/registres/applications`,
    headers: { cookie: await cookiePour(courrielObs, mdpObs) + "x" },
    payload: { nom: "Tentative sans session", criticite: "secondaire" },
  });
  r.verifie("EF-201a", "et une session invalide n'écrit rien",
    refusEtranger.statusCode === 401, `reçu ${refusEtranger.statusCode}`);

  const apresTentatives = await registres.applications.lister(sql, organisationId);
  r.verifie("EF-201a", "aucune des tentatives refusées n'a laissé de fiche",
    !apresTentatives.some((a) => a.nom.startsWith("Tentative")));

    // ── La prise de connaissance, dès l'ouverture (EF-301b) ─────────────────
  //
  // Tous les délais réglementaires en découlent, et ils ne partent pas de
  // l'ouverture de la crise dans l'outil. La question se posait après coup, avec
  // un motif obligatoire — c'est-à-dire trop tard, ou jamais, et les délais
  // affichés étaient faux dans le sens dangereux.
  r.titre("Prise de connaissance à l'ouverture");

  const appris = new Date(Date.now() - 30 * 3600_000);
  const criseTardive = await ouvrirCrise(sql, {
    organisationId, intitule: "Fuite découverte tardivement", type: "fuite_donnees",
    gravite: "critique", connaissanceAt: appris,
  }, ctx);

  const [posee] = await sql<{ connaissance_at: Date | null; motif: string | null }[]>`
    select connaissance_at, connaissance_motif as motif
      from crise where id = ${criseTardive.criseId}`;
  r.verifie("EF-301b", "la date donnée à l'ouverture est retenue",
    posee?.connaissance_at !== null
      && Math.abs((posee!.connaissance_at!.getTime() - appris.getTime())) < 2000,
    String(posee?.connaissance_at));
  r.verifie("EF-301b", "et le journal dit d'où elle vient",
    posee?.motif?.includes("à l'ouverture") === true, String(posee?.motif));

  const echTardive = await continuite.echeances(sql, criseTardive.criseId, organisationId);
  r.verifie("EF-301b", "les échéances partent des faits, non de l'ouverture",
    echTardive.depart_declare
      && Math.abs(echTardive.depart_at.getTime() - appris.getTime()) < 2000);

  // Sans date, rien n'est inscrit : « j'ai appris à l'instant où j'ai ouvert »
  // est déjà l'hypothèse par défaut, et un événement qui ne dit rien de plus
  // que le défaut est du bruit dans un journal.
  // Sur « suivante », qu'aucun contrôle ne date : la crise principale a servi
  // à éprouver la datation après coup, elle en porte donc un.
  const [bruit] = await sql<{ n: string }[]>`
    select count(*) as n from evenement
     where crise_id = ${suivante.criseId} and type = 'crise.connaissance_datee'`;
  r.verifie("EF-301b", "sans date donnée, aucun événement n'est écrit",
    Number(bruit?.n ?? 0) === 0, String(bruit?.n));

    r.titre("Rejeu de la continuité");
  const anomalies = await verifierEnBase(sql, crise.criseId);
  r.verifie("EB-08", "la chaîne reste intacte après constats et notifications",
+138 −2

File changed.

Preview size limit exceeded, changes collapsed.

+140 −2

File changed.

Preview size limit exceeded, changes collapsed.

Loading