Commit 30580da5 authored by Kourser's avatar Kourser
Browse files

Conduite de crise : décisions, actions, points de situation, cockpit

Lot L3, et les premières routes de crise — jusqu'ici rien du contenu d'une
crise n'était exposé, faute de contrôle d'accès.

La boucle décisionnelle
  C'est ce qui sépare le produit d'une messagerie. L'opérationnelle publie un
  point de situation selon une trame fixe, la décisionnelle arbitre, les
  arbitrages redescendent en actions suivies.

  La trame est fixe à dessein : sous pression, une page blanche ne produit rien
  d'exploitable, alors que cinq questions nommées forcent à dire l'essentiel.
  « Ce qu'on ne sait pas » est obligatoire — c'est la rubrique qui coûte le plus
  à écrire et qui sert le plus. Une direction qui ignore l'étendue de son
  ignorance communique trop tôt et se dédit ensuite.

  Le brouillon se pré-remplit depuis le journal en repartant du dernier point
  publié. Ce n'est pas de la rédaction automatique mais un rappel de ce qui
  s'est passé : sous pression on oublie la moitié de ce qu'on a fait.

Les décisions retiennent ce qu'on a écarté
  Une décision de crise se juge des mois plus tard, souvent par quelqu'un qui
  n'était pas là. Ce qui la rend défendable n'est pas le choix retenu mais le
  fait de pouvoir montrer ce qui était su, ce qui a été envisagé, et qui a
  tranché. Une décision qui dépasse le mandat de la cellule remonte à la
  décisionnelle et reste au cockpit jusqu'au tranchage.

Les actions ont un responsable, sinon elles n'existent pas
  Créer une action sans nommer quelqu'un est refusé : une liste de tâches sans
  responsable n'est pas un registre, c'est un vœu collectif que personne ne
  tient. Le responsable n'a pas forcément de compte — un prestataire reste un
  responsable valable.

  Création, réaffectation et clôture passent en main courante ; les statuts
  intermédiaires restent au journal seul, sinon la main courante se noie.

Le cockpit ne montre pas tout
  Il montre ce qui appelle une décision : arbitrages en attente, actions en
  retard, cellules muettes au-delà du seuil, et tiers appelés qui n'ont pas
  rappelé. Ce dernier point est l'angle mort classique d'une cellule — on
  suppose que le CERT arrive, et personne ne relance.

EF-206 achevé
  Activer un tiers crée désormais l'action de suivi promise par le cahier des
  charges, avec une échéance tirée du délai contractuel. Le retour — ou son
  absence — la referme.

Contrainte transverse tenue
  Décisions, actions et points sont des projections : `etatALaSeq` les rejoue
  toutes. Au rembobinage, une décision arbitrée redevient en attente et les
  actions postérieures n'existent pas encore. C'était le critère de recette que
  le lot L1 imposait à tous les suivants.

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

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 8acc876b
Loading
Loading
Loading
Loading
+38 −4
Original line number Diff line number Diff line
@@ -13,9 +13,10 @@ l'enregistre intégralement — de manière à pouvoir la rembobiner.
| Lot | Contenu | État |
|-----|---------|------|
| **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 |
| **L1** | Noyau de persistance, main courante, rembobinage | 🟡 en cours — noyau, lecture à date, filtres et routes faits ; reste l'export PDF paginé de la main courante (EF-410) et la vue frise, qui suppose une interface |
| **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 | ⚪ |
| **L3** | Décisions, actions, points de situation, cockpit | 🟢 fait |
| L4+ | Chat, GED, visio, tableau blanc, mobilisation | ⚪ |

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é
@@ -23,7 +24,9 @@ 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,
le dossier de préparation versionné avec son export papier, et la pile Docker.
le dossier de préparation versionné avec son export papier, la boucle
décisionnelle complète (décisions, arbitrages, actions, points de situation,
cockpit) et ses routes HTTP, et la pile Docker.

---

@@ -83,6 +86,12 @@ 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
  domaine/        conduite de crise
    crise.ts         ouverture, gravité, consignation, rectification
    decisions.ts     registre, arbitrages
    actions.ts       registre, responsables, échéances, « ce qui m'incombe »
    points.ts        trame fixe, brouillon depuis le journal, export autonome
    cockpit.ts       ce qui appelle une décision, en dix secondes
  preparation/    la moitié amont du produit
    dossier.ts       versionnement à temps de validité
    annuaire.ts      personnes, suppléants, astreintes
