Commit bd0250ca authored by Jordan Grossemy's avatar Jordan Grossemy
Browse files

Ouvrir prend quinze secondes ; ce qui suit n'était nulle part

L'écran de déclenchement porte quatre champs et vise soixante secondes. C'est un
acquis, et la tentation était de le défaire : on demandait de l'aide à
l'ouverture, et un assistant en cinq étapes aurait été la réponse évidente. Ce
serait ralentir le seul geste qui ne doit jamais ralentir, et chaque étape est
une occasion d'abandonner — où abandonner signifie « pas de crise ouverte ».

L'aide arrive donc après. Mais d'abord, trois choses trouvées en regardant.

── Le mur

Une cellule ne pouvait naître qu'au déclenchement : l'événement n'était émis que
depuis l'ouverture de la crise, et aucune route n'en ajoutait. Oublier la cellule
juridique à la seconde trente, c'était ne pas l'avoir à la troisième heure. Cela
contredit le principe posé pour le cockpit — une crise est un phénomène mouvant,
et le produit ne doit pas être un frein.

Elle s'ouvre désormais en pleine crise, avec son salon, sa salle, et le cockpit
préparé pour son code. Deux cellules de même code sont refusées plutôt
qu'ignorées : elles partageraient un cockpit et diviseraient une conversation, et
personne ne comprendrait laquelle est la bonne. Le droit est celui du
déclenchement et non celui d'y faire entrer quelqu'un : l'animateur compose sa
cellule, il ne décide pas qu'il en faut une autre.

── Quatre cellules sur cinq

La liste était écrite en dur dans le client, et n'en connaissait que quatre. La
cellule métier ne pouvait donc jamais être ouverte, alors qu'on pouvait y
affecter des gens dans l'annuaire. Elle vient maintenant du dossier, avec ce que
le dossier en sait : les cellules cochées d'emblée sont celles qu'une
organisation arme réellement — des gens affectés, ou un cockpit composé — et
l'effectif s'affiche à côté. On ne coche plus de mémoire.

── La date qui décide de tous les délais

La prise de connaissance des faits se renseignait après coup, avec un motif
obligatoire. C'est-à-dire trop tard, ou jamais — et les délais réglementaires
affichés étaient alors faux dans le sens dangereux. Un contrôle replié la
recueille à l'ouverture, par défaut « à l'instant ». Sans date donnée, rien n'est
inscrit au journal : « j'ai appris à l'instant où j'ai ouvert » est déjà
l'hypothèse par défaut, et un événement qui ne dit rien de plus que le défaut est
du bruit.

── La première heure

Le déclenchement atterrit sur une lecture du dossier gelé, non sur un formulaire
de plus. Composer les cellules depuis les affectations de l'annuaire — les
personnes sans compte sont signalées, on les mobilise sans les faire entrer.
Choisir une mobilisation préparée, sans que rien ne part de cet écran : mobiliser
reste une décision. Dérouler les fiches réflexes. Voir les tiers, et les délais
qui courent déjà, avant tout le reste.

EF-207 promettait que les fiches sont « affichées automatiquement au
déclenchement ». Elles l'étaient à un clic, dans un écran qu'il fallait
connaître, ce qui n'est pas la même chose : la différence se paie dans l'heure où
personne ne va chercher. Cocher une étape produit maintenant un événement, et
pas seulement un affichage — le retour d'expérience peut dire qu'une fiche n'a
jamais été déroulée, ce qu'aucun état d'écran ne saurait dire. Décocher est
permis : se tromper de ligne à trois heures du matin ne doit pas être définitif.

Le point qui rend tout cela peu coûteux : l'état de cette liste se déduit du
journal. Aucune table nouvelle, aucun état à maintenir, et le rembobinage montre
la première heure telle qu'elle a été plutôt que telle qu'un écran l'aurait
mémorisée. Une seule lecture serveur, aussi : au pire moment, six allers-retours
valent cinq occasions d'attendre.

── Ce que j'ai écrit puis retiré

J'avais ajouté un rattrapage pour le cas « tiers activé mais absent du dossier
gelé », de peur que l'écran dise qu'on n'a activé personne juste après qu'on ait
appelé quelqu'un. En l'éprouvant, l'activation refuse déjà un tiers que la crise
n'a pas gelé : le cas était impossible et le code gardait un état qui ne peut pas
exister. Il est parti, remplacé par la garantie qui documente l'invariant.

Vingt garanties neuves. 1099 au total.

Ce que je n'ai pas fait : un assistant avant que la crise existe, et la frise
chronologique de la main courante, qui reste un travail d'interface à part
entière.

Co-Authored-By: Claude (RCA)
parent 7cbe95f2
Loading
Loading
Loading
Loading
+83 −0
Changes for apps/api/src/domaine/crise.ts: 83 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -20,6 +20,21 @@ export interface OuvertureCrise {
  cellules?: { code: string; libelle: string }[];
  /** L'identifiant de l'alerte chez le système qui l'a déclenchée (RG-501). */
  referenceExterne?: string | null | undefined;
  /**
   * Quand les faits ont été appris (EF-301b).
   *
   * Tous les délais réglementaires en découlent, et ils ne partent pas de
   * l'ouverture de la crise dans l'outil : les deux diffèrent presque toujours,
   * et les confondre fait croire qu'il reste du temps. La question se posait
   * jusqu'ici après coup, avec un motif obligatoire — c'est-à-dire trop tard,
   * ou jamais.
   *
   * Absente, ou postérieure au déclenchement, elle n'est pas inscrite : « j'ai
   * appris à l'instant où j'ai ouvert » est déjà l'hypothèse par défaut, et
   * l'écran des échéances le dit. Un événement qui ne dit rien de plus que le
   * défaut est du bruit dans un journal.
   */
  connaissanceAt?: Date | null | undefined;
}

export interface CriseOuverte {
@@ -29,6 +44,58 @@ export interface CriseOuverte {
  preparationVersion: number;
}

/**
 * Ouvrir une cellule pendant la crise (EF-302a).
 *
 * Jusqu'ici une cellule ne pouvait naître qu'au déclenchement : l'événement
 * n'était émis que depuis `ouvrirCrise`, et aucune route ne permettait d'en
 * ajouter. Oublier la cellule juridique à la seconde trente, c'était ne pas
 * l'avoir à la troisième heure — un mur, alors qu'une crise est un phénomène
 * mouvant et que le produit ne doit pas être un frein.
 *
 * Le geste est le même qu'au déclenchement, aux mêmes conséquences : la cellule
 * reçoit son salon et sa salle, et son cockpit préparé s'applique de lui-même,
 * puisqu'il est indexé sur le code de cellule.
 *
 * Rouvrir un code déjà ouvert est refusé plutôt qu'ignoré : deux cellules
 * portant le même code partageraient un cockpit et diviseraient une
 * conversation, et personne ne comprendrait laquelle est la bonne.
 */
export async function ouvrirCellule(
  sql: Sql, criseId: string, c: { code: string; libelle: string }, ctx: Contexte,
): Promise<{ celluleId: string }> {
  return (await sql.begin(async (tx) => {
    const [crise] = await tx<{ close_at: Date | null }[]>`
      select close_at from crise where id = ${criseId} for update`;
    if (!crise) throw new Error("Crise inconnue.");
    if (crise.close_at !== null) {
      throw new Error("Cette crise est close : on n'y ouvre plus de cellule.");
    }

    const [existe] = await tx`
      select 1 from cellule where crise_id = ${criseId} and code = ${c.code}`;
    if (existe) {
      throw new Error(
        `Une cellule « ${c.code} » est déjà ouverte sur cette crise. `
        + "Deux cellules de même code partageraient son cockpit et diviseraient sa "
        + "conversation.");
    }

    const celluleId = randomUUID();
    await enregistrer(tx, {
      criseId, type: "cellule.creee",
      charge: { cellule_id: celluleId, code: c.code, libelle: c.libelle },
      ctx,
    });
    // Les mêmes deux conséquences qu'au déclenchement : une cellule sans lieu
    // où parler pousse les gens vers WhatsApp (EF-701), et une salle qui se
    // planifie ne s'ouvre pas à trois heures du matin (EF-801).
    await creerSalonDeCellule(tx, criseId, celluleId, c.code, c.libelle, ctx);
    await ouvrirSalleDeCellule(tx, criseId, celluleId, c.code, c.libelle, ctx);
    return { celluleId };
  })) as { celluleId: string };
}

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");
@@ -68,6 +135,22 @@ export async function ouvrirCrise(sql: Sql, o: OuvertureCrise, ctx: Contexte): P
      ctx,
    });

    // La prise de connaissance, quand elle précède l'ouverture. Inscrite juste
    // après l'événement d'ouverture : elle est un fait de la crise, pas un
    // paramètre de sa création, et le rembobinage doit pouvoir la montrer
    // apparaître.
    if (o.connaissanceAt && o.connaissanceAt.getTime() < Date.now()) {
      await enregistrer(tx, {
        criseId: crise.id,
        type: "crise.connaissance_datee",
        charge: {
          connaissance_at: o.connaissanceAt.toISOString(),
          motif: "Déclarée à l'ouverture de la crise.",
        },
        ctx,
      });
    }

    const cellules: { id: string; code: string }[] = [];
    for (const c of o.cellules ?? []) {
      const celluleId = randomUUID();
+226 −0
Changes for apps/api/src/domaine/premiereHeure.ts: 226 added lines, 0 removed lines.
Original line number Diff line number Diff line
import type { Sql } from "../db/client.js";
import { enregistrer, type Contexte } from "../noyau/enregistrer.js";
import { LIBELLE_CELLULE, type CELLULES } from "@kastell/shared";
import * as crises from "./crise.js";
import * as continuite from "./continuite.js";
import * as mobilisation from "./mobilisation.js";
import * as fiches from "../preparation/fiches.js";
import * as tiersModule from "../preparation/tiers.js";
import * as registres from "../preparation/continuite.js";

/**
 * La première heure (EF-301a, EF-207a).
 *
 * Ouvrir une crise prend quinze secondes, et c'est un acquis qu'il ne faut pas
 * défaire : l'écran de déclenchement porte quatre champs et vise soixante
 * secondes. Mais ce qui suit l'ouverture n'était nulle part. La cellule se
 * retrouvait devant un cockpit vide, avec un dossier gelé complet à deux clics
 * — et deux clics, dans la première heure, ce sont deux clics que personne ne
 * fait.
 *
 * D'où cette lecture, et non un formulaire. Tout ce qui est ici, le dossier le
 * sait déjà : qui compose quelle cellule, quel message part, quelles fiches se
 * déroulent, quels tiers s'activent, quels délais courent. On ne demande donc
 * rien — on propose, et chaque proposition se confirme d'un geste.
 *
 * Ce qui est fait se déduit du journal, sans table d'état. C'est ce qui garantit
 * que le rembobinage montre la première heure telle qu'elle a été, et non telle
 * qu'un écran l'aurait mémorisée.
 */

export interface Propose {
  personne_id: string;
  nom: string;
  role_crise: string | null;
  /** Nul : la personne n'a pas de compte — on la mobilise, on ne l'admet pas. */
  compte_id: string | null;
}

export interface CellulePremiereHeure {
  cellule_id: string;
  code: string;
  libelle: string;
  membres: { compte_id: string; nom: string; role: string }[];
  /** Les personnes du dossier gelé affectées à cette cellule, non encore admises. */
  proposes: Propose[];
}

export interface FichePremiereHeure {
  id: string;
  titre: string;
  ordre: number;
  contenu: string;
  faite: boolean;
}

export interface PremiereHeure {
  crise: {
    type: string;
    type_libelle: string;
    gravite: string;
    declenchee_at: Date;
    connaissance_at: Date | null;
    preparation_version: number | null;
  };
  cellules: CellulePremiereHeure[];
  /** Les cellules du socle qui ne sont pas ouvertes, et ce que le dossier en dit. */
  cellules_fermees: { code: string; libelle: string; personnes: number }[];
  mobilisations: {
    modeles: { id: string; nom: string; roles: string[]; objectif: number | null }[];
    lancees: number;
  };
  fiches: FichePremiereHeure[];
  tiers: { id: string; nom: string; categorie: string; active: boolean }[];
  echeances: {
    depart_declare: boolean;
    /** Le nombre de parties dont le délai court déjà. */
    en_cours: number;
    /** La plus proche, celle qu'on rate. */
    plus_proche: { nom: string; reste_minutes: number | null } | null;
  };
}

export async function lire(sql: Sql, criseId: string): Promise<PremiereHeure | null> {
  const [crise] = await sql<{ organisation_id: string; type: string; gravite: string;
    declenchee_at: Date; connaissance_at: Date | null;
    preparation_version: number | null }[]>`
    select organisation_id, type, gravite, declenchee_at, connaissance_at,
           preparation_version
      from crise where id = ${criseId}`;
  if (!crise) return null;
  const version = crise.preparation_version;

  const [ouvertes, listeFiches, listeTiers, ech, modeles, typesCrise] = await Promise.all([
    crises.cellulesEtMembres(sql, criseId),
    fiches.pour(sql, crise.organisation_id, crise.type, null, version),
    tiersModule.lister(sql, crise.organisation_id, version),
    continuite.echeances(sql, criseId, crise.organisation_id),
    registres.mobilisations.lister(sql, crise.organisation_id, version),
    sql<{ code: string; nom: string }[]>`
      select code, nom from type_crise
       where organisation_id = ${crise.organisation_id} and not supprime
         ${version === null
           ? sql`and jusqu_a_version is null`
           : sql`and depuis_version <= ${version}
                 and (jusqu_a_version is null or jusqu_a_version > ${version})`}`,
  ]);

  // Les personnes du dossier gelé, par cellule. Celles qui n'ont pas de compte
  // sont rendues quand même, et signalées : on peut les mobiliser, pas les
  // faire entrer en cellule — et l'écran doit le dire plutôt que de les taire.
  const personnes = await sql<{ id: string; nom: string; role_crise: string | null;
    cellule: string | null; compte_id: string | null }[]>`
    select id, nom, role_crise, cellule, compte_id from personne
     where organisation_id = ${crise.organisation_id} and not supprimee
       and cellule is not null
       ${version === null
         ? sql`and jusqu_a_version is null`
         : sql`and depuis_version <= ${version}
               and (jusqu_a_version is null or jusqu_a_version > ${version})`}
     order by nom`;

  const deroulees = await sql<{ fiche_id: string; faite: boolean }[]>`
    select distinct on (charge->>'fiche_id')
           charge->>'fiche_id' as fiche_id,
           coalesce((charge->>'faite')::boolean, true) as faite
      from evenement
     where crise_id = ${criseId} and type = 'fiche.deroulee'
     order by charge->>'fiche_id', seq desc`;
  const faites = new Set(deroulees.filter((d) => d.faite).map((d) => d.fiche_id));

  const actives = await sql<{ tiers_id: string }[]>`
    select distinct charge->>'tiers_id' as tiers_id from evenement
     where crise_id = ${criseId} and type = 'tiers.active'`;
  // La liste gelée suffit, et il vaut la peine de dire pourquoi : `tiers.activer`
  // refuse d'activer un tiers absent du dossier gelé de la crise. Un tiers
  // activé y figure donc toujours, et il n'y a pas d'état « activé mais
  // invisible » à rattraper ici. J'avais écrit ce rattrapage avant de le
  // vérifier ; il gardait un cas que le domaine interdit.
  const dejaActives = new Set(actives.map((a) => a.tiers_id));

  const campagnes = await mobilisation.etat(sql, criseId);

  const codesOuverts = new Set(ouvertes.map((c) => c.code));
  const parCellule = (code: string) => personnes
    .filter((p) => p.cellule === code)
    .map((p) => ({
      personne_id: p.id, nom: p.nom, role_crise: p.role_crise, compte_id: p.compte_id,
    }));

  return {
    crise: {
      type: crise.type,
      type_libelle: typesCrise.find((t) => t.code === crise.type)?.nom ?? crise.type,
      gravite: crise.gravite,
      declenchee_at: crise.declenchee_at,
      connaissance_at: crise.connaissance_at,
      preparation_version: version,
    },
    cellules: ouvertes.map((c) => {
      const presents = new Set(c.membres.map((m) => m.compte_id));
      return {
        cellule_id: c.id, code: c.code, libelle: c.libelle,
        membres: c.membres.map((m) => ({
          compte_id: m.compte_id, nom: m.nom, role: m.role,
        })),
        // Ne proposer que ceux qui manquent : une liste où figurent les gens
        // déjà présents fait douter de ce qu'on a fait.
        proposes: parCellule(c.code).filter(
          (p) => p.compte_id === null || !presents.has(p.compte_id)),
      };
    }),
    cellules_fermees: (Object.keys(LIBELLE_CELLULE) as (typeof CELLULES)[number][])
      .filter((code) => !codesOuverts.has(code))
      .map((code) => ({
        code, libelle: LIBELLE_CELLULE[code],
        personnes: parCellule(code).length,
      }))
      .filter((c) => c.personnes > 0),
    mobilisations: {
      modeles: modeles.map((m) => ({
        id: m.id, nom: m.nom, roles: m.roles, objectif: m.objectif,
      })),
      lancees: campagnes.length,
    },
    fiches: listeFiches.map((f) => ({
      id: f.id, titre: f.titre, ordre: f.ordre, contenu: f.contenu,
      faite: faites.has(f.id),
    })),
    tiers: listeTiers.map((t) => ({
      id: t.id, nom: t.nom, categorie: t.categorie, active: dejaActives.has(t.id),
    })),
    echeances: {
      depart_declare: ech.depart_declare,
      en_cours: ech.parties.filter((p) => p.informe_at === null
        && p.reste_minutes !== null).length,
      plus_proche: (() => {
        const restants = ech.parties
          .filter((p) => p.informe_at === null && p.reste_minutes !== null)
          .sort((a, b) => a.reste_minutes! - b.reste_minutes!);
        const p = restants[0];
        return p ? { nom: p.nom, reste_minutes: p.reste_minutes } : null;
      })(),
    },
  };
}

/**
 * Cocher — ou décocher — une étape de fiche réflexe.
 *
 * Décocher est permis, et l'événement le dit plutôt que d'effacer le premier :
 * se tromper de ligne dans la première heure ne doit pas être définitif, et le
 * journal reste en ajout seul.
 */
export async function deroulerFiche(
  sql: Sql, criseId: string, ficheId: string, titre: string, faite: boolean,
  ctx: Contexte,
): Promise<number> {
  return (await sql.begin(async (tx) => {
    const ev = await enregistrer(tx, {
      criseId, celluleId: null, type: "fiche.deroulee",
      charge: { fiche_id: ficheId, titre, faite },
      ctx,
    });
    return ev.seq;
  })) as number;
}
+66 −0
Changes for apps/api/src/routes/crise.ts: 66 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -5,6 +5,7 @@ import type { Sql } from "../db/client.js";
import * as actions from "../domaine/actions.js";
import * as cockpit from "../domaine/cockpit.js";
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";
@@ -62,6 +63,64 @@ export function enregistrerRoutesCrise(app: FastifyInstance, sql: Sql): void {
    cellules: await crises.cellulesEtMembres(sql, criseId(req)),
  }));

  /**
   * La première heure (EF-301a).
   *
   * Une seule lecture, et c'est délibéré : au moment où l'on en a besoin, six
   * allers-retours sur un réseau qui souffre valent cinq occasions d'attendre.
   */
  app.get("/crises/:criseId/premiere-heure", acces, async (req, reply) => {
    const vue = await premiereHeure.lire(sql, criseId(req));
    if (!vue) return reply.code(404).send({ code: "introuvable", message: "Crise inconnue." });
    return reply.send(vue);
  });

  /** Cocher une étape de fiche réflexe (EF-207a). */
  app.post("/crises/:criseId/fiches-reflexes/:sousId/deroulement",
    acces, async (req, reply) => {
      const c = z.object({
        titre: z.string().trim().min(1).max(200),
        faite: z.boolean().default(true),
      }).parse(req.body);
      const seq = await premiereHeure.deroulerFiche(sql, criseId(req), sousId(req),
        c.titre, c.faite, ctx(req));
      return reply.code(201).send({ seq });
    });

  /**
   * Ouvrir une cellule pendant la crise (EF-302a).
   *
   * Le droit est celui du déclenchement, et non celui d'y faire entrer
   * quelqu'un : ouvrir une cellule est un geste d'organisation de la crise, pas
   * de composition. L'animateur d'une cellule compose la sienne ; il ne décide
   * pas qu'il en faut une autre.
   */
  app.post("/crises/:criseId/cellules", acces, async (req, reply) => {
    if (req.role !== "directeur_crise" && req.role !== "proprietaire") {
      return reply.code(403).send({
        code: "role_insuffisant",
        message: "Ouvrir une cellule revient de la direction de crise.",
      });
    }
    const c = z.object({
      code: z.string().trim().min(2).max(40),
      libelle: z.string().trim().min(2).max(120),
    }).parse(req.body);
    try {
      const ouverte = await crises.ouvrirCellule(sql, criseId(req), c, ctx(req));
      return reply.code(201).send({
        ...ouverte,
        message: `Cellule « ${c.libelle} » ouverte, avec son salon et sa salle. `
          + "Son cockpit préparé s'applique s'il en existe un pour ce code.",
      });
    } catch (e) {
      return reply.code(400).send({
        code: "ouverture_refusee",
        message: e instanceof Error ? e.message : "Ouverture refusée.",
      });
    }
  });

  app.post("/crises/:criseId/cellules/:sousId/membres", acces, async (req, reply) => {
    const celluleId = sousId(req);
    if (!await peutComposer(req, celluleId)) {
@@ -97,6 +156,13 @@ export function enregistrerRoutesCrise(app: FastifyInstance, sql: Sql): void {
        code: z.string().trim().min(2).max(40),
        libelle: z.string().trim().min(2).max(120),
      })).default([]),
      /**
       * Quand les faits ont été appris (EF-301b).
       *
       * Trois secondes à l'ouverture, et tous les comptes à rebours
       * réglementaires en découlent. Absente, c'est « à l'instant ».
       */
      connaissanceAt: z.coerce.date().optional(),
    }).parse(req.body);
    const ouverte = await crises.ouvrirCrise(sql, { organisationId, ...c }, ctx(req));
    return reply.code(201).send(ouverte);
+37 −2
Changes for apps/api/src/routes/preparation.ts: 37 added lines, 2 removed lines.
Original line number Diff line number Diff line
import type { FastifyInstance, FastifyRequest } from "fastify";
import { z } from "zod";
import { destinataireSchema, etiquetteSchema, ficheSchema, modeleSchema,
  personneSchema, tiersSchema } from "@kastell/shared";
import { CELLULES, destinataireSchema, etiquetteSchema, ficheSchema,
  LIBELLE_CELLULE, modeleSchema, personneSchema, tiersSchema } from "@kastell/shared";
import type { Env } from "../env.js";
import type { Sql } from "../db/client.js";
import * as annuaire from "../preparation/annuaire.js";
@@ -396,6 +396,41 @@ export function enregistrerRoutesPreparation(app: FastifyInstance, env: Env, sql
    return reply.send(fiche);
  });

  /**
   * Les cellules que le dossier suggère d'ouvrir (EF-301a).
   *
   * L'écran de déclenchement portait quatre cellules écrites en dur, sur les
   * cinq du socle : la cellule métier ne pouvait jamais être ouverte. Et il ne
   * disait pas lesquelles sont réellement armées — on cochait de mémoire.
   *
   * La proposition se lit dans le dossier : les cellules où des gens sont
   * affectés, et celles pour lesquelles un cockpit a été composé. Une cellule
   * sans personne ni cockpit reste offerte, mais elle se voit pour ce qu'elle
   * est : une case qu'on ouvre sans savoir qui va y entrer.
   */
  app.get("/organisations/:id/preparation/cellules", lecture, async (req) => {
    const organisationId = org(req);
    const effectifs = await sql<{ cellule: string; n: number }[]>`
      select cellule, count(*)::int as n from personne_courante
       where organisation_id = ${organisationId} and cellule is not null
       group by cellule`;
    const cockpits = await sql<{ cellule_code: string }[]>`
      select distinct cellule_code from disposition_courante
       where organisation_id = ${organisationId} and cellule_code is not null`;

    const parCellule = new Map(effectifs.map((e) => [e.cellule, e.n]));
    const avecCockpit = new Set(cockpits.map((c) => c.cellule_code));

    return {
      cellules: CELLULES.map((code) => ({
        code,
        libelle: LIBELLE_CELLULE[code],
        personnes: parCellule.get(code) ?? 0,
        cockpit: avecCockpit.has(code),
      })),
    };
  });

  app.get("/crises/:criseId/fiches-reflexes", criseMobile, async (req) => {
    const { criseId } = z.object({ criseId: z.string().uuid() }).parse(req.params);
    const { role } = z.object({ role: z.string().optional() }).parse(req.query);
+142 −0

File changed.

Preview size limit exceeded, changes collapsed.

Loading