Commit 3340c538 authored by Kourser's avatar Kourser
Browse files

Tableau blanc : le trait est un brouillon, l'instantané fait foi

Lot L7. Modèles de crise, scène collaborative, instantanés versés au dossier.

Ce qui n'entre pas au journal
  Un tableau se dessine à plusieurs traits par seconde. Inscrire chacun dans
  une chaîne d'empreintes serait absurde et rendrait le rembobinage
  impraticable. La scène en cours est un état de travail — au même titre qu'un
  message non envoyé, et §5 dit déjà que la boîte noire enregistre les actes
  posés, pas les gestes en train de se faire.

  Ce qui fait foi, c'est l'instantané : daté, référencé par empreinte, versé en
  gestion documentaire, inscrit au journal. Douze modifications de scène
  produisent zéro événement ; un instantané en produit un.

Six modèles, parce qu'une page blanche à 23 h ne produit rien
  Chronologie d'incident, cartographie du SI, arbre d'attaque, matrice de
  décision, organigramme de cellule. Chacun pose ses zones d'emblée : le dessin
  commence par se répartir, pas par se cadrer. Et chacun dit à quoi il sert.

Deux gestes que personne ne pense à faire
  Fermer un tableau en fige d'abord l'état final — c'est souvent celui qui
  compte. Et l'entretien fige périodiquement les tableaux qui ont bougé, sans
  refiger ceux qui n'ont pas changé.

Défaut corrigé, et il était bien caché
  `select seq::text` crée une colonne de sortie nommée `seq`, et `order by seq`
  non qualifié s'y réfère : le tri devenait textuel, où « 9 » passe avant
  « 15 ». Le rembobinage d'un tableau rendait donc le mauvais instantané. Les
  requêtes concernées trient désormais sur la colonne qualifiée, avec le
  pourquoi en commentaire. Deux contrôles l'ont attrapé.

Vérification
  `pnpm verif` passe de 341 à 361 contrôles.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 768ed796
Loading
Loading
Loading
Loading
+2 −1
Original line number Diff line number Diff line
@@ -19,7 +19,8 @@ l'enregistre intégralement — de manière à pouvoir la rembobiner.
| **L4** | Chat de crise, flux temps réel | 🟢 fait |
| **L5** | Gestion documentaire, stockage objet | 🟢 fait — indexation des PDF à venir avec l'aperçu |
| **L6** | Visioconférence, enregistrement, rétention | 🟢 fait côté serveur — les fonctions d'appel (partage d'écran, panneau flottant, repli audio) vivent dans le client |
| L7+ | Tableau blanc, mobilisation, restitution | ⚪ |
| **L7** | Tableau blanc, modèles, instantanés | 🟢 fait côté serveur |
| L8+ | Mobilisation, restitution, durcissement | ⚪ |

Ce qui fonctionne aujourd'hui : le journal inviolable et son chaînage
d'empreintes, le vérificateur indépendant, la reconstitution d'un état passé
+54 −0
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- 0011 — Tableaux blancs (§6.10)
--
-- Décision structurante : la scène en cours d'édition N'EST PAS journalisée.
-- Un tableau se dessine à raison de plusieurs modifications par seconde ;
-- inscrire chaque trait dans une chaîne d'empreintes serait absurde et rendrait
-- le rembobinage impraticable.
--
-- Ce qui fait foi, c'est l'instantané (EF-1004) : daté, référencé par empreinte,
-- versé en gestion documentaire. Le trait en cours est un brouillon, au même
-- titre qu'un message non envoyé — et §5 dit déjà que la boîte noire enregistre
-- les actes posés, pas les gestes en train de se faire.
-- ═══════════════════════════════════════════════════════════════════════════

create table tableau (
  crise_id     uuid        not null references crise(id) on delete restrict,
  id           uuid        not null,
  cellule_id   uuid        references cellule(id) on delete set null,
  titre        text        not null,
  modele       text        not null default 'libre',
  seq_creation bigint      not null,
  cree_at      timestamptz not null,
  ferme_at     timestamptz,
  primary key (crise_id, id)
);
create index tableau_cellule on tableau (crise_id, cellule_id) where ferme_at is null;

