Commit 1ef21eba authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Un modèle qui ouvre une page blanche n'est pas un modèle

Signalé à l'écran : les modèles de tableau n'en sont pas, ce sont des tableaux
vides. C'était exact, et la cause était à un endroit qu'on ne soupçonne pas.

Les zones existaient — coordonnées, dimensions, titres, tout était écrit dans les
modèles. La scène était enregistrée comme « { elements: [], zones } », et le
canevas ne dessine que « elements ». Les zones étaient donc des données que
personne ne traçait.

Le plus gênant est ailleurs : une garantie verte affirmait le contraire. Elle
vérifiait « carto.zones.length === 4 », c'est-à-dire la longueur d'un tableau
écrit dans le code. Elle serait restée verte quoi qu'affiche le canevas. Elle lit
maintenant la scène enregistrée, et compte les cadres et les titres.

J'ai écarté le type « frame » d'Excalidraw, qui conviendrait mieux — le nom est
rendu hors du cadre et les éléments déposés le suivent. Je n'ai pas de navigateur
pour l'éprouver ici, et un cadre mal formé rendrait la page blanche, c'est-à-dire
le défaut d'origine. Le rectangle en tirets s'affiche à coup sûr. C'est une
amélioration à faire le jour où on peut la voir.

── La bibliothèque de formes

Demandée avec un doute — « ce n'est peut-être pas possible ». C'est possible, et
c'était même à moitié là : le canevas est Excalidraw, qui sait tenir une
bibliothèque, et la fonction n'était pas branchée. Ce qu'on ajoutait disparaissait
au rechargement, puisque rien ne le conservait.

Elle est conservée au niveau de l'organisation, et c'est le choix qui compte. Une
bibliothèque de formes est une préparation, pas une préférence : la cartographie
du SI se dessine avec les mêmes symboles par tout le monde, ou elle ne se relit
pas. Conséquence assumée et dite dans l'API — ce qu'un membre ajoute vaut pour
toute l'organisation, et la crise suivante l'a. Un observateur n'y touche pas.

Elle n'est pas versionnée avec le dossier, à la différence des registres :
dessiner un serveur avec la forme d'aujourd'hui dans un tableau ancien ne change
rien à ce que la cellule savait. Versionner coûterait sans rien garantir.

Ce que je n'ai pas fait : livrer un jeu de symboles. Un jeu inventé ici décrirait
un système d'information que je n'ai pas vu, et une bibliothèque approximative se
retourne contre celui qui s'y fie. Les bibliothèques publiques s'importent depuis
le canevas, et sont dès lors conservées pour l'organisation. C'est le mécanisme
qui manquait, pas le contenu.

Huit garanties : les zones réellement dessinées, le tableau libre qui reste
libre, la bibliothèque qui survit au rechargement, que la crise suivante
retrouve, et dont le plafond refuse sans rien écraser.

Co-Authored-By: Claude (RCA)
parent e1fd2e6d
Loading
Loading
Loading
Loading
+36 −0
Changes for apps/api/src/db/migrations/0037_bibliotheque_tableau.sql: 36 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- Bibliothèque de formes, par organisation (EF-1002a).
--
-- Le canevas est Excalidraw, qui sait tenir une bibliothèque : un jeu de formes
-- réutilisables — un serveur, un site, un pare-feu, une personne — qu'on dépose
-- au lieu de les redessiner. La fonction existait dans le composant et n'était
-- pas branchée : ce qu'on ajoutait disparaissait au rechargement, puisque rien
-- ne le conservait.
--
-- ── Pourquoi par organisation, et non par compte ───────────────────────────
--
-- Une bibliothèque de formes est une préparation, pas une préférence. Le sens
-- de ce produit est que ce qui sert le jour J a été constitué avant, et par
-- l'organisation : la cartographie du SI se dessine avec les mêmes symboles par
-- tout le monde, ou elle ne se relit pas.
--
-- La conséquence est assumée : ce qu'un membre ajoute ou retire vaut pour toute
-- l'organisation. C'est le même partage que le tableau lui-même, où le trait de
-- l'un s'affiche chez l'autre.
--
-- ── Pourquoi ce n'est pas versionné avec le dossier ────────────────────────
--
-- Les registres du dossier sont versionnés parce qu'une crise doit se relire
-- avec ce que la cellule avait sous les yeux. Un symbole n'est pas de cet
-- ordre : dessiner un serveur avec la forme d'aujourd'hui dans un tableau
-- ancien ne change rien à ce que la cellule savait. Versionner ici coûterait
-- sans rien garantir.
create table bibliotheque_tableau (
  organisation_id uuid        primary key
                    references organisation(id) on delete cascade,
  items           jsonb       not null default '[]'::jsonb,
  maj_at          timestamptz not null default now(),
  maj_par         uuid        references compte(id) on delete set null
);

