Commit 8acc876b authored by Kourser's avatar Kourser
Browse files

Dossier de préparation : la moitié du produit qui sert avant la crise

Lot L2. Annuaire interne, registre des tiers activables, fiches réflexes,
versionnement, indicateur, campagnes de vérification, export papier.

Versionnement à temps de validité
  Rien n'est écrasé. Modifier une fiche pose une borne haute à la ligne en
  vigueur et en ouvre une nouvelle ; lire « à la version 47 » revient à
  sélectionner les lignes dont l'intervalle de validité contient 47. Même
  philosophie que le journal de crise, et pour la même raison : au
  déclenchement une crise fige un numéro de version, et des mois plus tard on
  doit pouvoir montrer l'annuaire et les procédures que la cellule avait
  réellement sous les yeux — pas ceux d'aujourd'hui.

  L'axe est un entier plutôt qu'un horodatage : « version 47 » se lit et se
  cite, et la comparaison ne dépend d'aucune horloge.

Les tiers activables
  Un contrat d'assurance sans numéro d'urgence ne sert à rien un vendredi soir.
  Chaque tiers porte sa procédure en clair : qui est habilité à appeler, quel
  numéro, quoi préparer avant de composer, quel délai attendre, et surtout que
  faire si personne ne décroche — c'est là que se perdent les heures.

  Depuis une crise, activer un tiers lit sa fiche dans la version gelée, écrit
  l'événement qui porte l'heure exacte et verse la ligne en main courante. La
  réponse — ou son absence, qui exige de dire ce qu'on a fait à la place —
  donne le délai d'activation que le dossier de clôture restituera.

L'indicateur ne note pas pour noter
  Chaque point manquant nomme le geste qui le comble. Un tableau de bord qui
  affiche 62 % sans dire quoi faire ne fait que culpabiliser. Le point le plus
  lourd n'est pas la complétude mais les coordonnées personnelles : sans elles,
  les gens sont injoignables le jour où la messagerie d'entreprise est hors
  service, c'est-à-dire le jour J.

L'export imprimable
  Le dernier recours, et l'un des plus importants : la feuille rangée dans un
  classeur qui reste lisible quand tout le reste a été chiffré — y compris
  Kastell si le réseau est coupé. Mis en page pour le papier, il porte les
  numéros d'urgence en évidence et signale les procédures incomplètes plutôt
  que de les taire. Rendu en HTML imprimable plutôt qu'en PDF serveur : pas de
  moteur de rendu dans l'image, et le PDF signé viendra avec le dossier de
  crise, où la valeur probante l'exige.

Import tout ou rien
  L'annuaire existe déjà dans un tableur quelque part ; exiger une ressaisie de
  quarante fiches garantit que le dossier ne sera jamais constitué. L'import
  tolère les séparateurs et les en-têtes usuels, mais refuse en bloc si une
  ligne est invalide : un annuaire à moitié juste est pire qu'un annuaire
  absent, parce qu'on croira l'avoir.

Hors périmètre, et pourquoi
  Le kit documentaire (EF-208) suit le stockage objet au lot L5 ; la
  consultation hors ligne (EF-212) suit l'application installable au lot L10.
  Marqués comme tels dans le cahier des charges. D'ici là l'export papier tient
  ce rôle, et le tient mieux : il survit à une panne d'appareil.

Vérification
  `pnpm verif` passe de 115 à 170 contrôles.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent e097ba50
Loading
Loading
Loading
Loading
+37 −2
Original line number Diff line number Diff line
@@ -14,7 +14,7 @@ l'enregistre intégralement — de manière à pouvoir la rembobiner.
|-----|---------|------|
| **L0** | Socle, conteneurisation, authentification, console root | 🟢 fait |
| **L1** | Noyau de persistance, main courante, rembobinage | 🟡 en cours — noyau et lecture à date faits, filtres et export PDF à venir |
| L2 | Dossier de préparation (annuaire, tiers, fiches réflexes) |  |
| **L2** | Dossier de préparation (annuaire, tiers, fiches réflexes) | 🟢 fait — sauf le kit documentaire, qui suit le stockage objet (L5) |
| L3+ | Décisions, chat, GED, visio, tableau blanc, mobilisation | ⚪ |

Ce qui fonctionne aujourd'hui : le journal inviolable et son chaînage
@@ -23,7 +23,7 @@ par rejeu, la main courante avec rectification sans effacement, le compte root
avec son journal d'administration et le bris de glace, l'authentification
complète (mot de passe, lien à usage unique, TOTP, codes de secours, sessions,
invitations, multi-organisation), la console d'administration avec ses quotas,
et la pile Docker.
le dossier de préparation versionné avec son export papier, et la pile Docker.

---

@@ -83,6 +83,14 @@ apps/api/src/
    sessions.ts      deux durées : inactivité 12 h, absolue 7 jours
    comptes.ts       inscription, connexion, second facteur, invitations
    limites.ts       limitation de débit, en base pour survivre au redémarrage
  preparation/    la moitié amont du produit
    dossier.ts       versionnement à temps de validité
    annuaire.ts      personnes, suppléants, astreintes
    tiers.ts         registre des tiers et activation depuis une crise
    fiches.ts        fiches réflexes par type de crise et par rôle
    indicateur.ts    ce qui manque, et le geste qui le comble
    campagne.ts      vérification semestrielle des coordonnées
    export.ts        tirage papier et tableur
  administration/ console root — administrer sans lire
    console.ts       organisations, comptes, droit root
    etat.ts          état d'instance, intégrité globale, bris de glace
@@ -133,6 +141,33 @@ promesse « cloner et composer suffit » est une exigence produit. Le format
stocke ses paramètres, une migration reste possible sans invalider les
empreintes.

## Dossier de préparation

La moitié du produit sert **avant** la crise. Annuaire avec coordonnées
personnelles, registre des tiers activables avec leur procédure d'activation en
clair, fiches réflexes par type de crise.

**Rien n'est écrasé.** Modifier une fiche ferme la ligne en vigueur et en ouvre
une nouvelle : le dossier se lit « à la version 47 » comme le journal se lit à
une séquence. Au déclenchement, la crise fige un numéro de version — des mois
plus tard on peut montrer l'annuaire et les procédures que la cellule avait
réellement sous les yeux, pas ceux d'aujourd'hui.

L'**export imprimable** est le dernier recours du produit, et l'un des plus
importants : la feuille rangée dans un classeur qui reste lisible quand tout le
reste a été chiffré — y compris Kastell si le réseau est coupé. Mis en page pour
le papier, il porte les numéros d'urgence en évidence et **signale les
procédures incomplètes plutôt que de les taire**.

L'**indicateur de préparation** ne note pas pour noter : chaque point manquant
nomme le geste qui le comble. Un tableau de bord qui affiche 62 % sans dire quoi
faire ne fait que culpabiliser.

Deux exigences attendent leur lot : le kit documentaire (EF-208) suit le
stockage objet en L5, la consultation hors ligne (EF-212) suit l'application
installable en L10. D'ici là, l'export papier tient ce rôle — et le tient mieux,
puisqu'il survit à une panne d'appareil.

## Administration de l'instance

Un compte **root** administre l'instance : organisations, comptes, quotas,
+185 −0
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- 0006 — Dossier de préparation (§6.2)
--
-- La moitié amont du produit. Ce qui est constitué à froid — annuaire, tiers
-- activables, fiches réflexes — et qui rend la conduite possible.
--
-- Versionnement à temps de validité : rien n'est écrasé. Une modification
-- ferme la ligne en vigueur et en ouvre une nouvelle. On peut donc établir ce
-- que la cellule avait sous les yeux au déclenchement d'une crise (EF-213,
-- EF-214) — condition d'un rembobinage fidèle, et même philosophie que le
-- journal de crise.
--
-- L'axe temporel est un entier plutôt qu'un horodatage : « version 47 » se lit
-- et se cite, et la comparaison ne dépend d'aucune horloge.
-- ═══════════════════════════════════════════════════════════════════════════

alter table organisation add column preparation_version integer not null default 0;

create table preparation_version (
  organisation_id uuid        not null references organisation(id) on delete cascade,
  version         integer     not null,
  cree_at         timestamptz not null default now(),
  auteur_id       uuid        references compte(id) on delete set null,
  resume          text        not null,
  primary key (organisation_id, version)
);

/**
 * Ouvre une version et décrit ce qu'elle change. À appeler une fois par
 * mutation, avant de fermer les lignes en vigueur : le verrou sur
 * l'organisation sérialise les modifications concurrentes du dossier.
 */
create or replace function preparation_nouvelle_version(
  p_org uuid, p_auteur uuid, p_resume text
) returns integer language plpgsql as $$
declare v integer;
begin
  update organisation set preparation_version = preparation_version + 1
   where id = p_org returning preparation_version into v;
  if v is null then
    raise exception 'préparation: organisation % inconnue', p_org
      using errcode = 'foreign_key_violation';
  end if;
  insert into preparation_version (organisation_id, version, auteur_id, resume)
  values (p_org, v, p_auteur, p_resume);
  return v;
end $$;

-- ── Annuaire interne (EF-202) ──────────────────────────────────────────────
create table personne (
  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,
  fonction        text,
  role_crise      text,
  cellule         text,
  -- Désigne l'identité stable d'une autre personne, pas une de ses versions.
  suppleant_id    uuid,
  compte_id       uuid    references compte(id) on delete set null,

  email_pro       text,
  email_perso     text,
  tel_pro         text,
  tel_perso       text,
  tel_astreinte   text,
  notes           text,
  verifie_at      timestamptz,
  primary key (id, depuis_version)
);
create index personne_courante_idx on personne (organisation_id) where jusqu_a_version is null;

-- ── Registre des tiers activables (EF-203 à EF-205) ────────────────────────
create table tiers (
  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,
  supprime        boolean not null default false,

  categorie       text    not null check (categorie in (
                    'reponse_incident','assureur','avocat','hebergeur','operateur',
                    'autorite','communication','expert_judiciaire','restauration','autre')),
  nom             text    not null,

  -- Éléments contractuels utiles à chaud (EF-204)
  reference_contrat         text,
  perimetre                 text,
  exclusions                text,
  delai_intervention_heures integer,
  plafond                   text,
  echeance_contrat          date,
  interlocuteur             text,
  interlocuteur_tel         text,
  interlocuteur_email       text,
  suppleant                 text,
  suppleant_tel             text,

  -- Procédure d'activation en clair (EF-205)
  qui_appelle               text,
  numero_urgence            text,
  informations_a_preparer   text,
  delai_attendu             text,
  si_pas_de_reponse         text,

  verifie_at      timestamptz,
  primary key (id, depuis_version)
);
create index tiers_courant_idx on tiers (organisation_id) where jusqu_a_version is null;

-- ── Fiches réflexes (EF-207) ───────────────────────────────────────────────
create table fiche_reflexe (
  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,

  titre           text    not null,
  types_crise     text[]  not null default '{}',
  roles           text[]  not null default '{}',
  contenu         text    not null,
  ordre           integer not null default 0,
  revue_at        timestamptz,
  primary key (id, depuis_version)
);
create index fiche_courante_idx on fiche_reflexe (organisation_id) where jusqu_a_version is null;

-- ── Vues du dossier en vigueur ─────────────────────────────────────────────
create view personne_courante as
  select * from personne where jusqu_a_version is null and not supprimee;
create view tiers_courant as
  select * from tiers where jusqu_a_version is null and not supprime;
create view fiche_courante as
  select * from fiche_reflexe where jusqu_a_version is null and not supprimee;

-- ── Indisponibilités et astreintes ─────────────────────────────────────────
-- Hors versionnement à dessein : une indisponibilité révolue n'a aucune valeur
-- probante, et geler l'agenda de chacun à chaque crise n'apporterait rien.
create table indisponibilite (
  id           uuid        primary key default gen_random_uuid(),
  personne_id  uuid        not null,
  genre        text        not null check (genre in ('astreinte','absence')),
  du           timestamptz not null,
  au           timestamptz not null,
  motif        text,
  constraint periode_coherente check (au > du)
);
create index indisponibilite_personne on indisponibilite (personne_id, du);

-- ── Campagnes de vérification (EF-210) ─────────────────────────────────────
alter table jeton_usage_unique drop constraint jeton_usage_unique_genre_check;
alter table jeton_usage_unique add constraint jeton_usage_unique_genre_check
  check (genre in ('verification_email','connexion','invitation','verification_preparation'));

