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

Le cockpit se compose, et se recompose pendant la crise

Le cockpit était le même pour tout le monde. Deux niveaux de rôle existent
pourtant — celui de l'organisation, celui de la cellule — et aucun n'était lu :
le directeur de crise y voyait les actions de toutes les cellules mêlées,
l'opérationnel des arbitrages qu'il ne peut pas rendre.

Treize tuiles, un catalogue, et des compositions préparées à froid par code de
cellule. La clé est le code et non l'identifiant : une cellule n'existe qu'au
déclenchement, et l'on compose pour une cellule qu'on ne peut pas encore
désigner.

Le point qui a demandé le plus de réflexion est la recomposition à chaud. Une
crise est un phénomène mouvant, et ce qu'on a préparé peut ne pas correspondre
à celle qu'on traverse ; un outil qui refuserait de suivre serait un frein
alors qu'il doit être le contraire. La modification pendant la crise est donc
un acte comme un autre — un événement au journal, projeté par-dessus la
composition gelée.

Trois conséquences, et ce sont elles qui rendent la souplesse acceptable : le
rembobinage montre le cockpit tel qu'il était à cet instant ; le retour
d'expérience lit ce qui avait été préparé, ce qu'il a fallu changer, et à
quelle minute ; le dossier gelé n'est jamais réécrit — retirer la surcharge
rend la main à ce qui avait été prévu.

Deux choses trouvées en chemin.

La projection ne peut pas s'écrire en « on conflict » : la table porte deux
index uniques partiels — un par cellule, un pour le défaut d'organisation — et
« on conflict » n'en cible qu'un. Elle supprime puis insère.

Le catalogue de tuiles a cassé la construction de l'image, et pour une raison
qui ne se devinait pas. C'était le premier import de *valeur* depuis
@kastell/shared : jusque-là tout était typé, donc effacé à la compilation. Le
paquet partagé n'avait donc jamais eu besoin de ses dépendances dans l'étape de
construction, et « packages/shared/node_modules » n'y était pas copié. Rollup
ne résolvait plus zod. Corrigé des deux côtés : la copie manquante au
Dockerfile, et un catalogue sans zod exposé en sous-chemin — le client n'a
aucune raison d'embarquer un validateur de schémas pour afficher des tuiles.

Vérifié sur une construction d'image complète, et en comptant les fichiers du
paquet client qui contiennent encore « zod » : aucun.

Co-Authored-By: Claude (RCA)
parent e2b8d1a0
Loading
Loading
Loading
Loading
+81 −0
Changes for apps/api/src/db/migrations/0030_cockpit.sql: 81 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- Composition du cockpit (§6.3, EF-304).
--
-- Le cockpit était identique pour tout le monde. Deux niveaux de rôle existent
-- pourtant — celui de l'organisation, celui de la cellule — et aucun n'était
-- lu : le directeur de crise y lisait les actions de toutes les cellules
-- mêlées, l'opérationnel y voyait des arbitrages qu'il ne peut pas rendre.
--
-- La composition se prépare à froid, comme l'annuaire et les fiches réflexes,
-- et se gèle au déclenchement. Mais elle reste modifiable pendant la crise, et
-- c'est le point qui a demandé le plus de réflexion : une crise est un
-- phénomène mouvant, et ce qu'on a préparé peut ne pas correspondre à celle
-- qu'on traverse. Un outil qui refuserait de suivre serait un frein — or il
-- doit être le contraire.
--
-- La modification à chaud est donc un acte comme un autre : un événement au
-- journal, projeté ici. Trois conséquences, et ce sont elles qui rendent la
-- souplesse acceptable :
--
--   1. Le rembobinage montre le cockpit tel qu'il était à cet instant.
--   2. Le retour d'expérience lit ce qui avait été préparé, ce qu'il a fallu
--      changer, et à quelle minute. C'est un enseignement sur la préparation,
--      pas une entorse à la préparation.
--   3. Le dossier gelé n'est jamais réécrit : la composition de crise se pose
--      par-dessus, sans effacer celle qui était en vigueur au déclenchement.

-- ── À froid : versionné avec le dossier de préparation ─────────────────────
--
-- La clé est le *code* de cellule, pas son identifiant : une cellule n'existe
-- qu'au déclenchement, et l'on compose pour une cellule qu'on ne peut pas
-- encore désigner. Le code, lui, est stable d'une crise à l'autre.
create table disposition_cockpit (
  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,
  -- Nul : la composition par défaut de l'organisation, pour toute cellule qui
  -- n'a pas la sienne.
  cellule_code    text,
  tuiles          jsonb   not null default '[]'::jsonb,
  notes           text,
  verifie_at      timestamptz,
  primary key (id, depuis_version)
);
create index disposition_courante_idx on disposition_cockpit (organisation_id)
  where jusqu_a_version is null;

create view disposition_courante as
  select * from disposition_cockpit where jusqu_a_version is null and not supprimee;

comment on table disposition_cockpit is
  'Composition du cockpit, préparée à froid et gelée au déclenchement.';

-- ── À chaud : ce que la crise a changé ─────────────────────────────────────
--
-- Une ligne par cellule recomposée. Elle recouvre la composition gelée sans
-- la remplacer : retirer la ligne rend la main à ce qui avait été préparé,
-- exactement comme une surcharge de réglage rend la main au fichier.
create table disposition_crise (
  crise_id     uuid        not null references crise(id) on delete cascade,
  -- Nulle : la composition que voit toute cellule sans la sienne.
  cellule_id   uuid        references cellule(id) on delete cascade,
  tuiles       jsonb       not null default '[]'::jsonb,
  compose_par  uuid        references compte(id),
  compose_at   timestamptz not null default now(),
  seq          bigint      not null
);

-- Une seule composition courante par cellule, et une seule pour « sans
-- cellule » : deux lignes concurrentes feraient dépendre l'écran de l'ordre de
-- lecture. L'index partiel traite le cas nul, qu'une clé primaire ordinaire
-- laisserait se dupliquer.
create unique index disposition_crise_cellule_unique
  on disposition_crise (crise_id, cellule_id) where cellule_id is not null;
create unique index disposition_crise_defaut_unique
  on disposition_crise (crise_id) where cellule_id is null;

comment on table disposition_crise is
  'Composition posée pendant la crise, projetée depuis « cockpit.compose ».';
+9 −1
Changes for apps/api/src/domaine/cockpit.ts: 9 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -45,6 +45,10 @@ export interface Cockpit {
    chrono: string;
    seuil_point_situation_minutes: number;
    preparation_version: number | null;
    /** Le pont de conférence, s'il a été renseigné. La route qui le pose
     *  annonçait qu'il apparaît ici ; il n'était pas servi. */
    pont_numero: string | null;
    pont_code: string | null;
  };
  cellules: EtatCellule[];
  arbitrages_en_attente: { id: string; objet: string; cellule_id: string | null; depuis: Date }[];
@@ -73,9 +77,11 @@ export async function etat(sql: Sql, criseId: string): Promise<Cockpit | null> {
    gravite: string; phase: string;
    exercice: boolean; declenchee_at: Date; close_at: Date | null;
    seuil_point_situation_minutes: number; preparation_version: number | null;
    pont_numero: string | null; pont_code: string | null;
  }[]>`select id, organisation_id, intitule, type, gravite, phase, exercice,
              declenchee_at, close_at,
              seuil_point_situation_minutes, preparation_version
              seuil_point_situation_minutes, preparation_version,
              pont_numero, pont_code
         from crise where id = ${criseId}`;
  if (!crise) return null;

@@ -144,6 +150,8 @@ export async function etat(sql: Sql, criseId: string): Promise<Cockpit | null> {
      chrono: chrono(crise.declenchee_at, reference),
      seuil_point_situation_minutes: crise.seuil_point_situation_minutes,
      preparation_version: crise.preparation_version,
      pont_numero: crise.pont_numero,
      pont_code: crise.pont_code,
    },
    cellules: cellules.map((c) => {
      const anciennete = c.dernier_point_at ? minutesDepuis(c.dernier_point_at) : null;
+115 −0
Changes for apps/api/src/domaine/dispositions.ts: 115 added lines, 0 removed lines.
Original line number Diff line number Diff line
import { DISPOSITION_DEFAUT, TUILES, type Tuile } from "@kastell/shared";
import type { Sql } from "../db/client.js";
import { enregistrer } from "../noyau/enregistrer.js";
import type { Contexte } from "../noyau/enregistrer.js";

/**
 * La composition du cockpit, à froid et à chaud (§6.3).
 *
 * Trois couches, lues dans cet ordre et arrêtées à la première qui répond :
 *
 *   1. Ce que la crise a posé pour cette cellule, s'il y a lieu.
 *   2. Ce que le dossier de préparation avait gelé pour le code de cette
 *      cellule, puis pour l'organisation entière.
 *   3. Le catalogue par défaut, pour qu'une crise déclenchée sans rien de
 *      préparé reste lisible.
 *
 * La deuxième couche se lit dans la version *gelée* de la crise, jamais dans
 * la version courante : une crise se relit avec le dossier qui était en
 * vigueur ce jour-là.
 */

export interface Disposition {
  tuiles: Tuile[];
  /** D'où vient ce qui est affiché — l'écran le dit, plutôt que de le taire. */
  source: "crise" | "cellule" | "organisation" | "defaut";
  compose_at: Date | null;
  compose_par_nom: string | null;
}

const connues = new Set<string>(TUILES);

/** Une tuile retirée du catalogue ne doit pas casser la lecture d'une crise close. */
const filtrer = (brut: unknown): Tuile[] =>
  Array.isArray(brut) ? brut.filter((t): t is Tuile => typeof t === "string" && connues.has(t)) : [];

export async function pour(
  sql: Sql, criseId: string, celluleId: string | null,
): Promise<Disposition> {
  const [crise] = await sql<{ organisation_id: string; preparation_version: number | null }[]>`
    select organisation_id, preparation_version from crise where id = ${criseId}`;
  if (!crise) return { tuiles: DISPOSITION_DEFAUT, source: "defaut", compose_at: null,
    compose_par_nom: null };

  // ── 1. Ce que la crise a posé ────────────────────────────────────────────
  const [posee] = await sql<{ tuiles: unknown; compose_at: Date; nom: string | null }[]>`
    select d.tuiles, d.compose_at, c.nom
      from disposition_crise d
      left join compte c on c.id = d.compose_par
     where d.crise_id = ${criseId}
       and d.cellule_id is not distinct from ${celluleId}`;
  if (posee) {
    const tuiles = filtrer(posee.tuiles);
    if (tuiles.length > 0) {
      return { tuiles, source: "crise", compose_at: posee.compose_at,
        compose_par_nom: posee.nom };
    }
  }

  // ── 2. Ce que le dossier avait gelé ──────────────────────────────────────
  const version = crise.preparation_version;
  const [code] = celluleId
    ? await sql<{ code: string }[]>`select code from cellule where id = ${celluleId}`
    : [];

  const gelees = await sql<{ cellule_code: string | null; tuiles: unknown }[]>`
    select cellule_code, tuiles from disposition_cockpit
     where organisation_id = ${crise.organisation_id}
       and not supprimee
       and depuis_version <= ${version ?? 0}
       and (jusqu_a_version is null or jusqu_a_version > ${version ?? 0})`;

  const pourCellule = code
    ? gelees.find((d) => d.cellule_code === code.code)
    : undefined;
  if (pourCellule) {
    const tuiles = filtrer(pourCellule.tuiles);
    if (tuiles.length > 0) {
      return { tuiles, source: "cellule", compose_at: null, compose_par_nom: null };
    }
  }

  const pourOrganisation = gelees.find((d) => d.cellule_code === null);
  if (pourOrganisation) {
    const tuiles = filtrer(pourOrganisation.tuiles);
    if (tuiles.length > 0) {
      return { tuiles, source: "organisation", compose_at: null, compose_par_nom: null };
    }
  }

  // ── 3. Le défaut ─────────────────────────────────────────────────────────
  return { tuiles: DISPOSITION_DEFAUT, source: "defaut", compose_at: null,
    compose_par_nom: null };
}

/**
 * Recomposer pendant la crise.
 *
 * Une liste vide rend la main à ce qui avait été préparé : c'est le geste
 * « revenir au dossier », et non « cockpit vide ». Le tri du catalogue est
 * conservé tel que l'appelant l'a donné — l'ordre des tuiles est une décision
 * de composition, pas un détail d'affichage.
 */
export async function composer(
  sql: Sql, criseId: string, celluleId: string | null, tuiles: Tuile[], ctx: Contexte,
): Promise<number> {
  const ev = await sql.begin((tx) =>
    enregistrer(tx, {
      criseId,
      celluleId,
      type: "cockpit.compose",
      charge: { cellule_id: celluleId, tuiles },
      ctx,
    }));
  return (ev as { seq: number }).seq;
}
+511 −0

File added.

Preview size limit exceeded, changes collapsed.

+198 −122

File changed.

Preview size limit exceeded, changes collapsed.

Loading