comment on table bibliotheque_tableau is
  'Formes réutilisables du tableau blanc, communes à l''organisation.';
+62 −0
Changes for apps/api/src/domaine/bibliotheque.ts: 62 added lines, 0 removed lines.
Original line number Diff line number Diff line
import type { Sql } from "../db/client.js";

/**
 * La bibliothèque de formes du tableau blanc (EF-1002a).
 *
 * Le canevas sait tenir une bibliothèque — un jeu de formes réutilisables qu'on
 * dépose au lieu de les redessiner. La fonction existait dans le composant et
 * n'était pas branchée : ce qu'on ajoutait disparaissait au rechargement.
 *
 * Elle est commune à l'organisation, parce qu'une bibliothèque de formes est
 * une préparation et non une préférence : la cartographie du SI se dessine avec
 * les mêmes symboles par tout le monde, ou elle ne se relit pas.
 *
 * Ce que Kastell ne fait pas : livrer un jeu de symboles. Un jeu inventé ici
 * décrirait un système d'information que nous n'avons pas vu, et une
 * bibliothèque approximative se retourne contre celui qui s'y fie. Les
 * bibliothèques publiques d'Excalidraw s'importent depuis le canevas, et celles
 * d'une organisation lui appartiennent.
 */

/** Assez pour un jeu de symboles complet ; pas de quoi verser un dossier entier. */
export const PLAFOND_OCTETS = 2 * 1024 * 1024;

export async function lire(sql: Sql, organisationId: string): Promise<unknown[]> {
  const [b] = await sql<{ items: unknown[] }[]>`
    select items from bibliotheque_tableau where organisation_id = ${organisationId}`;
  return b?.items ?? [];
}

/** Lue depuis une crise : le canevas ne connaît que celle-ci. */
export async function lirePourCrise(sql: Sql, criseId: string): Promise<unknown[]> {
  const [c] = await sql<{ organisation_id: string }[]>`
    select organisation_id from crise where id = ${criseId}`;
  return c ? lire(sql, c.organisation_id) : [];
}

export async function poser(
  sql: Sql, organisationId: string, items: unknown[], parCompteId: string | null,
): Promise<{ items: number }> {
  const taille = Buffer.byteLength(JSON.stringify(items), "utf8");
  if (taille > PLAFOND_OCTETS) {
    throw new Error(
      `Cette bibliothèque pèse ${Math.round(taille / 1024)} ko, au-delà des `
      + `${PLAFOND_OCTETS / 1024} ko admis. Une bibliothèque de crise tient en quelques `
      + "dizaines de formes ; au-delà, on ne la parcourt plus, on la subit.");
  }
  await sql`
    insert into bibliotheque_tableau (organisation_id, items, maj_par)
    values (${organisationId}, ${sql.json(items as never)}, ${parCompteId})
    on conflict (organisation_id) do update
      set items = excluded.items, maj_at = now(), maj_par = excluded.maj_par`;
  return { items: items.length };
}

export async function poserDepuisCrise(
  sql: Sql, criseId: string, items: unknown[], parCompteId: string | null,
): Promise<{ items: number }> {
  const [c] = await sql<{ organisation_id: string }[]>`
    select organisation_id from crise where id = ${criseId}`;
  if (!c) throw new Error("Crise inconnue.");
  return poser(sql, c.organisation_id, items, parCompteId);
}
+9 −4
Changes for apps/api/src/domaine/tableaux.ts: 9 added lines, 4 removed lines.
Original line number Diff line number Diff line
import { createHash, randomUUID } from "node:crypto";
import { modele, type ModeleTableau } from "@kastell/shared";
import { elementsDuModele, 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";
@@ -48,12 +48,17 @@ export async function creer(
      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.
    // Les zones du modèle sont posées d'emblée, et **dessinées** : elles
    // étaient jusqu'ici enregistrées à côté des éléments, où le canevas ne les
    // lit pas. Choisir « Chronologie d'incident » ouvrait donc une page
    // blanche, ce que le modèle promettait précisément d'éviter.
    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})`;
              ${tx.json({
                elements: elementsDuModele(m, tableauId.slice(0, 8)),
                zones: m.zones,
              } as never)}, ${ctx.acteurId})`;
  });
  return { tableauId, zones: m.zones };
}
+36 −0
Changes for apps/api/src/routes/tableaux.ts: 36 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@ import { z } from "zod";
import { MODELES, MODELES_TABLEAU } from "@kastell/shared";
import type { Sql } from "../db/client.js";
import * as tableaux from "../domaine/tableaux.js";
import * as bibliotheque from "../domaine/bibliotheque.js";
import { requerirAccesCrise } from "./garde.js";

/** Tableaux blancs (§6.10). */
@@ -18,6 +19,41 @@ export function enregistrerRoutesTableaux(app: FastifyInstance, sql: Sql): void

  app.get("/tableaux/modeles", async () => ({ modeles: MODELES_TABLEAU }));

  /**
   * La bibliothèque de formes (EF-1002a).
   *
   * Servie depuis la crise, car le canevas ne connaît que celle-ci ; conservée
   * au niveau de l'organisation, car une bibliothèque de formes est une
   * préparation et non une préférence.
   */
  app.get("/crises/:criseId/bibliotheque", acces, async (req) => ({
    items: await bibliotheque.lirePourCrise(sql, criseId(req)),
  }));

  app.put("/crises/:criseId/bibliotheque", acces, async (req, reply) => {
    if (req.role === "observateur") {
      return reply.code(403).send({
        code: "role_insuffisant",
        message: "Un observateur ne modifie pas la bibliothèque de l'organisation.",
      });
    }
    const c = z.object({ items: z.array(z.unknown()).max(500) }).parse(req.body);
    try {
      const r = await bibliotheque.poserDepuisCrise(sql, criseId(req), c.items,
        req.auth!.compte.id);
      return reply.send({
        ...r,
        note: "La bibliothèque est commune à l'organisation : ce que vous ajoutez sert "
          + "à toute la cellule, et à la prochaine crise.",
      });
    } catch (e) {
      return reply.code(400).send({
        code: "bibliotheque_refusee",
        message: e instanceof Error ? e.message : "Bibliothèque refusée.",
      });
    }
  });

  app.get("/crises/:criseId/tableaux", acces, async (req) => {
    const { celluleId } = z.object({ celluleId: z.string().uuid().optional() }).parse(req.query);
    return { tableaux: await tableaux.lister(sql, criseId(req), celluleId) };
+57 −2
Changes for apps/api/src/verification/tableaux.ts: 57 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -4,6 +4,7 @@ import type { Sql } from "../db/client.js";
import * as comptes from "../auth/comptes.js";
import { ouvrirCrise } from "../domaine/crise.js";
import * as tableaux from "../domaine/tableaux.js";
import * as bibliotheque from "../domaine/bibliotheque.js";
import { verifierEnBase } from "../noyau/integrite.js";
import { MODELES_TABLEAU } from "@kastell/shared";
import type { Rapport } from "./rapport.js";
@@ -34,12 +35,66 @@ export async function verifierTableaux(

  const carto = await tableaux.creer(sql, crise.criseId, cellule,
    "Cartographie de l'incident", "cartographie", ctx);
  r.verifie("EF-1003", "le modèle pose ses zones d'emblée, la page n'est pas blanche",
    carto.zones.length === 4);
  // Ce que la cellule a sous les yeux, et non la définition du modèle.
  //
  // La garantie d'origine vérifiait « carto.zones.length === 4 », c'est-à-dire
  // la longueur d'un tableau écrit dans le code. Elle serait restée verte quel
  // que soit ce que le canevas affiche — et il affichait une page blanche, les
  // zones étant enregistrées à côté des éléments, là où le dessin ne les lit
  // pas.
  const sceneCarto = await tableaux.scene(sql, crise.criseId, carto.tableauId);
  const dessines = (sceneCarto?.scene as { elements?: Record<string, unknown>[] } | null)
    ?.elements ?? [];
  r.verifie("EF-1003", "le modèle pose ses zones d'emblée, dessinées et non seulement décrites",
    dessines.filter((e) => e["type"] === "rectangle").length === 4,
    `${dessines.length} élément(s), dont `
      + `${dessines.filter((e) => e["type"] === "rectangle").length} cadre(s)`);
  r.verifie("EF-1003", "et chaque zone porte son titre à l'écran",
    dessines.filter((e) => e["type"] === "text").length === 4
      && dessines.some((e) => e["text"] === "Compromis — confirmé"),
    JSON.stringify(dessines.filter((e) => e["type"] === "text").map((e) => e["text"])));
  r.verifie("EF-1003", "un tableau libre reste libre : aucune zone imposée",
    ((await tableaux.scene(sql, crise.criseId,
      (await tableaux.creer(sql, crise.criseId, cellule, "Brouillon", "libre", ctx)).tableauId))
      ?.scene as { elements?: unknown[] } | null)?.elements?.length === 0);
  r.verifie("EF-1001", "plusieurs tableaux coexistent par cellule",
    (await tableaux.creer(sql, crise.criseId, cellule, "Arbre d'attaque", "arbre_attaque", ctx))
      .tableauId !== carto.tableauId);

  // ── La bibliothèque de formes (EF-1002a) ─────────────────────────────────
  //
  // Le canevas sait en tenir une, et elle n'était pas branchée : ce qu'on
  // ajoutait disparaissait au rechargement.
  r.titre("Bibliothèque de formes");

  r.verifie("EF-1002a", "une organisation neuve n'en a pas, et cela se dit sans erreur",
    (await bibliotheque.lirePourCrise(sql, crise.criseId)).length === 0);

  const formes = [
    { status: "published", id: "serveur", elements: [{ type: "rectangle", id: "a" }] },
    { status: "published", id: "site", elements: [{ type: "ellipse", id: "b" }] },
  ];
  await bibliotheque.poserDepuisCrise(sql, crise.criseId, formes, compteId);
  r.verifie("EF-1002a", "ce qu'on y ajoute survit au rechargement",
    (await bibliotheque.lirePourCrise(sql, crise.criseId)).length === 2);

  // Commune à l'organisation : une autre crise de la même organisation la
  // trouve, ce qui est le propos — la préparation sert la fois suivante.
  const autre = await ouvrirCrise(sql, {
    organisationId, intitule: "Seconde crise — bibliothèque", type: "autre",
    gravite: "mineure",
  }, ctx);
  r.verifie("EF-1002a", "et la crise suivante la trouve : c'est une préparation, "
    + "pas une préférence",
    (await bibliotheque.lirePourCrise(sql, autre.criseId)).length === 2);

  await r.refuse("EF-1002a", "une bibliothèque démesurée est refusée — au-delà, on ne la "
    + "parcourt plus, on la subit",
    () => bibliotheque.poserDepuisCrise(sql, crise.criseId,
      [{ bourrage: "x".repeat(bibliotheque.PLAFOND_OCTETS + 1000) }], compteId));
  r.verifie("EF-1002a", "et le refus n'a rien écrasé",
    (await bibliotheque.lirePourCrise(sql, crise.criseId)).length === 2);

  // ── L'état de travail n'est pas journalisé ───────────────────────────────
  r.titre("Scène et instantanés");
  const evenementsAvant = Number((await sql<{ n: string }[]>`
Loading