@@ -95,7 +104,6 @@ apps/api/src/
    console.ts       organisations, comptes, droit root
    etat.ts          état d'instance, intégrité globale, bris de glace
    quotas.ts        appliqués au point d'usage, pas seulement affichés
  domaine/        opérations métier, qui n'écrivent que via le noyau
  verification/   suites de contrôle, une par domaine
  db/migrations/  le schéma fait autorité, y compris sur les garanties
packages/shared/  catalogue des événements, partagé avec le web et le mobile
@@ -141,6 +149,32 @@ promesse « cloner et composer suffit » est une exigence produit. Le format
stocke ses paramètres, une migration reste possible sans invalider les
empreintes.

## La boucle décisionnelle

Ce qui sépare Kastell d'une messagerie. L'opérationnelle publie un **point de
situation** selon une trame fixe, la décisionnelle **arbitre**, les arbitrages
redescendent en **actions suivies**.

La trame est fixe à dessein : sous pression, une page blanche ne produit rien
d'exploitable, alors que cinq questions nommées forcent à dire l'essentiel. La
rubrique « ce qu'on ne sait pas » est **obligatoire** — c'est celle qui coûte le
plus à écrire et qui sert le plus : une direction qui ignore l'étendue de son
ignorance communique trop tôt et se dédit ensuite.

Le brouillon se pré-remplit depuis le journal, en repartant du dernier point
publié. Ce n'est pas de la rédaction automatique, c'est un rappel de ce qui
s'est passé : sous pression on oublie la moitié de ce qu'on a fait.

Une décision retient aussi **ce qui a été écarté**. Elle se juge des mois plus
tard, souvent par quelqu'un qui n'était pas là ; ce qui la rend défendable n'est
pas le choix retenu mais le fait de pouvoir montrer ce qui était su, ce qui a
été envisagé, et qui a tranché.

Le **cockpit** ne montre pas tout : il montre ce qui appelle une décision —
arbitrages en attente, actions en retard, cellules muettes depuis trop
longtemps, et tiers appelés qui n'ont pas rappelé. Ce dernier point est l'angle
mort classique : on suppose que le CERT arrive, et personne ne relance.

## Dossier de préparation

La moitié du produit sert **avant** la crise. Annuaire avec coordonnées
+94 −0
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- 0007 — Conduite de crise : décisions, actions, points de situation (§6.5, §6.6)
--
-- Ces trois tables sont des projections : tout leur contenu se rejoue depuis le
-- journal. Si elles étaient perdues, elles se reconstruiraient ; l'inverse est
-- faux. Rien ne s'y écrit hors du chemin `enregistrer()`.
--
-- C'est ici que se joue la différence entre le produit et une messagerie : la
-- cellule opérationnelle publie un point de situation, la cellule décisionnelle
-- arbitre, les arbitrages redescendent en actions suivies.
-- ═══════════════════════════════════════════════════════════════════════════

-- Au-delà de ce délai sans point de situation, le cockpit passe en alerte (EF-604).
alter table crise add column seuil_point_situation_minutes integer not null default 60
  check (seuil_point_situation_minutes between 5 and 1440);

-- ── Décisions (EF-501, EF-502) ─────────────────────────────────────────────
create table decision (
  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,
  objet            text        not null,
  options          jsonb       not null default '[]'::jsonb,
  retenue          text,
  decideur_id      uuid        references compte(id) on delete set null,
  -- Une décision qui dépasse le mandat de la cellule remonte à la décisionnelle
  -- et y reste visible jusqu'à ce qu'elle soit tranchée.
  arbitrage_requis boolean     not null default false,
  arbitree_at      timestamptz,
  seq_creation     bigint      not null,
  seq_arbitrage    bigint,
  cree_at          timestamptz not null,
  primary key (crise_id, id)
);
create index decision_arbitrage on decision (crise_id)
  where arbitrage_requis and arbitree_at is null;

-- ── Actions (EF-503) ───────────────────────────────────────────────────────
create table action (
  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,
  intitule               text        not null,
  -- Le responsable n'a pas toujours de compte : un prestataire, un tiers, une
  -- personne de l'annuaire jamais connectée restent des responsables valables.
  responsable_id         uuid        references compte(id) on delete set null,
  responsable_libelle    text,
  echeance               timestamptz,
  statut                 text        not null default 'a_faire'
                           check (statut in ('a_faire','en_cours','bloquee','faite','abandonnee')),
  decision_id            uuid,
  tiers_id               uuid,
  seq_creation           bigint      not null,
  seq_dernier_changement bigint      not null,
  cree_at                timestamptz not null,
  close_at               timestamptz,
  primary key (crise_id, id)
);
create index action_ouvertes on action (crise_id, echeance)
  where statut in ('a_faire','en_cours','bloquee');
create index action_responsable on action (responsable_id)
  where statut in ('a_faire','en_cours','bloquee');
create index action_tiers on action (crise_id, tiers_id) where tiers_id is not null;

create view action_en_retard as
  select * from action
   where statut in ('a_faire','en_cours','bloquee')
     and echeance is not null and echeance < now();

-- ── Points de situation (EF-601 à EF-604) ──────────────────────────────────
create table point_situation (
  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,
  auteur_id          uuid        references compte(id) on delete set null,
  seq                bigint      not null,
  publie_at          timestamptz not null,
  -- La trame est fixe (EF-601) : sous pression, une page blanche ne produit
  -- rien d'exploitable, et cinq champs nommés forcent à dire l'essentiel.
  sait               text        not null,
  ne_sait_pas        text        not null,
  en_cours           text        not null,
  arbitrages         text,
  prochaine_echeance text,
  destinataires      uuid[]      not null default '{}',
  primary key (crise_id, id)
);
create index point_par_cellule on point_situation (crise_id, cellule_id, publie_at desc);

/** Dernier point publié par cellule : c'est ce que le cockpit surveille. */
create view dernier_point as
  select distinct on (crise_id, cellule_id) *
    from point_situation
   order by crise_id, cellule_id, publie_at desc;
+225 −0
Original line number Diff line number Diff line
import { randomUUID } from "node:crypto";
import { STATUTS_OUVERTS, type StatutAction } from "@kastell/shared";
import type { Sql } from "../db/client.js";
import { enregistrer, type Contexte } from "../noyau/enregistrer.js";

/**
 * Registre des actions (EF-503 à EF-506).
 *
 * Une action porte toujours un responsable nommé et, sauf exception assumée,
 * une échéance. Une liste de tâches sans responsable ni date n'est pas un
 * registre d'actions : c'est un vœu collectif, et personne ne le tient.
 */

export interface Action {
  id: string;
  crise_id: string;
  cellule_id: string | null;
  intitule: string;
  responsable_id: string | null;
  responsable_libelle: string | null;
  echeance: Date | null;
  statut: StatutAction;
  decision_id: string | null;
  tiers_id: string | null;
  seq_creation: string;
  seq_dernier_changement: string;
  cree_at: Date;
  close_at: Date | null;
}

export interface SaisieAction {
  criseId: string;
  celluleId: string | null;
  intitule: string;
  /** L'un des deux au moins : un prestataire n'a pas de compte. */
  responsableId?: string | null;
  responsableLibelle?: string | null;
  echeance?: Date | null;
  decisionId?: string | null;
  tiersId?: string | null;
  /** Les actions engendrées par le système ne polluent pas la main courante. */
  silencieuse?: boolean;
}

async function libelleDe(sql: Sql, a: SaisieAction): Promise<string> {
  if (a.responsableLibelle) return a.responsableLibelle;
  if (a.responsableId) {
    const [c] = await sql<{ nom: string }[]>`select nom from compte where id = ${a.responsableId}`;
    if (c) return c.nom;
  }
  return "non attribuée";
}

export async function creer(
  sql: Sql, a: SaisieAction, ctx: Contexte,
): Promise<{ actionId: string; seq: number }> {
  if (!a.responsableId && !a.responsableLibelle) {
    throw new Error("Une action sans responsable ne sera pas tenue : nommez quelqu'un.");
  }
  const actionId = randomUUID();
  const libelle = await libelleDe(sql, a);

  return (await sql.begin(async (tx) => {
    const ev = await enregistrer(tx, {
      criseId: a.criseId, celluleId: a.celluleId, type: "action.creee",
      charge: {
        action_id: actionId, intitule: a.intitule,
        responsable_id: a.responsableId ?? null, responsable_libelle: libelle,
        echeance: a.echeance ? a.echeance.toISOString() : null,
        decision_id: a.decisionId ?? null, tiers_id: a.tiersId ?? null,
      },
      ctx,
    });

    // EF-405 : création, réaffectation et clôture passent en main courante ;
    // les changements de statut intermédiaires resteraient du bruit.
    if (!a.silencieuse) {
      await enregistrer(tx, {
        criseId: a.criseId, celluleId: a.celluleId, type: "main_courante.consignee",
        charge: {
          genre: "action",
          texte: `Action confiée à ${libelle} : ${a.intitule}`
            + (a.echeance ? ` — échéance ${a.echeance.toLocaleString("fr-FR")}.` : "."),
          diffusion: "cellule",
          liens: [{ genre: "action", id: actionId, libelle: a.intitule }],
        },
        ctx: { ...ctx, origine: "automatique" },
      });
    }
    return { actionId, seq: ev.seq };
  })) as { actionId: string; seq: number };
}