create table campagne_verification (
  id              uuid        primary key default gen_random_uuid(),
  organisation_id uuid        not null references organisation(id) on delete cascade,
  lancee_at       timestamptz not null default now(),
  lancee_par      uuid        references compte(id) on delete set null,
  cloturee_at     timestamptz,
  echeance_at     timestamptz not null
);

create table verification_demande (
  id          uuid        primary key default gen_random_uuid(),
  campagne_id uuid        not null references campagne_verification(id) on delete cascade,
  personne_id uuid        not null,
  nom         text        not null,
  email       text        not null,
  envoye_at   timestamptz not null default now(),
  repondu_at  timestamptz,
  corrections jsonb
);
create index verification_campagne on verification_demande (campagne_id);

-- ── Gel au déclenchement (EF-214) ──────────────────────────────────────────
-- `crise.preparation_version` existe depuis la migration 0001 : elle prend ici
-- son sens. Une crise référence la version en vigueur à son ouverture ; les
-- mises à jour ultérieures n'altèrent pas ce que la cellule avait sous les yeux.
comment on column crise.preparation_version is
  'Version du dossier de préparation gelée au déclenchement (EF-214).';
+13 −4
Original line number Diff line number Diff line
@@ -15,19 +15,28 @@ export interface OuvertureCrise {
  type: string;
  gravite: Gravite;
  exercice?: boolean;
  preparationVersion?: number | null;
  cellules?: { code: string; libelle: string }[];
}

export interface CriseOuverte {
  criseId: string;
  cellules: { id: string; code: string }[];
  /** Version du dossier de préparation gelée au déclenchement (EF-214). */
  preparationVersion: number;
}

export async function ouvrirCrise(sql: Sql, o: OuvertureCrise, ctx: Contexte): Promise<CriseOuverte> {
  return (await sql.begin(async (tx) => {
    await exigerQuota(tx, o.organisationId, "crises_actives");

    // EF-214 — Gel du dossier de préparation. La crise retient le numéro de
    // version en vigueur à cet instant ; les modifications ultérieures de
    // l'annuaire ou des procédures n'altéreront pas ce que la cellule avait
    // sous les yeux, condition d'un rembobinage fidèle.
    const [preparation] = await tx<{ v: number }[]>`
      select preparation_version as v from organisation where id = ${o.organisationId} for update`;
    const preparationVersion = preparation?.v ?? 0;

    // La ligne `crise` est l'ancre de la chaîne : le déclencheur du journal en
    // a besoin pour attribuer la séquence 1. Elle précède donc l'événement qui
    // la décrit — seule exception au principe « rien avant le journal », et
@@ -35,7 +44,7 @@ export async function ouvrirCrise(sql: Sql, o: OuvertureCrise, ctx: Contexte): P
    const [crise] = await tx<{ id: string }[]>`
      insert into crise (organisation_id, intitule, type, gravite, exercice, preparation_version)
      values (${o.organisationId}, ${o.intitule}, ${o.type}, ${o.gravite},
              ${o.exercice ?? false}, ${o.preparationVersion ?? null})
              ${o.exercice ?? false}, ${preparationVersion})
      returning id`;
    if (!crise) throw new Error("Création de crise refusée.");

@@ -47,7 +56,7 @@ export async function ouvrirCrise(sql: Sql, o: OuvertureCrise, ctx: Contexte): P
        type: o.type,
        gravite: o.gravite,
        exercice: o.exercice ?? false,
        preparation_version: o.preparationVersion ?? null,
        preparation_version: preparationVersion,
      },
      ctx,
    });
@@ -64,7 +73,7 @@ export async function ouvrirCrise(sql: Sql, o: OuvertureCrise, ctx: Contexte): P
      cellules.push({ id: celluleId, code: c.code });
    }

    return { criseId: crise.id, cellules };
    return { criseId: crise.id, cellules, preparationVersion };
  })) as CriseOuverte;
}

+122 −0
Original line number Diff line number Diff line
import type { Partiel, SaisiePersonne } from "@kastell/shared";
import type { Sql } from "../db/client.js";
import * as dossier from "./dossier.js";

/** Annuaire interne (EF-202). */

export interface Personne {
  id: string;
  depuis_version: number;
  jusqu_a_version: number | null;
  nom: string;
  fonction: string | null;
  role_crise: string | null;
  cellule: string | null;
  suppleant_id: string | null;
  compte_id: string | null;
  email_pro: string | null;
  email_perso: string | null;
  tel_pro: string | null;
  tel_perso: string | null;
  tel_astreinte: string | null;
  notes: string | null;
  verifie_at: Date | null;
}

export async function lister(
  sql: Sql, organisationId: string, version: number | null = null,
): Promise<Personne[]> {
  return dossier.lire<Personne>(sql, "personne", organisationId, version);
}

export async function creer(
  sql: Sql, organisationId: string, saisie: SaisiePersonne, auteurId: string | null,
): Promise<string> {
  return (await sql.begin(async (tx) => {
    const v = await dossier.nouvelleVersion(tx, organisationId, auteurId,
      `Ajout de ${saisie.nom} à l'annuaire`);
    return dossier.ecrire(tx, "personne", organisationId, v, null, {
      ...saisie, verifie_at: new Date(),
    });
  })) as string;
}

export async function modifier(
  sql: Sql, organisationId: string, id: string,
  changements: Partiel<SaisiePersonne>, auteurId: string | null,
): Promise<void> {
  await sql.begin(async (tx) => {
    const [actuelle] = await tx<{ nom: string }[]>`
      select nom from personne where id = ${id} and jusqu_a_version is null`;
    const nom = changements.nom ?? actuelle?.nom ?? "une fiche";
    const v = await dossier.nouvelleVersion(tx, organisationId, auteurId,
      `Modification de la fiche de ${nom}`);
    await dossier.ecrire(tx, "personne", organisationId, v, id, { ...changements });
  });
}

export async function retirer(
  sql: Sql, organisationId: string, id: string, auteurId: string | null,
): Promise<void> {
  await sql.begin(async (tx) => {
    const [actuelle] = await tx<{ nom: string }[]>`
      select nom from personne where id = ${id} and jusqu_a_version is null`;
    const v = await dossier.nouvelleVersion(tx, organisationId, auteurId,
      `Retrait de ${actuelle?.nom ?? "une personne"} de l'annuaire`);
    await dossier.supprimer(tx, "personne", organisationId, v, id);
  });
}

/**
 * Confirmer ses coordonnées ne crée pas de version : la fiche n'a pas changé,
 * seule sa fraîcheur a été rétablie. Empiler des versions pour cela rendrait
 * l'historique illisible.
 */
export async function marquerVerifiee(sql: Sql, id: string): Promise<void> {
  await sql`update personne set verifie_at = now() where id = ${id} and jusqu_a_version is null`;
}

// ── Astreintes et absences ────────────────────────────────────────────────
export interface Indisponibilite {
  id: string;
  personne_id: string;
  genre: "astreinte" | "absence";
  du: Date;
  au: Date;
  motif: string | null;
}

export async function periodes(sql: Sql, organisationId: string): Promise<Indisponibilite[]> {
  return sql<Indisponibilite[]>`
    select i.* from indisponibilite i
     where i.personne_id in (select id from personne_courante where organisation_id = ${organisationId})
       and i.au > now() - interval '7 days'
     order by i.du`;
}

export async function ajouterPeriode(
  sql: Sql, personneId: string, genre: "astreinte" | "absence",
  du: Date, au: Date, motif?: string,
): Promise<string> {
  const [r] = await sql<{ id: string }[]>`
    insert into indisponibilite (personne_id, genre, du, au, motif)
    values (${personneId}, ${genre}, ${du}, ${au}, ${motif ?? null}) returning id`;
  return r!.id;
}

export async function retirerPeriode(sql: Sql, id: string): Promise<boolean> {
  const r = await sql`delete from indisponibilite where id = ${id}`;
  return r.count > 0;
}

/** Qui est joignable maintenant : la première question d'une mobilisation. */
export async function joignables(sql: Sql, organisationId: string): Promise<Personne[]> {
  return sql<Personne[]>`
    select p.* from personne_courante p
     where p.organisation_id = ${organisationId}
       and not exists (
         select 1 from indisponibilite i
          where i.personne_id = p.id and i.genre = 'absence'
            and now() between i.du and i.au)
     order by p.nom`;
}
+192 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading