Commit 1232a865 authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Une main courante qui commence à l'entrée douze

Trois exigences du cahier des charges portaient sur cet écran et n'étaient pas
faites. Elles se sont vues à l'usage avant que je les trouve : « je n'ai qu'une
notion d'heure et pas de jours », « je ne le trouve pas lisible », « je veux un
champ recherche avec des filtres ».

── EF-404 : le journal ne versait pas ce qu'il constate

Sur les neuf événements structurants exigés, un seul arrivait — le début
d'enregistrement, que le module de réunion consigne lui-même. Les huit autres
restaient au journal sans jamais paraître, et c'est pourquoi une main courante
commençait à l'entrée numéro douze : les onze premières étaient l'ouverture de la
crise, ses cellules et leurs salons.

Versés par dérivation, non par un second événement. Écrire « crise ouverte » une
deuxième fois pour que cela se voie doublerait le journal et ferait deux vérités
là où il en faut une ; et la dérivation vaut pour les crises déjà ouvertes, qui
montrent leur propre commencement dès la première lecture.

Deux exclusions délibérées. « reunion.rejointe » et « reunion.quittee » décrivent
des allées et venues, pas un début de visio : huit personnes qui entrent et
sortent produiraient trente entrées pour une réunion et noieraient ce que la main
courante sert à lire — le début et la fin sont représentés par l'ouverture et la
fermeture de la salle. Et la salle d'une cellule naît avec elle : la verser
doublerait chaque « cellule ouverte » d'un « salle ouverte » qui n'apprend rien.

Une entrée versée ne se rectifie pas : elle a été constatée, pas écrite. La
proposer à la rectification laisserait croire qu'on peut réécrire l'ouverture de
la crise.

Une erreur que la suite a rattrapée : j'avais compté « tiers.active » parmi les
manquants. Le registre des tiers consigne déjà l'activation, avec sa catégorie,
le numéro appelé et un lien vers la fiche. En l'ajoutant je créais deux entrées
pour un appel, dont la moins complète — deux garanties existantes sont tombées
avant que je le voie. Huit des neuf étaient à faire ; le neuvième était là.

── EF-406 : « H+04:12 »

« C'est ainsi que les cellules raisonnent réellement », dit l'exigence. La barre
du haut l'affichait pour la crise, les entrées non. Il est sous l'heure absolue,
pas à sa place : on a besoin des deux.

Le formateur du H+ et celui du résumé d'événement existaient dans la clôture. Je
les ai déplacés au noyau plutôt que dupliqués — deux formulations pour un même
fait finiraient par se contredire dans le dossier que l'assureur relira. Cela a
cassé au passage un cycle d'import : la clôture dépend de la main courante,
l'inverse n'était pas tenable.

── EF-407 : des filtres qui se combinent, et se partagent

Recherche dans le texte, insensible à la casse **et aux accents** : « securite »
trouve « sécurité ». Par « translate », un builtin, plutôt que par l'extension
« unaccent » qui ferait mieux — une extension demande un privilège que toutes les
instances n'ont pas, et une migration qui échoue empêche de démarrer. Prix
disproportionné pour une recherche.

Ce que l'utilisateur tape n'est pas de la syntaxe : « % » cherche un pour cent.
Le terme trouvé est surligné, sinon on cherche deux fois. La frappe est débattue
de 250 ms et la liste ne se vide pas pendant le chargement : sur un réseau de
crise, un écran qui clignote à chaque lettre est un écran qu'on n'utilise pas.

Genres en multi-sélection, cellule, auteur, période, diffusion. Et l'état des
filtres vit dans l'adresse, en remplacement plutôt qu'en empilement — filtrer
n'est pas naviguer. Sans cela on décrit à l'oral ce qu'on vient de trouver, au
moment où personne n'a le temps d'écouter.

Sur « criticité », que l'exigence énumère : je ne l'ai pas implémentée, parce
qu'une entrée de main courante n'en a pas. Ni le texte, ni l'auteur, ni le genre
ne portent de criticité — seules les applications métier en ont une. J'ai mis
« diffusion » à sa place, qui est l'axe réel le plus proche, plutôt que d'inventer
un champ pour cocher une case.

── La lisibilité

Le coupable n'était pas le manque de style : le genre était rendu **à l'intérieur
du texte**. « FAIT » et « POINT SITUATION » n'ayant pas la même largeur, chaque
entrée commençait à un endroit différent et l'œil devait chercher le début de
chaque ligne. Il a sa colonne, de largeur fixe.

Un séparateur de jour collant s'insère à chaque changement de date : à H+20 une
crise a franchi minuit, et « 16:17 » puis « 11:41 » se lisaient comme une erreur
d'ordre. L'auteur est là systématiquement — l'entrée ne portait que son
identifiant, si bien que le nom n'apparaissait que quand le texte le contenait.
Ce qui situe l'entrée passe sur une seconde ligne, discrète. Et « Rectifier » ne
s'affiche qu'au survol ou au clavier : douze fois le même bouton en pleine
colonne de lecture, pour un geste rare.

Vingt-et-une garanties neuves.

Ce qui reste sur cet écran, non demandé aujourd'hui : la frise (EF-408), l'export
PDF, CSV et JSON (EF-410), la saisie hors ligne (EF-409), et la pagination — la
route renvoie tout le journal, ce qui pèsera à cinq mille entrées.

Co-Authored-By: Claude (RCA)
parent acf0a069
Loading
Loading
Loading
Loading
+1 −27
Changes for apps/api/src/domaine/cloture.ts: 1 added line, 27 removed lines.
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@ import type { Sql } from "../db/client.js";
import { enregistrer, type Contexte } from "../noyau/enregistrer.js";
import { exporterJournal, verifierEnBase } from "../noyau/integrite.js";
import { mainCourante } from "../noyau/rejeu.js";
import { relatif, resumer } from "../noyau/formuler.js";
import { stockageConfigure } from "../adaptateurs/stockage.js";
import { deposerDirect } from "./documents.js";

@@ -33,33 +34,6 @@ export interface Moment {
  resume: string;
}

function relatif(depuis: Date, quand: Date): string {
  const e = Math.max(0, quand.getTime() - depuis.getTime());
  return `H+${String(Math.floor(e / 3600000)).padStart(2, "0")}`
    + `:${String(Math.floor((e % 3600000) / 60000)).padStart(2, "0")}`;
}

function resumer(type: string, charge: Record<string, unknown>): string {
  const c = charge as Record<string, string>;
  switch (type) {
    case "crise.ouverte": return `Crise ouverte — ${c.intitule} (${c.gravite})`;
    case "crise.gravite_modifiee": return `Gravité portée de ${c.de} à ${c.vers} — ${c.motif}`;
    case "crise.phase_modifiee": return `Passage en phase « ${c.vers} »`;
    case "crise.close": return "Crise close";
    case "cellule.creee": return `Cellule ouverte — ${c.libelle}`;
    case "decision.enregistree":
      return c.retenue ? `Décision — ${c.objet} : ${c.retenue}` : `Arbitrage demandé — ${c.objet}`;
    case "decision.arbitree": return `Arbitrage rendu — ${c.objet} : ${c.retenue}`;
    case "point_situation.publie": return "Point de situation publié";
    case "tiers.active": return `Tiers activé — ${c.nom}`;
    case "tiers.sans_reponse": return `${c.nom} sans réponse — ${c.suite_donnee}`;
    case "enregistrement.demarre": return "Enregistrement de réunion démarré";
    case "mobilisation.lancee": return `Mobilisation — ${c.destinataires} destinataire(s)`;
    case "mobilisation.escaladee": return `Escalade vers ${c.vers} après ${c.apres_minutes} min`;
    default: return type;
  }
}

/** EB-14 — sauter d'un moment clé au suivant, plutôt que dérouler le journal. */
export async function momentsCles(sql: Sql, criseId: string): Promise<Moment[]> {
  const [crise] = await sql<{ declenchee_at: Date }[]>`
+61 −0
Changes for apps/api/src/noyau/formuler.ts: 61 added lines, 0 removed lines.
Original line number Diff line number Diff line
/**
 * Dire un événement du journal en une ligne.
 *
 * Deux consommateurs : la chronologie du dossier de clôture (EB-14) et le
 * versement automatique en main courante (EF-404). Ils versent les mêmes
 * événements, et deux formulations pour un même fait finiraient par se
 * contredire dans le dossier que l'assureur relira.
 *
 * Placé au noyau plutôt que dans la clôture : la main courante ne peut pas
 * dépendre de la clôture, qui dépend d'elle.
 */

/**
 * L'horodatage relatif au déclenchement (EF-406).
 *
 * « H+04:12 » — c'est ainsi que les cellules raisonnent réellement. Exporté
 * parce que la main courante en a besoin autant que la chronologie de clôture,
 * et qu'un second formateur finirait par en différer d'une minute.
 */
export function relatif(depuis: Date, quand: Date): string {
  const e = Math.max(0, quand.getTime() - depuis.getTime());
  return `H+${String(Math.floor(e / 3600000)).padStart(2, "0")}`
    + `:${String(Math.floor((e % 3600000) / 60000)).padStart(2, "0")}`;
}

/**
 * Ce qu'un événement structurant dit, en une ligne.
 *
 * Exporté pour la main courante (EF-404) : elle verse les mêmes événements, et
 * deux formulations pour un même fait finiraient par se contredire dans le
 * dossier de clôture.
 */
export function resumer(type: string, charge: Record<string, unknown>): string {
  const c = charge as Record<string, string>;
  switch (type) {
    case "crise.ouverte": return `Crise ouverte — ${c.intitule} (${c.gravite})`;
    case "crise.gravite_modifiee": return `Gravité portée de ${c.de} à ${c.vers} — ${c.motif}`;
    case "crise.phase_modifiee": return `Passage en phase « ${c.vers} »`;
    case "crise.close": return "Crise close";
    case "cellule.creee": return `Cellule ouverte — ${c.libelle}`;
    case "decision.enregistree":
      return c.retenue ? `Décision — ${c.objet} : ${c.retenue}` : `Arbitrage demandé — ${c.objet}`;
    case "decision.arbitree": return `Arbitrage rendu — ${c.objet} : ${c.retenue}`;
    case "point_situation.publie": return "Point de situation publié";
    case "tiers.active": return `Tiers activé — ${c.nom}`;
    case "tiers.sans_reponse": return `${c.nom} sans réponse — ${c.suite_donnee}`;
    case "enregistrement.demarre": return "Enregistrement de réunion démarré";
    case "mobilisation.lancee": return `Mobilisation — ${c.destinataires} destinataire(s)`;
    case "mobilisation.escaladee": return `Escalade vers ${c.vers} après ${c.apres_minutes} min`;
    // Ajoutés pour la main courante (EF-404), qui verse aussi ce qui suit.
    case "salle.ouverte": return `Salle de visioconférence ouverte — ${c.libelle}`;
    case "salle.fermee":
      return c.motif ? `Salle de visioconférence fermée — ${c.motif}`
        : "Salle de visioconférence fermée";
    case "cellule.membre_ajoute":
      return c.role === "externe"
        ? "Intervenant externe admis en cellule"
        : `Entrée en cellule au titre de ${c.role}`;
    default: return type;
  }
}
+152 −3
Changes for apps/api/src/noyau/rejeu.ts: 152 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@ import {
  relire, type Gravite, type Phase, type StatutAction, type TypeEvenement,
} from "@kastell/shared";
import type { Sql } from "../db/client.js";
import { relatif, resumer } from "./formuler.js";

/**
 * Lecture à date (EB-11, EX-15).
@@ -30,6 +31,8 @@ interface LigneVue {
  rectifie_seq: string | null;
  pour_le_compte_de: string | null;
  liens: unknown[];
  /** Le nom de qui a consigné. Nul pour ce qu'aucune main n'a écrit. */
  acteur_nom: string | null;
}

export interface LigneMainCourante extends Omit<LigneVue, "seq" | "rectifie_seq"> {
@@ -37,29 +40,124 @@ export interface LigneMainCourante extends Omit<LigneVue, "seq" | "rectifie_seq"
  rectifie_seq: number | null;
  /** Renseigné quand une entrée ultérieure rectifie celle-ci (EF-402). */
  rectifiee_par: number | null;
  /**
   * Vrai pour ce que le journal verse de lui-même (EF-404).
   *
   * Une entrée structurante ne se rectifie pas : elle n'a pas été écrite, elle
   * a été constatée. La proposer à la rectification laisserait croire qu'on
   * peut réécrire l'ouverture de la crise.
   */
  structurel: boolean;
  /** L'horodatage relatif au déclenchement (EF-406) : « H+04:12 ». */
  relatif: string;
}

/**
 * Les événements structurants versés d'office (EF-404).
 *
 * « Versement automatique dans la main courante des événements structurants du
 * journal : ouverture et clôture, création de cellule, changement de gravité,
 * début et fin de visio, début d'enregistrement, activation d'un tiers, arrivée
 * d'un intervenant externe, envoi d'une mobilisation. »
 *
 * Un seul des neuf y arrivait — le début d'enregistrement, que le module de
 * réunion consigne lui-même. Les huit autres restaient au journal sans jamais
 * paraître : c'est pourquoi une main courante commençait à l'entrée numéro
 * douze, les onze premières étant l'ouverture, les cellules et leurs salons.
 *
 * Versés par dérivation et non par un second événement. Écrire « crise
 * ouverte » une deuxième fois pour que cela se voie doublerait le journal et
 * ferait deux vérités là où il en faut une ; et la dérivation vaut pour les
 * crises déjà ouvertes, qui montrent leur propre commencement dès la première
 * lecture.
 *
 * « tiers.active » n'y figure pas non plus, et c'est le seul des neuf qui était
 * déjà là : le registre des tiers consigne l'activation lui-même, avec la
 * catégorie, le numéro appelé et un lien vers la fiche. Le verser une seconde
 * fois par dérivation aurait produit deux entrées pour un appel, dont la moins
 * complète — deux garanties existantes l'ont dit avant que je le voie.
 *
 * Ce que la liste ne contient pas non plus, et pourquoi : « reunion.rejointe » et
 * « reunion.quittee ». Ils décrivent des allées et venues, non un début de
 * visio — huit personnes qui entrent et sortent d'une salle produiraient trente
 * entrées pour une seule réunion, et noieraient ce que la main courante sert à
 * lire. Le début et la fin d'une visio sont représentés par l'ouverture et la
 * fermeture de la salle.
 */
const STRUCTURANTS = [
  "crise.ouverte", "crise.close", "crise.phase_modifiee", "crise.gravite_modifiee",
  "cellule.creee", "cellule.membre_ajoute",
  "mobilisation.lancee",
  "salle.ouverte", "salle.fermee",
] as const;

/** Le genre sous lequel ils paraissent : ni un fait, ni une décision. */
export const GENRE_STRUCTUREL = "journal";

export interface FiltreMainCourante {
  jusquASeq?: number | undefined;
  celluleId?: string | undefined;
  genre?: string | undefined;
  /** Plusieurs genres à la fois : on cherche « décisions et actions », pas l'un. */
  genres?: string[] | undefined;
  diffusions?: string[] | undefined;
  /** Cherché dans le texte, sans égard à la casse ni aux accents (EF-404a). */
  texte?: string | undefined;
  acteurId?: string | undefined;
  /** La période, bornes incluses (EF-407). */
  du?: Date | undefined;
  au?: Date | undefined;
  /** À qui l'entrée était adressée : cellule, direction, toutes cellules. */
  diffusion?: string | undefined;
}

/**
 * Les accents, dépliés à la comparaison.
 *
 * « securite » doit trouver « sécurité » : dans une main courante française
 * saisie à trois heures du matin, exiger les accents revient à ne rien trouver.
 *
 * « translate » plutôt que l'extension « unaccent », qui ferait mieux et
 * demanderait un privilège que toutes les instances n'ont pas — une migration
 * qui échoue empêche l'application de démarrer, et ce serait un prix
 * disproportionné pour une recherche. L'index n'est pas utilisé, ce qui est
 * sans conséquence ici : la requête est déjà bornée à une crise.
 */
const ACCENTS = "àáâäãåçèéêëìíîïñòóôöõùúûüýÿÀÁÂÄÃÅÇÈÉÊËÌÍÎÏÑÒÓÔÖÕÙÚÛÜÝ";
const PLATS = "aaaaaaceeeeiiiinooooouuuuyyAAAAAACEEEEIIIINOOOOOUUUUY";

/** Un motif « like » : ce que l'utilisateur tape n'est pas de la syntaxe. */
const motif = (q: string) => `%${q.replace(/([\\%_])/g, "\\$1")}%`;

export async function mainCourante(
  sql: Sql,
  criseId: string,
  filtre: FiltreMainCourante = {},
): Promise<LigneMainCourante[]> {
  const lignes = await sql<LigneVue[]>`
    select id, seq, occurred_at, saisi_at, acteur_id, cellule_id, est_rectification,
           genre, texte, diffusion, rectifie_seq, pour_le_compte_de, liens
    select m.id, m.seq, m.occurred_at, m.saisi_at, m.acteur_id, m.cellule_id,
           m.est_rectification, m.genre, m.texte, m.diffusion, m.rectifie_seq,
           m.pour_le_compte_de, m.liens,
           -- Le nom de qui a consigné : l'écran ne disposait que d'un
           -- identifiant, et affichait donc l'auteur quand le texte le
           -- contenait — c'est-à-dire une fois sur deux.
           c.nom as acteur_nom
      from main_courante m
      left join compte c on c.id = m.acteur_id
     where m.crise_id = ${criseId}
       ${filtre.jusquASeq !== undefined ? sql`and m.seq <= ${filtre.jusquASeq}` : sql``}
       ${filtre.celluleId ? sql`and m.cellule_id = ${filtre.celluleId}` : sql``}
       ${filtre.genre ? sql`and m.genre = ${filtre.genre}` : sql``}
       ${filtre.genres?.length ? sql`and m.genre in ${sql(filtre.genres)}` : sql``}
       ${filtre.acteurId ? sql`and m.acteur_id = ${filtre.acteurId}` : sql``}
       ${filtre.diffusions?.length ? sql`and m.diffusion in ${sql(filtre.diffusions)}` : sql``}
       ${filtre.diffusion ? sql`and m.diffusion = ${filtre.diffusion}` : sql``}
       ${filtre.du ? sql`and m.occurred_at >= ${filtre.du}` : sql``}
       ${filtre.au ? sql`and m.occurred_at <= ${filtre.au}` : sql``}
       ${filtre.texte
         ? sql`and translate(m.texte, ${ACCENTS}, ${PLATS})
                 ilike translate(${motif(filtre.texte)}, ${ACCENTS}, ${PLATS})`
         : sql``}
     order by m.seq`;

  // Une rectification ne réécrit rien : elle désigne. On remonte le renvoi
@@ -70,14 +168,65 @@ export async function mainCourante(
    if (l.rectifie_seq != null) rectifications.set(Number(l.rectifie_seq), Number(l.seq));
  }

  return lignes.map((l) => ({
  const [crise] = await sql<{ declenchee_at: Date }[]>`
    select declenchee_at from crise where id = ${criseId}`;
  const depart = crise?.declenchee_at ?? new Date(0);

  const consignees: LigneMainCourante[] = lignes.map((l) => ({
    ...l,
    seq: Number(l.seq),
    rectifie_seq: l.rectifie_seq == null ? null : Number(l.rectifie_seq),
    rectifiee_par: rectifications.get(Number(l.seq)) ?? null,
    structurel: false,
    relatif: relatif(depart, l.occurred_at),
  }));

  // ── Ce que le journal verse de lui-même (EF-404) ─────────────────────────
  //
  // Écarté dès que le filtre porte sur un autre genre : demander les décisions
  // ne doit pas rendre l'ouverture de la crise.
  const genresDemandes = filtre.genres ?? (filtre.genre ? [filtre.genre] : null);
  const veutStructurel = genresDemandes === null
    || genresDemandes.includes(GENRE_STRUCTUREL);

  const structurelles: LigneMainCourante[] = !veutStructurel ? [] : (
    await sql<{ id: string; seq: string; occurred_at: Date; acteur_id: string | null;
      cellule_id: string | null; type: string; charge: Record<string, unknown>;
      origine: string; empreinte: string; acteur_nom: string | null }[]>`
      select e.id, e.seq, e.occurred_at, e.acteur_id, e.cellule_id, e.type, e.charge,
             e.origine, e.empreinte, c.nom as acteur_nom
        from evenement e
        left join compte c on c.id = e.acteur_id
       where e.crise_id = ${criseId} and e.type in ${sql(STRUCTURANTS as unknown as string[])}
         -- La salle d'une cellule naît avec elle : la verser doublerait chaque
         -- « cellule ouverte » d'un « salle ouverte » qui n'apprend rien.
         and not (e.type = 'salle.ouverte' and e.charge->>'genre' = 'cellule')
         -- Seul l'intervenant extérieur est un fait de crise ; l'entrée d'un
         -- membre en cellule se lit à l'écran des cellules.
         and not (e.type = 'cellule.membre_ajoute' and e.charge->>'role' <> 'externe')
         ${filtre.jusquASeq !== undefined ? sql`and e.seq <= ${filtre.jusquASeq}` : sql``}
         ${filtre.celluleId ? sql`and e.cellule_id = ${filtre.celluleId}` : sql``}
         ${filtre.acteurId ? sql`and e.acteur_id = ${filtre.acteurId}` : sql``}
         ${filtre.du ? sql`and e.occurred_at >= ${filtre.du}` : sql``}
         ${filtre.au ? sql`and e.occurred_at <= ${filtre.au}` : sql``}
       order by e.seq`
  ).map((e) => ({
    id: e.id, seq: Number(e.seq), occurred_at: e.occurred_at, saisi_at: null,
    acteur_id: e.acteur_id, acteur_nom: e.acteur_nom, cellule_id: e.cellule_id,
    est_rectification: false, genre: GENRE_STRUCTUREL,
    texte: resumer(e.type, e.charge), diffusion: "cellule",
    rectifie_seq: null, rectifiee_par: null, pour_le_compte_de: null, liens: [],
    empreinte: e.empreinte, origine: e.origine,
    structurel: true, relatif: relatif(depart, e.occurred_at),
  })).filter((l) => !filtre.texte || sansAccent(l.texte).includes(sansAccent(filtre.texte)));

  return [...consignees, ...structurelles].sort((a, b) => a.seq - b.seq);
}

/** La même comparaison que celle du filtre SQL, appliquée en mémoire. */
const sansAccent = (t: string) =>
  t.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();

export interface CelluleReconstituee {
  id: string;
  code: string;
+47 −2
Changes for apps/api/src/routes/crise.ts: 47 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -8,7 +8,7 @@ import * as crises from "../domaine/crise.js";
import * as premiereHeure from "../domaine/premiereHeure.js";
import * as decisions from "../domaine/decisions.js";
import * as points from "../domaine/points.js";
import { etatALaSeq, mainCourante } from "../noyau/rejeu.js";
import { etatALaSeq, mainCourante, type FiltreMainCourante } from "../noyau/rejeu.js";
import { exporterJournal, verifierEnBase } from "../noyau/integrite.js";
import { enregistrer } from "../noyau/enregistrer.js";
import {
@@ -219,8 +219,40 @@ export function enregistrerRoutesCrise(app: FastifyInstance, sql: Sql): void {
      jusquASeq: z.coerce.number().int().positive().optional(),
      celluleId: z.string().uuid().optional(),
      genre: z.enum(GENRES).optional(),
      /** Plusieurs genres, séparés par des virgules : « decision,action ». */
      genres: z.string().max(200).optional(),
      acteurId: z.string().uuid().optional(),
      /** Cherché dans le texte, sans égard à la casse ni aux accents. */
      texte: z.string().trim().max(200).optional(),
      /** La période, bornes incluses (EF-407). */
      du: z.coerce.date().optional(),
      au: z.coerce.date().optional(),
      /** À qui l'entrée était adressée. */
      diffusion: z.enum(DIFFUSIONS).optional(),
    }).parse(req.query);
    return { entrees: await mainCourante(sql, criseId(req), f) };
    const genres = f.genres
      ? f.genres.split(",").map((g) => g.trim()).filter((g) => GENRES.includes(g as never))
      : undefined;
    // Les propriétés facultatives sont posées une par une : « exactOptional »
    // refuse qu'on leur donne « undefined » par étalement.
    const filtre: FiltreMainCourante = {};
    if (f.jusquASeq !== undefined) filtre.jusquASeq = f.jusquASeq;
    if (f.celluleId) filtre.celluleId = f.celluleId;
    if (f.genre) filtre.genre = f.genre;
    if (genres && genres.length > 0) filtre.genres = genres;
    if (f.acteurId) filtre.acteurId = f.acteurId;
    if (f.texte) filtre.texte = f.texte;
    if (f.du) filtre.du = f.du;
    if (f.au) filtre.au = f.au;
    if (f.diffusion) filtre.diffusion = f.diffusion;
    // Le déclenchement accompagne les entrées : sans lui, l'écran ne peut pas
    // afficher « H+04:12 », qui est la façon dont une cellule raisonne (EF-406).
    const [c] = await sql<{ declenchee_at: Date }[]>`
      select declenchee_at from crise where id = ${criseId(req)}`;
    return {
      declenchee_at: c?.declenchee_at ?? null,
      entrees: await mainCourante(sql, criseId(req), filtre),
    };
  });

  app.post("/crises/:criseId/main-courante", accesMobile, async (req, reply) => {
@@ -340,6 +372,19 @@ export function enregistrerRoutesCrise(app: FastifyInstance, sql: Sql): void {
    return { actions: await actions.lister(sql, criseId(req), f) };
  });

  /**
   * Qui peut porter une action (EF-505a).
   *
   * Distinct de « /membres », qui ne rend que les comptes admis en cellule et
   * sert le chat : écrire un message direct à quelqu'un sans compte n'a pas de
   * sens, porter une action, si.
   */
  app.get("/crises/:criseId/porteurs", acces, async (req, reply) => {
    const liste = await crises.porteurs(sql, criseId(req), req.auth!.compte.id);
    if (!liste) return reply.code(404).send({ code: "introuvable", message: "Crise inconnue." });
    return reply.send(liste);
  });

  app.post("/crises/:criseId/actions", acces, async (req, reply) => {
    const c = z.object({
      celluleId: z.string().uuid().nullish(),
+183 −0

File changed.

Preview size limit exceeded, changes collapsed.

Loading