async function charger(sql: Sql, criseId: string, actionId: string): Promise<Action> {
  const [a] = await sql<Action[]>`
    select * from action where crise_id = ${criseId} and id = ${actionId}`;
  if (!a) throw new Error("Action inconnue.");
  return a;
}

export async function reaffecter(
  sql: Sql, criseId: string, actionId: string,
  responsableId: string | null, responsableLibelle: string | null, ctx: Contexte,
): Promise<void> {
  const a = await charger(sql, criseId, actionId);
  const vers = responsableLibelle
    ?? (responsableId
      ? (await sql<{ nom: string }[]>`select nom from compte where id = ${responsableId}`)[0]?.nom
      : null);
  if (!vers) throw new Error("Nommez le nouveau responsable.");

  await sql.begin(async (tx) => {
    await enregistrer(tx, {
      criseId, celluleId: a.cellule_id, type: "action.reaffectee",
      charge: {
        action_id: actionId, intitule: a.intitule,
        de: a.responsable_libelle, vers, responsable_id: responsableId ?? null,
      },
      ctx,
    });
    await enregistrer(tx, {
      criseId, celluleId: a.cellule_id, type: "main_courante.consignee",
      charge: {
        genre: "action",
        texte: `Action « ${a.intitule} » réaffectée de ${a.responsable_libelle ?? "personne"} à ${vers}.`,
        diffusion: "cellule",
        liens: [{ genre: "action", id: actionId, libelle: a.intitule }],
      },
      ctx: { ...ctx, origine: "automatique" },
    });
  });
}

export async function reporter(
  sql: Sql, criseId: string, actionId: string, echeance: Date | null, motif: string, ctx: Contexte,
): Promise<void> {
  const a = await charger(sql, criseId, actionId);
  if (motif.trim().length < 3) throw new Error("Dites pourquoi l'échéance change.");
  await sql.begin((tx) => enregistrer(tx, {
    criseId, celluleId: a.cellule_id, type: "action.echeance_modifiee",
    charge: {
      action_id: actionId, intitule: a.intitule,
      de: a.echeance ? new Date(a.echeance).toISOString() : null,
      vers: echeance ? echeance.toISOString() : null,
      motif,
    },
    ctx,
  }));
}

export async function changerStatut(
  sql: Sql, criseId: string, actionId: string, vers: StatutAction,
  commentaire: string | null, ctx: Contexte,
): Promise<void> {
  const a = await charger(sql, criseId, actionId);
  if (a.statut === vers) return;
  const cloture = !STATUTS_OUVERTS.includes(vers) && STATUTS_OUVERTS.includes(a.statut);

  await sql.begin(async (tx) => {
    await enregistrer(tx, {
      criseId, celluleId: a.cellule_id, type: "action.statut_modifie",
      charge: {
        action_id: actionId, intitule: a.intitule, de: a.statut, vers, commentaire,
      },
      ctx,
    });
    if (cloture) {
      await enregistrer(tx, {
        criseId, celluleId: a.cellule_id, type: "main_courante.consignee",
        charge: {
          genre: "action",
          texte: `Action « ${a.intitule} » ${vers === "faite" ? "achevée" : "abandonnée"}`
            + (commentaire ? ` : ${commentaire}` : "."),
          diffusion: "cellule",
          liens: [{ genre: "action", id: actionId, libelle: a.intitule }],
        },
        ctx: { ...ctx, origine: "automatique" },
      });
    }
  });
}

export interface FiltreActions {
  celluleId?: string | undefined;
  responsableId?: string | undefined;
  ouvertes?: boolean | undefined;
  enRetard?: boolean | undefined;
}

export async function lister(sql: Sql, criseId: string, f: FiltreActions = {}): Promise<Action[]> {
  return sql<Action[]>`
    select * from action
     where crise_id = ${criseId}
       ${f.celluleId ? sql`and cellule_id = ${f.celluleId}` : sql``}
       ${f.responsableId ? sql`and responsable_id = ${f.responsableId}` : sql``}
       ${f.ouvertes ? sql`and statut in ${sql(STATUTS_OUVERTS)}` : sql``}
       ${f.enRetard ? sql`and statut in ${sql(STATUTS_OUVERTS)}
                          and echeance is not null and echeance < now()` : sql``}
     order by (echeance is null), echeance, cree_at`;
}

/** EF-506 — « ce qui m'incombe », toutes crises actives confondues. */
export interface Incombance extends Action {
  crise_intitule: string;
  crise_gravite: string;
}

export async function mesActions(sql: Sql, compteId: string): Promise<Incombance[]> {
  return sql<Incombance[]>`
    select a.*, c.intitule as crise_intitule, c.gravite as crise_gravite
      from action a join crise c on c.id = a.crise_id
     where a.responsable_id = ${compteId}
       and a.statut in ${sql(STATUTS_OUVERTS)}
       and c.phase <> 'close'
     order by (a.echeance is null), a.echeance`;
}

/** EF-505 — l'action se relit comme une suite d'actes, pas comme un état final. */
export async function historique(sql: Sql, criseId: string, actionId: string) {
  return sql<{ seq: string; type: string; occurred_at: Date; acteur_id: string | null;
    charge: Record<string, unknown> }[]>`
    select seq, type, occurred_at, acteur_id, charge from evenement
     where crise_id = ${criseId} and charge->>'action_id' = ${actionId}
     order by seq`;
}
+165 −0
Original line number Diff line number Diff line
import type { Sql } from "../db/client.js";

/**
 * Cockpit (EF-304, EF-504, EF-604).
 *
 * Une seule vue, lisible en dix secondes par quelqu'un qui arrive à 2 h du
 * matin. Elle ne montre pas tout : elle montre ce qui appelle une décision —
 * les arbitrages en attente, les actions en retard, les cellules qui n'ont pas
 * donné signe de vie, les tiers appelés qui n'ont pas rappelé.
 */

export interface EtatCellule {
  id: string;
  code: string;
  libelle: string;
  dernier_point_at: Date | null;
  anciennete_minutes: number | null;
  /** Vrai au-delà du seuil de la crise : la cellule est muette depuis trop longtemps. */
  alerte: boolean;
  actions_ouvertes: number;
  actions_en_retard: number;
}

export interface TiersEnAttente {
  tiers_id: string;
  nom: string;
  active_at: Date;
  attente_minutes: number;
  delai_attendu: string | null;
}

export interface Cockpit {
  crise: {
    id: string;
    intitule: string;
    type: string;
    gravite: string;
    phase: string;
    exercice: boolean;
    declenchee_at: Date;
    /** Le temps que la cellule compte réellement : H+04:12. */
    chrono: string;
    seuil_point_situation_minutes: number;
    preparation_version: number | null;
  };
  cellules: EtatCellule[];
  arbitrages_en_attente: { id: string; objet: string; cellule_id: string | null; depuis: Date }[];
  actions_en_retard: {
    id: string; intitule: string; responsable: string | null; echeance: Date | null;
  }[];
  actions_ouvertes: number;
  tiers_en_attente: TiersEnAttente[];
  /** Qui a écrit dans le journal au cours du dernier quart d'heure. */
  actifs: { compte_id: string; nom: string; dernier_acte: Date }[];
  entrees_main_courante: number;
}

function chrono(depuis: Date, jusqu: Date = new Date()): string {
  const ecart = Math.max(0, jusqu.getTime() - depuis.getTime());
  const h = Math.floor(ecart / 3600000);
  const m = Math.floor((ecart % 3600000) / 60000);
  return `H+${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}

const minutesDepuis = (d: Date) => Math.floor((Date.now() - d.getTime()) / 60000);

export async function etat(sql: Sql, criseId: string): Promise<Cockpit | null> {
  const [crise] = await sql<{
    id: string; intitule: string; type: string; gravite: string; phase: string;
    exercice: boolean; declenchee_at: Date; close_at: Date | null;
    seuil_point_situation_minutes: number; preparation_version: number | null;
  }[]>`select id, intitule, type, gravite, phase, exercice, declenchee_at, close_at,
              seuil_point_situation_minutes, preparation_version
         from crise where id = ${criseId}`;
  if (!crise) return null;

  const [cellules, arbitrages, retards, ouvertes, tiers, actifs, entrees] = await Promise.all([
    sql<{ id: string; code: string; libelle: string; dernier_point_at: Date | null;
      actions_ouvertes: string; actions_en_retard: string }[]>`
      select c.id, c.code, c.libelle,
             (select p.publie_at from point_situation p
               where p.crise_id = c.crise_id and p.cellule_id = c.id
               order by p.publie_at desc limit 1) as dernier_point_at,
             (select count(*) from action a
               where a.crise_id = c.crise_id and a.cellule_id = c.id
                 and a.statut in ('a_faire','en_cours','bloquee')) as actions_ouvertes,
             (select count(*) from action_en_retard a
               where a.crise_id = c.crise_id and a.cellule_id = c.id) as actions_en_retard
        from cellule c where c.crise_id = ${criseId} and c.fermee_at is null
       order by c.cree_at`,

    sql<{ id: string; objet: string; cellule_id: string | null; depuis: Date }[]>`
      select id, objet, cellule_id, cree_at as depuis from decision
       where crise_id = ${criseId} and arbitrage_requis and arbitree_at is null
       order by cree_at`,

    sql<{ id: string; intitule: string; responsable: string | null; echeance: Date | null }[]>`
      select id, intitule, responsable_libelle as responsable, echeance
        from action_en_retard where crise_id = ${criseId} order by echeance`,

    sql<{ n: string }[]>`
      select count(*) as n from action where crise_id = ${criseId}
        and statut in ('a_faire','en_cours','bloquee')`,

    // Un tiers appelé qui n'a pas rappelé est l'angle mort classique d'une
    // cellule : on suppose qu'il arrive, et on ne relance jamais.
    sql<{ tiers_id: string; nom: string; active_at: Date; delai_attendu: string | null }[]>`
      select distinct on (e.charge->>'tiers_id')
             e.charge->>'tiers_id' as tiers_id,
             e.charge->>'nom'      as nom,
             e.occurred_at         as active_at,
             e.charge->>'delai_attendu' as delai_attendu
        from evenement e
       where e.crise_id = ${criseId} and e.type = 'tiers.active'
         and not exists (
           select 1 from evenement r
            where r.crise_id = e.crise_id
              and r.type in ('tiers.repondu','tiers.sans_reponse')
              and r.charge->>'tiers_id' = e.charge->>'tiers_id'
              and r.seq > e.seq)
       order by e.charge->>'tiers_id', e.seq desc`,

    sql<{ compte_id: string; nom: string; dernier_acte: Date }[]>`
      select e.acteur_id as compte_id, c.nom, max(e.occurred_at) as dernier_acte
        from evenement e join compte c on c.id = e.acteur_id
       where e.crise_id = ${criseId} and e.occurred_at > now() - interval '15 minutes'
       group by e.acteur_id, c.nom order by max(e.occurred_at) desc`,

    sql<{ n: string }[]>`select count(*) as n from main_courante where crise_id = ${criseId}`,
  ]);

  const reference = crise.close_at ?? new Date();

  return {
    crise: {
      id: crise.id, intitule: crise.intitule, type: crise.type, gravite: crise.gravite,
      phase: crise.phase, exercice: crise.exercice, declenchee_at: crise.declenchee_at,
      chrono: chrono(crise.declenchee_at, reference),
      seuil_point_situation_minutes: crise.seuil_point_situation_minutes,
      preparation_version: crise.preparation_version,
    },
    cellules: cellules.map((c) => {
      const anciennete = c.dernier_point_at ? minutesDepuis(c.dernier_point_at) : null;
      return {
        id: c.id, code: c.code, libelle: c.libelle,
        dernier_point_at: c.dernier_point_at,
        anciennete_minutes: anciennete,
        // Jamais de point publié compte comme une alerte : le silence initial est
        // exactement ce qu'il faut voir.
        alerte: crise.phase !== "close"
          && (anciennete === null || anciennete > crise.seuil_point_situation_minutes),
        actions_ouvertes: Number(c.actions_ouvertes),
        actions_en_retard: Number(c.actions_en_retard),
      };
    }),
    arbitrages_en_attente: arbitrages,
    actions_en_retard: retards,
    actions_ouvertes: Number(ouvertes[0]?.n ?? 0),
    tiers_en_attente: tiers.map((t) => ({
      ...t, attente_minutes: minutesDepuis(t.active_at),
    })),
    actifs,
    entrees_main_courante: Number(entrees[0]?.n ?? 0),
  };
}
+155 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading