Commit 5c82369d authored by Kourser's avatar Kourser
Browse files

Thème, signaux de chat, empreintes, import CSV, indicateurs honnêtes

Dix points signalés à l'usage. Aucun n'était petit ; deux étaient des défauts
de fond que la liste a mis au jour.

Thème
  Trois états dans la barre d'état, et « auto » est le défaut : il ne marque
  rien, la feuille de style suit alors le système — y compris quand il bascule
  au coucher du soleil, ce que l'application écoute. Le canevas de dessin suit
  le thème *choisi* et non celui du système, sans quoi le tableau restait clair
  sous une coque forcée en sombre.

Chat
  Trois canaux, parce qu'aucun ne suffit seul : pastille chiffrée pour qui
  regarde l'écran, compteur dans le titre de l'onglet pour qui travaille
  ailleurs, son pour qui ne regarde pas. Un nombre plutôt qu'un point — savoir
  qu'il y a « quelque chose » et savoir qu'il y a trente messages n'appelle pas
  la même réaction.

  Le son est synthétisé, pas téléchargé : rien à charger au moment où le réseau
  est mauvais, c'est-à-dire quand le message compte. Ni ses propres messages,
  ni le salon qu'on a sous les yeux quand la fenêtre est au premier plan. Et
  une coupure, parce qu'une salle à huit personnes qui sonne à chaque phrase
  devient un bruit qu'on apprend à ignorer.

Documents
  MD5 en plus de SHA-256, calculés dans la même lecture — un dépôt de 400 Mo
  relu deux fois coûterait le double au pire moment. SHA-256 fait foi ; MD5
  n'a plus de valeur probante mais c'est ce que réclament les outils d'analyse
  et les bases de menaces qu'on interroge un jour de crise. Repliés dans la
  liste, copiables d'un geste : on les lit rarement et on les transmet souvent.

  « Consigner » produisait une entrée par clic — dix clics, dix entrées
  identiques dans le registre censé faire foi. La seconde tentative rend
  désormais la séquence de la première, le verrou est dans la transaction, et
  le bouton devient une pastille « consigné ».

  Les dossiers se réorganisent : un document se range ailleurs, avec les
  dossiers existants proposés plutôt qu'une orthographe à deviner — deux
  dossiers « Analyse » et « analyses » valent moins que pas de dossier. Le
  déplacement passe au journal comme tout le reste : au rejeu, on doit
  retrouver où était une pièce le jour où quelqu'un a dit ne pas la trouver.

Annuaire et tiers : l'import existait déjà, l'interface l'ignorait
  Même situation que les pièces jointes. Le serveur importait du CSV depuis le
  lot L2, avec ses synonymes d'en-têtes et son refus en bloc — un choix
  délibéré et documenté que j'ai respecté plutôt que de le refaire. Ce qui
  manquait était l'écran : choisir un fichier, voir les colonnes reconnues,
  et surtout lire les lignes fautives avec leur numéro pour les corriger dans
  le tableur d'où le fichier vient.

  Ces messages sortaient en anglais — « String must contain at least 2
  character(s) » au milieu d'un import d'annuaire. Zod parle français
  désormais, une fois pour tout le produit.

Le vert vacant : le défaut que la question a révélé
  « Contrats en cours de validité : aucune échéance dans les trois mois » et
  « Chaque rôle de crise a un suppléant » passaient au vert sur un dossier
  vide. Aucun tiers, donc aucun contrat qui expire. Aucun rôle, donc aucun rôle
  sans relève. La question posée — « c'est validé, mais ça se configure
  comment ? » — était la bonne, et la réponse n'était pas dans la
  documentation : c'était un indicateur qui mentait.

  Un vert vacant se lit exactement comme un vert acquis, et c'est le pire
  mensonge qu'un indicateur de préparation puisse faire, puisqu'il rassure
  précisément ceux qui n'ont rien préparé. D'où « sans objet » : un quatrième
  état qui ne compte ni pour ni contre dans la note, dit ce qui n'est pas
  renseigné, et nomme quand même le geste. Et chaque point porte l'onglet où il
  se règle, avec un bouton pour y aller — la question ne doit jamais rester
  sans réponse.

517 → 532 garanties. Éprouvé au navigateur : les trois thèmes basculent, un
message envoyé par un second membre fait « (1) Kastell » et une pastille qui
s'éteint à l'ouverture, trois fiches entrent par CSV, un fichier fautif est
refusé avec « ligne 2 — nom : au moins 2 caractères », et les deux indicateurs
affichent « sans objet » avec leur bouton vers le bon onglet.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 7fca94f3
Loading
Loading
Loading
Loading
+12 −0
Original line number Diff line number Diff line
@@ -411,6 +411,18 @@ domaine résolvable. En production, activez `tls_port: 443` dans
pare-feux laissent passer, et souvent le seul chemin depuis l'hôtel où la
cellule s'est repliée.

## Thème et signaux

Trois états dans la barre d'état — **auto, clair, sombre**. « Auto » est le
défaut et ne marque rien : la feuille de style suit alors le réglage du
système, y compris quand il bascule au coucher du soleil.

Un message arrivé dans un salon qu'on ne regarde pas se signale trois fois :
**pastille chiffrée** dans le rail, **compteur dans le titre de l'onglet**, et
un **son de synthèse** — aucun fichier à télécharger au moment où le réseau est
mauvais, c'est-à-dire quand le message compte. Ni ses propres messages, ni le
salon qu'on a sous les yeux. Le son se coupe, les deux autres restent.

## Appareils mobiles

Le serveur sait déjà accueillir un téléphone — l'application, elle, reste à
+27 −0
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- Documents : seconde empreinte, consignation unique, dossiers
-- ═══════════════════════════════════════════════════════════════════════════

-- MD5 en plus de SHA-256.
--
-- SHA-256 fait foi : c'est lui qui est chaîné au journal et qui prouve qu'un
-- fichier restitué est bien celui qui fut déposé. MD5 n'a plus de valeur
-- probante depuis longtemps — mais c'est ce que rendent la plupart des outils
-- d'analyse, des bases de menaces et des interlocuteurs qu'on appelle un jour
-- de crise. Le donner évite de faire recalculer une empreinte à quelqu'un qui
-- travaille dans l'urgence, avec une pièce peut-être infectée.
alter table document_version add column empreinte_md5 bytea;

comment on column document_version.empreinte_md5 is
  'MD5 pour l''échange avec les outils tiers. Sans valeur probante : c''est '
  'la colonne empreinte, en SHA-256, qui fait foi.';

-- Consignation unique.
--
-- Le bouton pouvait être cliqué dix fois, et produisait dix entrées identiques
-- dans la main courante — ce qui salit précisément le registre censé faire foi.
alter table document add column consigne_seq bigint;

comment on column document.consigne_seq is
  'Séquence de l''entrée de main courante qui a versé ce document, s''il l''a '
  'été. Un document ne se consigne qu''une fois.';
+71 −5
Original line number Diff line number Diff line
@@ -36,6 +36,8 @@ export interface Document {
  cree_at: Date;
  supprime_at: Date | null;
  message_id: string | null;
  /** Séquence de l'entrée de main courante, si le document y a été versé. */
  consigne_seq: string | null;
}

export interface Version {
@@ -46,6 +48,8 @@ export interface Version {
  type_mime: string;
  taille: string;
  empreinte: Buffer;
  /** MD5, pour l'échange avec les outils tiers. C'est SHA-256 qui fait foi. */
  empreinte_md5: Buffer | null;
  depose_par: string | null;
  depose_at: Date;
  seq: string;
@@ -209,9 +213,13 @@ export async function acheverDepot(

  await stockage().acheverDepot(d.cle_objet, d.televersement_id, d.parties);

  // Relecture unique, dérivée en trois : empreinte, analyse, extraction.
  // Relecture unique, dérivée en quatre : deux empreintes, analyse, extraction.
  // Le fichier n'est lu qu'une fois quelle que soit sa taille — un dépôt de
  // 400 Mo relu deux fois coûterait le double au moment où l'on a le moins de
  // marge.
  const source = await stockage().lire(d.cle_objet);
  const hacheur = createHash("sha256");
  const hacheurMd5 = createHash("md5");
  const versAntivirus = new PassThrough();
  const morceaux: Buffer[] = [];
  let extraits = 0;
@@ -220,6 +228,7 @@ export async function acheverDepot(
  await pipeline(source, async function* (flux) {
    for await (const bloc of flux as AsyncIterable<Buffer>) {
      hacheur.update(bloc);
      hacheurMd5.update(bloc);
      if (extraits < TAILLE_EXTRACTION_MAX) {
        morceaux.push(bloc);
        extraits += bloc.length;
@@ -229,6 +238,7 @@ export async function acheverDepot(
  }, versAntivirus);
  const resultat = await analyse;
  const empreinte = hacheur.digest("hex");
  const empreinteMd5 = hacheurMd5.digest("hex");
  const texte = extraireTexte(d.type_mime, Buffer.concat(morceaux));

  const version = (await sql.begin(async (tx) => {
@@ -243,6 +253,7 @@ export async function acheverDepot(
        document_id: d.document_id, version: numero, titre: d.titre, dossier: d.dossier,
        nom_fichier: d.nom_fichier, type_mime: d.type_mime,
        taille: Number(d.taille_declaree), cle_objet: d.cle_objet, empreinte,
        empreinte_md5: empreinteMd5,
        message_id: d.message_id,
      },
      ctx,
@@ -280,6 +291,7 @@ export async function deposerDirect(

  const resultat = await antivirus().analyser(Readable.from([contenu]));
  const empreinte = createHash("sha256").update(contenu).digest("hex");
  const empreinteMd5 = createHash("md5").update(contenu).digest("hex");
  const texte = extraireTexte(o.typeMime, contenu);

  const version = (await sql.begin(async (tx) => {
@@ -293,7 +305,7 @@ export async function deposerDirect(
      charge: {
        document_id: documentId, version: numero, titre: o.titre, dossier: o.dossier ?? "",
        nom_fichier: o.nomFichier, type_mime: o.typeMime, taille: contenu.length,
        cle_objet: cle, empreinte, message_id: o.messageId ?? null,
        cle_objet: cle, empreinte, empreinte_md5: empreinteMd5, message_id: o.messageId ?? null,
      },
      ctx,
    });
@@ -541,15 +553,25 @@ export async function partages(sql: Sql, criseId: string, documentId: string) {
}

/** EF-909 — consigner un document en main courante depuis sa fiche. */
/**
 * Verser un document à la main courante — une seule fois.
 *
 * Le bouton pouvait être actionné dix fois et produisait dix entrées
 * identiques, ce qui salit précisément le registre censé faire foi. La
 * deuxième tentative rend la séquence de la première : c'est idempotent, donc
 * un double-clic ou un réseau qui rejoue une requête ne fait aucun mal.
 */
export async function consigner(
  sql: Sql, criseId: string, documentId: string,
  genre: "fait" | "communication" | "decision", ctx: Contexte,
): Promise<number> {
): Promise<{ seq: number; deja: boolean }> {
  const [d] = await sql<{ titre: string; cellule_id: string | null;
    version_courante: number }[]>`
    select titre, cellule_id, version_courante from document
    version_courante: number; consigne_seq: string | null }[]>`
    select titre, cellule_id, version_courante, consigne_seq::text as consigne_seq
      from document
     where crise_id = ${criseId} and id = ${documentId} and supprime_at is null`;
  if (!d) throw new Error("Document inconnu.");
  if (d.consigne_seq !== null) return { seq: Number(d.consigne_seq), deja: true };

  return (await sql.begin(async (tx) => {
    const ev = await enregistrer(tx, {
@@ -562,6 +584,50 @@ export async function consigner(
      },
      ctx,
    });
    // Le verrou est ici, dans la même transaction que l'écriture : deux clics
    // simultanés ne peuvent pas produire deux entrées.
    const marque = await tx`
      update document set consigne_seq = ${ev.seq}
       where crise_id = ${criseId} and id = ${documentId} and consigne_seq is null`;
    if (marque.count === 0) throw new Error("Ce document vient d'être consigné.");
    return { seq: ev.seq, deja: false };
  })) as { seq: number; deja: boolean };
}

/**
 * Ranger un document dans un autre dossier (EF-903).
 *
 * Le classement d'une crise se réorganise en la vivant : au troisième jour on
 * ne range plus comme au premier. Le mouvement passe au journal comme tout le
 * reste — au rejeu, on doit retrouver où était une pièce le jour où quelqu'un
 * a dit ne pas l'avoir trouvée.
 */
export async function ranger(
  sql: Sql, criseId: string, documentId: string, dossier: string, ctx: Contexte,
): Promise<number> {
  const [d] = await sql<{ dossier: string; cellule_id: string | null }[]>`
    select dossier, cellule_id from document
     where crise_id = ${criseId} and id = ${documentId} and supprime_at is null`;
  if (!d) throw new Error("Document inconnu.");
  const cible = dossier.trim();
  if (cible === d.dossier) return 0;

  return (await sql.begin(async (tx) => {
    const ev = await enregistrer(tx, {
      criseId, celluleId: d.cellule_id, type: "document.range",
      charge: { document_id: documentId, dossier: cible, dossier_precedent: d.dossier },
      ctx,
    });
    return ev.seq;
  })) as number;
}

/** Les dossiers existants, pour proposer plutôt que faire deviner. */
export async function dossiers(sql: Sql, criseId: string): Promise<
  { dossier: string; documents: number }[]
> {
  return sql<{ dossier: string; documents: number }[]>`
    select dossier, count(*)::int as documents from document
     where crise_id = ${criseId} and supprime_at is null
     group by dossier order by dossier`;
}
+4 −0
Original line number Diff line number Diff line
@@ -19,6 +19,10 @@ import { definirMedia, media, mediaConfigure, mediaLiveKit } from "./adaptateurs
import { definirPoussee, pousseeRelais } from "./adaptateurs/poussee.js";
import { demarrerEntretien } from "./entretien.js";
import { construireServeur } from "./serveur.js";
import { installerMessagesFrancais } from "@kastell/shared";

// Les messages de validation remontent jusqu'à l'écran : ils parlent français.
installerMessagesFrancais();

const env = chargerEnv();
const sql = ouvrirBase(env.DATABASE_URL);
+12 −3
Original line number Diff line number Diff line
@@ -249,11 +249,13 @@ const projecteurs: Partial<Record<TypeEvenement, Projecteur>> = {
    }
    await tx`
      insert into document_version (crise_id, document_id, version, cle_objet, nom_fichier,
                                    type_mime, taille, empreinte, depose_par, depose_at,
                                    seq, restaure_de)
                                    type_mime, taille, empreinte, empreinte_md5,
                                    depose_par, depose_at, seq, restaure_de)
      values (${ev.crise_id}, ${ch.document_id}, ${ch.version}, ${ch.cle_objet},
              ${ch.nom_fichier}, ${ch.type_mime}, ${ch.taille},
              ${Buffer.from(ch.empreinte, "hex")}, ${ev.acteur_id}, ${ev.occurred_at},
              ${Buffer.from(ch.empreinte, "hex")},
              ${ch.empreinte_md5 ? Buffer.from(ch.empreinte_md5, "hex") : null},
              ${ev.acteur_id}, ${ev.occurred_at},
              ${ev.seq}, ${ch.restaure_de ?? null})`;
    await tx`
      update document set version_courante = ${ch.version}, titre = ${ch.titre}
@@ -268,6 +270,13 @@ const projecteurs: Partial<Record<TypeEvenement, Projecteur>> = {
         and version = ${ch.version}`;
  },

  "document.range": async (tx, ev) => {
    const ch = relire("document.range", ev.charge);
    await tx`
      update document set dossier = ${ch.dossier}
       where crise_id = ${ev.crise_id} and id = ${ch.document_id}`;
  },

  "document.supprime": async (tx, ev) => {
    const ch = relire("document.supprime", ev.charge);
    await tx`
Loading