-- État de travail : remplacé à volonté, jamais chaîné. Sa perte coûterait le
-- dessin en cours, pas la preuve — les instantanés portent celle-ci.
create table tableau_scene (
  crise_id   uuid        not null,
  tableau_id uuid        not null,
  scene      jsonb       not null default '{"elements":[]}'::jsonb,
  revision   bigint      not null default 0,
  maj_at     timestamptz not null default now(),
  maj_par    uuid        references compte(id) on delete set null,
  primary key (crise_id, tableau_id)
);

create table tableau_instantane (
  crise_id    uuid        not null,
  id          uuid        not null,
  tableau_id  uuid        not null,
  seq         bigint      not null,
  pris_at     timestamptz not null,
  pris_par    uuid        references compte(id) on delete set null,
  -- « à la demande » ou « périodique » : on distingue le geste de la routine.
  cause       text        not null check (cause in ('demande', 'periodique', 'fermeture')),
  document_id uuid,
  empreinte   bytea       not null,
  elements    integer     not null default 0,
  primary key (crise_id, id)
);
create index instantane_tableau on tableau_instantane (crise_id, tableau_id, seq);
+6 −5
Original line number Diff line number Diff line
@@ -333,11 +333,12 @@ export async function versions(
  sql: Sql, criseId: string, documentId: string,
): Promise<Version[]> {
  return sql<Version[]>`
    select document_id, version, cle_objet, nom_fichier, type_mime, taille::text,
           empreinte, depose_par, depose_at, seq::text, verdict, verdict_detail, restaure_de
      from document_version
     where crise_id = ${criseId} and document_id = ${documentId}
     order by version desc`;
    select v.document_id, v.version, v.cle_objet, v.nom_fichier, v.type_mime, v.taille::text,
           v.empreinte, v.depose_par, v.depose_at, v.seq::text, v.verdict, v.verdict_detail,
           v.restaure_de
      from document_version v
     where v.crise_id = ${criseId} and v.document_id = ${documentId}
     order by v.version desc`;
}

export async function versionPrecise(
+4 −4
Original line number Diff line number Diff line
@@ -423,10 +423,10 @@ export async function poserMarquePage(
export async function marquePages(sql: Sql, criseId: string, enregistrementId: string) {
  return sql<{ id: string; offset_secondes: number; libelle: string; pose_at: Date;
    pose_par: string | null; seq: string }[]>`
    select id, offset_secondes, libelle, pose_at, pose_par, seq::text
      from marque_page
     where crise_id = ${criseId} and enregistrement_id = ${enregistrementId}
     order by offset_secondes`;
    select m.id, m.offset_secondes, m.libelle, m.pose_at, m.pose_par, m.seq::text
      from marque_page m
     where m.crise_id = ${criseId} and m.enregistrement_id = ${enregistrementId}
     order by m.offset_secondes`;
}

/** EB-19 — rembobiner à un instant propose l'extrait vidéo correspondant. */
+236 −0
Original line number Diff line number Diff line
import { createHash, randomUUID } from "node:crypto";
import { modele, type ModeleTableau } from "@kastell/shared";
import type { Sql } from "../db/client.js";
import { enregistrer, type Contexte } from "../noyau/enregistrer.js";
import { publier } from "../adaptateurs/diffusion.js";
import { stockageConfigure } from "../adaptateurs/stockage.js";
import { deposerDirect } from "./documents.js";

/**
 * Tableaux blancs (§6.10).
 *
 * La scène en cours n'entre pas au journal : plusieurs traits par seconde
 * rendraient la chaîne inexploitable. Ce qui fait foi, c'est l'instantané —
 * daté, référencé par empreinte, versé en gestion documentaire. Le trait en
 * train de se faire est un brouillon, et §5 dit déjà que la boîte noire
 * enregistre les actes posés, pas les gestes en cours.
 */

/** Au-delà, un instantané périodique est pris si la scène a changé (EF-1004). */
export const PERIODE_INSTANTANE_MS = 10 * 60 * 1000;

export interface Tableau {
  id: string;
  crise_id: string;
  cellule_id: string | null;
  titre: string;
  modele: ModeleTableau;
  cree_at: Date;
  ferme_at: Date | null;
}

export interface Scene {
  scene: { elements?: unknown[] } & Record<string, unknown>;
  revision: string;
  maj_at: Date;
  maj_par: string | null;
}

export async function creer(
  sql: Sql, criseId: string, celluleId: string | null,
  titre: string, cle: ModeleTableau, ctx: Contexte,
): Promise<{ tableauId: string; zones: unknown[] }> {
  const tableauId = randomUUID();
  const m = modele(cle);

  await sql.begin(async (tx) => {
    await enregistrer(tx, {
      criseId, celluleId, type: "tableau.cree",
      charge: { tableau_id: tableauId, titre, modele: m.cle }, ctx,
    });
    // Les zones du modèle sont posées d'emblée : le dessin commence par se
    // répartir, pas par se cadrer.
    await tx`
      insert into tableau_scene (crise_id, tableau_id, scene, maj_par)
      values (${criseId}, ${tableauId},
              ${tx.json({ elements: [], zones: m.zones } as never)}, ${ctx.acteurId})`;
  });
  return { tableauId, zones: m.zones };
}

export async function fermer(
  sql: Sql, criseId: string, tableauId: string, ctx: Contexte,
): Promise<void> {
  // Un tableau qu'on ferme mérite un dernier instantané : c'est souvent l'état
  // final qui compte, et personne ne pense à le prendre.
  await prendreInstantane(sql, criseId, tableauId, "fermeture", ctx).catch(() => undefined);
  await sql.begin((tx) => enregistrer(tx, {
    criseId, type: "tableau.ferme", charge: { tableau_id: tableauId }, ctx,
  }));
}

export async function lister(
  sql: Sql, criseId: string, celluleId?: string,
): Promise<(Tableau & { elements: number; maj_at: Date | null })[]> {
  return sql<(Tableau & { elements: number; maj_at: Date | null })[]>`
    select t.*, coalesce(jsonb_array_length(s.scene -> 'elements'), 0) as elements, s.maj_at
      from tableau t
      left join tableau_scene s on s.crise_id = t.crise_id and s.tableau_id = t.id
     where t.crise_id = ${criseId} and t.ferme_at is null
       ${celluleId ? sql`and (t.cellule_id = ${celluleId} or t.cellule_id is null)` : sql``}
     order by t.cree_at`;
}

export async function scene(
  sql: Sql, criseId: string, tableauId: string,
): Promise<Scene | null> {
  const [s] = await sql<Scene[]>`
    select scene, revision::text, maj_at, maj_par from tableau_scene
     where crise_id = ${criseId} and tableau_id = ${tableauId}`;
  return s ?? null;
}

/**
 * Enregistre l'état de travail. Le numéro de révision attendu évite qu'un
 * client en retard n'écrase le dessin des autres — sans verrouiller personne :
 * en cas de conflit, on rend la scène courante et le client rejoue ses traits.
 */
export async function poserScene(
  sql: Sql, criseId: string, tableauId: string,
  contenu: Record<string, unknown>, revisionAttendue: number | null, ctx: Contexte,
): Promise<{ revision: number; conflit: boolean; scene?: Scene }> {
  const [maj] = await sql<{ revision: string }[]>`
    update tableau_scene
       set scene = ${sql.json(contenu as never)}, revision = revision + 1,
           maj_at = now(), maj_par = ${ctx.acteurId}
     where crise_id = ${criseId} and tableau_id = ${tableauId}
       ${revisionAttendue === null ? sql`` : sql`and revision = ${revisionAttendue}`}
    returning revision::text`;

  if (!maj) {
    const courante = await scene(sql, criseId, tableauId);
    if (!courante) throw new Error("Tableau inconnu.");
    return { revision: Number(courante.revision), conflit: true, scene: courante };
  }

  await publier({
    criseId, seq: null, genre: "tableau.scene",
    charge: { tableau_id: tableauId, revision: Number(maj.revision), par: ctx.acteurId },
  });
  return { revision: Number(maj.revision), conflit: false };
}

// ── Instantanés (EF-1004, EF-1005) ─────────────────────────────────────────

export interface Instantane {
  id: string;
  tableau_id: string;
  seq: string;
  pris_at: Date;
  pris_par: string | null;
  cause: "demande" | "periodique" | "fermeture";
  document_id: string | null;
  empreinte: Buffer;
  elements: number;
}

/**
 * EF-1004 — L'instantané verse la scène en gestion documentaire et l'inscrit au
 * journal. C'est ainsi qu'un tableau devient une pièce du dossier plutôt qu'un
 * dessin qu'on ne retrouvera plus.
 */
export async function prendreInstantane(
  sql: Sql, criseId: string, tableauId: string,
  cause: "demande" | "periodique" | "fermeture", ctx: Contexte,
): Promise<{ instantaneId: string; empreinte: string; elements: number }> {
  const [t] = await sql<{ titre: string; cellule_id: string | null }[]>`
    select titre, cellule_id from tableau where crise_id = ${criseId} and id = ${tableauId}`;
  if (!t) throw new Error("Tableau inconnu.");
  const courante = await scene(sql, criseId, tableauId);
  if (!courante) throw new Error("Tableau sans scène.");

  const contenu = Buffer.from(JSON.stringify(courante.scene), "utf8");
  const empreinte = createHash("sha256").update(contenu).digest("hex");
  const elements = Array.isArray(courante.scene.elements) ? courante.scene.elements.length : 0;
  const instantaneId = randomUUID();

  // Le format de scène est le nôtre : si la brique de dessin disparaît, les
  // instantanés restent lisibles et convertibles (§3).
  let documentId: string | null = null;
  if (stockageConfigure()) {
    const document = await deposerDirect(sql, {
      criseId, celluleId: t.cellule_id, dossier: "Tableaux",
      titre: `${t.titre}${new Date().toLocaleString("fr-FR")}`,
      nomFichier: `${instantaneId}.json`, typeMime: "application/json",
      taille: contenu.length,
    }, contenu, { ...ctx, origine: "automatique" });
    documentId = document.documentId;
  }

  await sql.begin((tx) => enregistrer(tx, {
    criseId, celluleId: t.cellule_id, type: "tableau.instantane",
    charge: {
      instantane_id: instantaneId, tableau_id: tableauId, titre: t.titre,
      cause, empreinte, elements, document_id: documentId,
    },
    ctx: cause === "periodique" ? { ...ctx, origine: "automatique" } : ctx,
  }));

  return { instantaneId, empreinte, elements };
}

export async function instantanes(
  sql: Sql, criseId: string, tableauId: string,
): Promise<Instantane[]> {
  // Le tri est qualifié à dessein : `seq::text` crée une colonne de sortie
  // nommée `seq`, et un `order by seq` non qualifié s'y référerait — donnant un
  // tri textuel où « 9 » passerait avant « 15 ».
  return sql<Instantane[]>`
    select i.id, i.tableau_id, i.seq::text, i.pris_at, i.pris_par, i.cause,
           i.document_id, i.empreinte, i.elements
      from tableau_instantane i
     where i.crise_id = ${criseId} and i.tableau_id = ${tableauId}
     order by i.seq desc`;
}

/**
 * EF-1005 — Rembobiner un tableau, c'est retrouver l'instantané en vigueur à
 * l'instant demandé. On rejoue ainsi l'évolution de la compréhension de la
 * situation, qui est souvent l'enseignement principal d'un retour d'expérience.
 */
export async function instantaneALaSeq(
  sql: Sql, criseId: string, tableauId: string, seq: number,
): Promise<Instantane | null> {
  const [i] = await sql<Instantane[]>`
    select i.id, i.tableau_id, i.seq::text, i.pris_at, i.pris_par, i.cause,
           i.document_id, i.empreinte, i.elements
      from tableau_instantane i
     where i.crise_id = ${criseId} and i.tableau_id = ${tableauId} and i.seq <= ${seq}
     order by i.seq desc limit 1`;
  return i ?? null;
}

/** Instantanés périodiques : appelé par l'entretien, sur les tableaux qui ont bougé. */
export async function instantanesPeriodiques(sql: Sql): Promise<number> {
  const candidats = await sql<{ crise_id: string; tableau_id: string }[]>`
    select s.crise_id, s.tableau_id
      from tableau_scene s
      join tableau t on t.crise_id = s.crise_id and t.id = s.tableau_id
      join crise c on c.id = s.crise_id
     where t.ferme_at is null and c.phase <> 'close'
       and jsonb_array_length(s.scene -> 'elements') > 0
       and s.maj_at > now() - ${`${PERIODE_INSTANTANE_MS / 1000} seconds`}::interval
       and not exists (
         select 1 from tableau_instantane i
          where i.crise_id = s.crise_id and i.tableau_id = s.tableau_id
            and i.pris_at > s.maj_at)
     limit 50`;

  let pris = 0;
  for (const c of candidats) {
    await prendreInstantane(sql, c.crise_id, c.tableau_id, "periodique",
      { acteurId: null, origine: "automatique" }).then(() => { pris += 1; })
      .catch(() => undefined);
  }
  return pris;
}
Loading