Verified Commit 78d5fd56 authored by Kourser's avatar Kourser
Browse files

Dossier gelé : ce qui reste quand il ne reste rien

« Aucun réseau. Annuaire, tiers, fiches réflexes — tels qu'ils étaient au
déclenchement. » C'est le geste du volet mobile qui compte le plus après le
réveil, parce que le jour où l'on ouvre Kastell est souvent le jour où le
réseau d'entreprise est précisément ce qui est tombé.

Téléchargé au déclenchement, pas à l'ouverture
  « Le télécharger au moment où l'on en a besoin, c'est ne pas l'avoir. » Une
  application ne peut pas agir avant d'être ouverte : le plus tôt qu'elle
  puisse faire, c'est l'instant où elle apprend qu'une crise existe — la liste
  des crises, ou un coup frappé à la porte. Le dossier descend là, en tâche de
  fond, sans bloquer l'écran et sans se plaindre si le réseau lâche en route.

Le défaut qui rendait tout cela inutile
  Sans instance, la liste des crises disparaissait — donc le bouton qui mène au
  dossier, donc le dossier. Il n'était consultable qu'avec du réseau, c'est-à-
  dire jamais quand il sert. Cela ne se voyait qu'en éteignant réellement
  l'instance : tous les écrans fonctionnaient, et celui-là aussi, tant qu'on
  pouvait l'atteindre.

  La liste vient maintenant des dossiers eux-mêmes quand le serveur ne répond
  pas. Aucun cache supplémentaire : EM-26 n'autorise que la file et le dossier
  gelé à persister, et le dossier porte désormais la crise entière plutôt que
  son seul intitulé.

Deux autres choses trouvées à l'écran
  L'annuaire n'affichait aucun rôle — le serveur ne l'envoyait pas. Dans un
  annuaire de crise, c'est la colonne la plus utile : on cherche « qui est la
  directrice de crise », pas « où est Maëlle ». Une astreinte ne connaît pas
  les gens par cœur.

  Et « (at ExpoModulesCore/Promise.swift:56) » s'affichait au milieu d'un écran
  de crise. J'avais écrit la fonction qui rend un motif de panne lisible, mais
  seulement pour la sonde d'instance ; elle manquait au client d'API, c'est-à-
  dire partout ailleurs. Elle est maintenant au même endroit pour les deux.

Oublier une instance laissait des traces
  EM-86 promet un effacement intégral. Le trousseau du système ne sait pas
  énumérer ses clés : une liste de noms en dur ne pouvait pas couvrir les
  dossiers gelés, dont le nombre dépend des crises ouvertes. Un inventaire tenu
  à l'écriture les couvre tous, et une garantie vérifie qu'aucun module n'écrit
  dans le trousseau en dehors de ce point de passage.

  À ne pas confondre avec la révocation (EM-22), qui est subie et non choisie :
  elle efface le contenu de crise mis en cache, mais le dossier de préparation
  survit — « il peut être ce qui reste quand tout le reste manque ».

Éprouvé en éteignant l'instance pour de bon : application tuée, relancée sans
serveur, dossier ouvert — annuaire, rôles, numéros composables d'un geste, avec
son âge et un « pas de réseau » qui dit la vérité plutôt qu'une pile d'appels.

764 garanties. Restent à M2 la photo de terrain et la position.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent d78384f7
Loading
Loading
Loading
Loading
Loading
+16 −2
Original line number Diff line number Diff line
@@ -26,6 +26,15 @@ export interface Cible {
  tiersId?: string | null | undefined;
  compteId?: string | null | undefined;
  nom: string;
  /**
   * Le rôle de crise, quand la cible vient de l'annuaire.
   *
   * Dans un annuaire de crise, c'est souvent la colonne la plus utile : on
   * cherche « qui est la directrice de crise », pas « où est Maëlle ». Elle
   * sert au dossier gelé consulté sur un téléphone (EM-27), où l'on n'a ni le
   * temps ni la place de deviner.
   */
  role?: string | null | undefined;
  email?: string | null | undefined;
  telephone?: string | null | undefined;
  /** Identité de l'annuaire vers qui escalader si personne ne répond. */
@@ -222,6 +231,9 @@ export interface Sollicitation {
  intitule: string;
  /** EM-16 : dit avant tout autre mot, y compris sur l'écran verrouillé. */
  exercice: boolean;
  gravite: string;
  phase: string;
  declenchee_at: Date;
  mobilisationId: string;
  envoiId: string;
  message: string;
@@ -254,10 +266,11 @@ export async function sollicitation(
  sql: Sql, appareilId: string, reference: string,
): Promise<Sollicitation | null> {
  const [r] = await sql<{ crise_id: string; intitule: string; exercice: boolean;
    gravite: string; phase: string; declenchee_at: Date;
    mobilisation_id: string; envoi_id: string; message: string; envoye_at: Date;
    emis_at: Date; reponse: Reponse | null; repondu_at: Date | null;
    close_at: Date | null; compte_id: string; modele: string | null }[]>`
    select c.id as crise_id, c.intitule, c.exercice,
    select c.id as crise_id, c.intitule, c.exercice, c.gravite, c.phase, c.declenchee_at,
           e.mobilisation_id, e.id as envoi_id, m.message, e.envoye_at,
           h.emis_at, e.reponse, e.repondu_at, m.close_at,
           a.compte_id, a.modele
@@ -280,6 +293,7 @@ export async function sollicitation(

  return {
    criseId: r.crise_id, intitule: r.intitule, exercice: r.exercice,
    gravite: r.gravite, phase: r.phase, declenchee_at: r.declenchee_at,
    mobilisationId: r.mobilisation_id, envoiId: r.envoi_id, message: r.message,
    envoye_at: r.envoye_at, notifie_at: r.emis_at,
    reponse: r.reponse, repondu_at: r.repondu_at, close_at: r.close_at,
@@ -576,7 +590,7 @@ export async function ciblesDepuisAnnuaire(
    .filter((p) => !roles || roles.length === 0
      || (p.role_crise !== null && roles.includes(p.role_crise)))
    .map((p) => ({
      personneId: p.id, compteId: p.compte_id, nom: p.nom,
      personneId: p.id, compteId: p.compte_id, nom: p.nom, role: p.role_crise,
      // L'adresse personnelle d'abord : c'est celle qui survit au jour J.
      email: p.email_perso ?? p.email_pro,
      telephone: p.tel_perso,
+7 −1
Original line number Diff line number Diff line
@@ -181,7 +181,13 @@ export function enregistrerRoutesMobile(app: FastifyInstance, sql: Sql, env: Env
      // EM-16 : dit avant tout autre mot. Réveiller quelqu'un pour un exercice
      // sans le lui dire est le plus sûr moyen qu'il ignore la vraie.
      exercice: s.exercice,
      crise: { id: s.criseId, intitule: s.intitule },
      // La crise entière, et non son seul intitulé : c'est elle que le
      // téléphone range avec le dossier gelé, pour pouvoir l'ouvrir sans
      // réseau le jour où il n'y en a plus (EM-27).
      crise: {
        id: s.criseId, intitule: s.intitule, exercice: s.exercice,
        gravite: s.gravite, phase: s.phase, declenchee_at: s.declenchee_at,
      },
      mobilisation_id: s.mobilisationId,
      envoi_id: s.envoiId,
      message: s.message,
+14 −2
Original line number Diff line number Diff line
@@ -3,11 +3,23 @@ import { z } from "zod";
import type { Env } from "../env.js";
import type { Sql } from "../db/client.js";
import * as mobilisation from "../domaine/mobilisation.js";
import { requerirAccesCrise } from "./garde.js";
import { requerirAccesCrise, requerirAccesCriseOuAppareil } from "./garde.js";

/** Mobilisation (§6.11). */
export function enregistrerRoutesMobilisation(app: FastifyInstance, env: Env, sql: Sql): void {
  const acces = { preHandler: requerirAccesCrise(sql) };
  /**
   * L'annuaire gelé par la crise, en lecture, depuis un téléphone (EM-27).
   *
   * C'est le même contenu que ce que la console propose pour composer une
   * mobilisation : les personnes de l'annuaire dans la version que la crise a
   * gelée, avec leurs numéros. EM-29 en dépend — « les numéros du dossier gelé
   * se composent d'un geste ».
   *
   * Lancer une mobilisation, elle, reste hors de portée : mobiliser une
   * cellule entière depuis un téléphone n'est pas un des six gestes.
   */
  const accesMobile = { preHandler: requerirAccesCriseOuAppareil(sql) };
  const criseId = (req: FastifyRequest) =>
    z.object({ criseId: z.string().uuid() }).parse(req.params).criseId;
  const sousId = (req: FastifyRequest) =>
@@ -17,7 +29,7 @@ export function enregistrerRoutesMobilisation(app: FastifyInstance, env: Env, sq
  });

  /** Les cibles proposées viennent du dossier gelé, pas de l'annuaire d'aujourd'hui. */
  app.get("/crises/:criseId/mobilisations/cibles", acces, async (req) => {
  app.get("/crises/:criseId/mobilisations/cibles", accesMobile, async (req) => {
    const { roles } = z.object({ roles: z.string().optional() }).parse(req.query);
    return {
      cibles: await mobilisation.ciblesDepuisAnnuaire(sql, criseId(req),
+17 −4
Original line number Diff line number Diff line
@@ -11,7 +11,7 @@ import * as fiches from "../preparation/fiches.js";
import * as importer from "../preparation/import.js";
import * as indicateur from "../preparation/indicateur.js";
import * as tiers from "../preparation/tiers.js";
import { REDACTEURS, requerirAccesCrise, requerirMembre } from "./garde.js";
import { REDACTEURS, requerirAccesCrise, requerirAccesCriseOuAppareil, requerirMembre } from "./garde.js";

/** Dossier de préparation (§6.2) et activation des tiers depuis une crise. */

@@ -23,6 +23,19 @@ export function enregistrerRoutesPreparation(app: FastifyInstance, env: Env, sql
  const lecture = { preHandler: requerirMembre(sql) };
  const ecriture = { preHandler: requerirMembre(sql, REDACTEURS) };
  const crise = { preHandler: requerirAccesCrise(sql) };
  /**
   * Le dossier gelé, et lui seul, s'ouvre à un téléphone enrôlé (EM-27).
   *
   * « Le dossier de préparation survit à la révocation : il n'est pas du
   * contenu de crise, et il peut être ce qui reste quand tout le reste
   * manque. » C'est aussi ce qui justifie de le laisser descendre sur un
   * appareil : un annuaire de crise sans réseau vaut mieux qu'un cockpit
   * inaccessible.
   *
   * En lecture seulement : l'activation d'un tiers reste une écriture, et elle
   * garde le garde d'origine.
   */
  const criseMobile = { preHandler: requerirAccesCriseOuAppareil(sql) };

  const org = (req: FastifyRequest) => z.object({ id: z.string().uuid() }).parse(req.params).id;
  const sousId = (req: FastifyRequest) =>
@@ -215,7 +228,7 @@ export function enregistrerRoutesPreparation(app: FastifyInstance, env: Env, sql
  });

  // ── Depuis une crise : le dossier gelé (EF-206, EF-302) ──────────────────
  app.get("/crises/:criseId/tiers", crise, async (req) => {
  app.get("/crises/:criseId/tiers", criseMobile, async (req) => {
    const { criseId } = z.object({ criseId: z.string().uuid() }).parse(req.params);
    const [c] = await sql<{ organisation_id: string; preparation_version: number | null }[]>`
      select organisation_id, preparation_version from crise where id = ${criseId}`;
@@ -225,14 +238,14 @@ export function enregistrerRoutesPreparation(app: FastifyInstance, env: Env, sql
    };
  });

  app.get("/crises/:criseId/tiers/:sousId", crise, async (req, reply) => {
  app.get("/crises/:criseId/tiers/:sousId", criseMobile, async (req, reply) => {
    const { criseId } = z.object({ criseId: z.string().uuid() }).parse(req.params);
    const fiche = await tiers.ficheDActivation(sql, criseId, sousId(req));
    if (!fiche) return reply.code(404).send({ code: "introuvable", message: "Tiers introuvable." });
    return reply.send(fiche);
  });

  app.get("/crises/:criseId/fiches-reflexes", crise, async (req) => {
  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);
    const [c] = await sql<
+21 −4
Original line number Diff line number Diff line
@@ -230,8 +230,19 @@ export async function verifierApplicationMobile(r: Rapport): Promise<void> {
    /AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY/.test(registre));
  r.verifie("EM-80", "chaque instance a son compartiment de stockage",
    /export function prefixe/.test(registre));
  r.verifie("EM-86", "quitter une instance efface toutes ses clés",
    /export async function oublier/.test(registre) && /CLES_COMPARTIMENT/.test(registre));
  // EM-86 : « quitter une instance efface intégralement ses données locales, y
  // compris sa file hors ligne non remise ». Le trousseau du système ne sait
  // pas énumérer ses clés : l'effacement ne peut être exhaustif que si tout ce
  // qui s'écrit passe par le même point et s'y inscrit. Une liste de noms en
  // dur ne pouvait pas couvrir les dossiers gelés, dont le nombre dépend des
  // crises ouvertes.
  r.verifie("EM-86", "quitter une instance efface toutes ses clés, même non prévues",
    /export async function oublier/.test(registre) && /clesEcrites\(/.test(registre));
  const ecrivainsSauvages = [...lus].filter(([nom, texte]) =>
    nom !== "src/instance/registre.ts"
    && /SecureStore\.setItemAsync/.test(sansCommentaires(texte)));
  r.verifie("EM-86", "aucun module n'écrit dans le trousseau hors de l'inventaire",
    ecrivainsSauvages.length === 0, ecrivainsSauvages.map(([n]) => n).join(", "));

  // ── EM-64 : cibles tactiles ─────────────────────────────────────────────
  const theme = lus.get("src/theme.ts") ?? "";
@@ -305,9 +316,15 @@ export async function verifierApplicationMobile(r: Rapport): Promise<void> {
  const ecranMobilisation = lus.get("src/ecrans/Mobilisation.tsx") ?? "";
  r.verifie("EM-21", "répondre depuis l'application passe par ce déverrouillage",
    /deverrouillage\.exiger\(/.test(ecranMobilisation));
  // EM-16 : « une notification de mobilisation reçue pour une crise marquée
  // exercice le dit sur l'écran verrouillé, avant tout autre mot. » À l'écran,
  // « avant » veut dire plus haut dans le rendu — on mesure donc l'ordre dans
  // le JSX, pas dans le fichier : la logique au-dessus peut citer l'intitulé
  // sans que cela change ce que la personne lit en premier.
  const rendu = ecranMobilisation.slice(ecranMobilisation.lastIndexOf("return ("));
  r.verifie("EM-16", "l'exercice est annoncé avant l'intitulé de la crise",
    ecranMobilisation.indexOf("EXERCICE") < ecranMobilisation.indexOf("crise.intitule")
      && ecranMobilisation.includes("EXERCICE"));
    rendu.includes("EXERCICE")
      && rendu.indexOf("EXERCICE") < rendu.indexOf("crise.intitule"));

  // ── EM-42, EM-74 : la clause réseau ne s'arrête pas au navigateur ───────
  const apropos = lus.get("src/ecrans/APropos.tsx") ?? "";
Loading