Verified Commit 169d447c authored by Kourser's avatar Kourser
Browse files

Mobile M1 : être joint, et répondre

Le jalon pour lequel l'application existe. Tout ce qui précédait — enrôlement,
jeton, heurtoir — n'existait que pour amener quelqu'un jusqu'à trois boutons,
à trois heures du matin.

Le serveur ne connaissait qu'une réponse ; il en fallait trois
  « Accusé », c'était juste tant que le seul canal était un lien dans un SMS :
  on ne clique pas par erreur, et on ne clique pas pour dire non.

  Désormais : j'arrive, indisponible, appelez mon suppléant. La distinction
  n'est pas cosmétique. « J'arrive » clôt la recherche. « Indisponible »
  l'ouvre ailleurs. « Suppléant » désigne où — et l'escalade part aussitôt,
  sans attendre le délai : honorer vingt minutes plus tard une demande qui
  vient d'être formulée n'ajouterait qu'un retard.

  Conséquence la plus importante : le silence et le refus mènent tous deux à
  l'escalade, pour des raisons différentes, et la console les distingue. Un
  tableau qui affiche « 4 sans réponse » alors que trois ont dit « je ne peux
  pas » fait chercher au mauvais endroit, à l'heure où le temps compte le plus.
  Le journal dit lequel des deux, pour que le retour d'expérience ne corrige
  pas le mauvais dispositif.

  EM-14 ajoute l'état manquant : remis à l'appareil, vu, répondu. « Remis » ne
  dit pas que quelqu'un a regardé son téléphone.

Le contenu ne transite toujours pas par Apple
  La notification ne porte qu'une référence opaque. L'application la résout
  auprès de son instance, après authentification de l'appareil — et la
  résoudre, c'est précisément avoir ouvert la notification, d'où le « vu ».
  Une référence qui ne concerne pas cet appareil répond comme une référence
  inconnue : sans quoi un appareil enrôlé pourrait sonder les autres.

Une exigence en contredisait une autre, et le mauvais arbitrage était muet
  EM-11 veut qu'on réponde sans déverrouiller. EM-61 veut que le secret
  d'appareil ne parte dans aucune sauvegarde. Le trousseau les concilie —
  AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY — mais WHEN_UNLOCKED, qui semble plus
  prudent, aurait rendu le secret illisible à l'instant précis où l'écran est
  verrouillé. La réponse serait partie à vide, et l'échec ne se serait vu qu'à
  trois heures du matin, le jour où quelqu'un compte dessus.

  L'origine « mobile » entre au journal (EM-33) : au rejeu, ce qui a été saisi
  debout sur cinq pouces ne s'enseigne pas comme ce qui a été saisi en salle de
  crise.

Deux défauts trouvés à l'écran, pas à la relecture
  Le dernier heurtoir affichait l'heure de sa lecture, pas celle du coup
  frappé — au réveil, il aurait annoncé 7 h 04 pour une notification de 3 h 12,
  c'est-à-dire effacé le seul chiffre qui dit si le canal réveille.

  Et l'horodatage système est en secondes sur iOS, en millisecondes ailleurs :
  l'écran affichait « 21 janvier 1970 » avec aplomb, à côté d'un intitulé de
  crise. Les deux sont maintenant des garanties exécutées.

680 garanties, dont 33 nouvelles. Éprouvé de bout en bout sur un iPhone
simulé, contre l'instance de développement : mobilisation lancée, heurtoir
remis, référence résolue, « j'arrive » envoyé — et au journal, un événement
d'origine « mobile », délai 299 secondes compté depuis le coup frappé.

Ce que le simulateur ne prouve pas : le déverrouillage biométrique, qu'aucune
empreinte n'y est enrôlée, et les trois boutons sur l'écran verrouillé — ils
exigent une vraie remise APNs, donc un appareil et un relais.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 73c5f556
Loading
Loading
Loading
Loading
+17 −1
Original line number Diff line number Diff line
@@ -29,6 +29,20 @@ export interface Heurtoir {
   * décide en dernier ressort, selon ce que la personne a accordé.
   */
  reveiller: boolean;
  /**
   * EM-16 : « une notification de mobilisation reçue pour une crise marquée
   * exercice le dit sur l'écran verrouillé, avant tout autre mot. »
   *
   * C'est un booléen, et pas une chaîne, précisément pour rester fidèle à la
   * règle du heurtoir : le relais détient deux libellés fixes et choisit entre
   * eux ; il n'existe toujours aucun champ où glisser une phrase.
   *
   * Ce que cela concède : le relais apprend qu'un appareil est sollicité pour
   * un exercice plutôt que pour un événement réel. C'est peu au regard de ce
   * que cela évite — quelqu'un qu'on réveille sans lui dire que c'est pour
   * rire, et qui la fois suivante ne se lèvera pas.
   */
  exercice: boolean;
}

export interface Poussee {
@@ -67,7 +81,8 @@ export function pousseeConsole(): Poussee {
      console.log(
        `\n┌─ heurtoir (non remis, transport « console »)\n`
        + `│ appareil  : ${h.plateforme} ${h.jeton.slice(0, 12)}…\n`
        + `│ urgence   : ${h.urgence}${h.reveiller ? " · réveiller" : ""}\n`
        + `│ urgence   : ${h.urgence}${h.reveiller ? " · réveiller" : ""}`
        + `${h.exercice ? " · exercice" : ""}\n`
        + `│ référence : ${h.reference}\n└─\n`,
      );
    },
@@ -113,6 +128,7 @@ export function pousseeRelais(url: string, cle: string, delaiMs = 8000): Poussee
          reference: h.reference,
          urgence: h.urgence,
          reveiller: h.reveiller,
          exercice: h.exercice,
        }),
        signal: AbortSignal.timeout(delaiMs),
      });
+84 −0
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- Répondre à une mobilisation depuis un téléphone (EM-11, EM-14)
--
-- Jusqu'ici, une mobilisation ne connaissait qu'une réponse : « accusé ». Un
-- clic sur un lien, et la personne était comptée présente. C'était juste tant
-- que le seul canal était un lien dans un SMS — on ne clique pas par erreur, et
-- on ne clique pas pour dire non.
--
-- Le volet mobile en exige trois, et la distinction n'est pas cosmétique :
--
--   j'arrive        la personne vient. C'est le seul cas où la cellule peut
--                   cesser de chercher quelqu'un.
--   indisponible    la personne a répondu, et ne vient pas. Continuer à la
--                   relancer serait du bruit ; ne pas chercher quelqu'un
--                   d'autre serait une faute.
--   suppléant       la personne demande explicitement qu'on appelle son
--                   suppléant. Honorer cette demande vingt minutes plus tard,
--                   au titre du délai d'escalade, serait absurde.
--
-- D'où la conséquence la plus importante de cette migration : le silence et le
-- refus mènent tous deux à l'escalade, mais pour des raisons différentes, et la
-- console doit pouvoir les distinguer. Un tableau qui affiche « 4 sans
-- réponse » alors que trois ont répondu « je ne peux pas » fait chercher au
-- mauvais endroit, à l'heure où le temps compte le plus.
--
-- EM-14 ajoute un troisième état intermédiaire : remis à l'appareil, vu, puis
-- répondu. « Remis » ne dit pas que quelqu'un a regardé son téléphone.
-- ═══════════════════════════════════════════════════════════════════════════

alter table mobilisation_envoi
  add column reponse text
    check (reponse in ('j_arrive', 'indisponible', 'suppleant')),
  add column repondu_at    timestamptz,
  add column repondu_canal text,
  -- L'heure de la notification qui a produit cette réponse (EM-11). Elle
  -- diffère de envoye_at dès qu'il y a eu une relance, et c'est l'écart entre
  -- les deux qui dit si le canal réveille vraiment.
  add column notifie_at    timestamptz,
  add column vu_at         timestamptz;

create index envoi_sans_reponse_reelle on mobilisation_envoi (envoye_at)
  where accuse_at is null and repondu_at is null;

-- Le heurtoir sait désormais s'il a été regardé. C'est l'application qui le
-- dit, en résolvant la référence : résoudre une référence opaque, c'est
-- précisément avoir ouvert la notification.
alter table heurtoir add column vu_at timestamptz;

/**
 * Ce que le cockpit montre de la première heure : qui vient, qui ne vient pas,
 * et qui n'a rien dit. Les trois se comptent séparément.
 */
create or replace view mobilisation_etat as
  select m.crise_id, m.id, m.lancee_at, m.close_at, m.message,
         count(e.*)                                                as envoyes,
         count(e.*) filter (where e.accuse_at is not null)          as accuses,
         -- Le silence, et lui seul. Quelqu'un qui a dit « je ne peux pas » a
         -- répondu : le compter parmi les sans-réponse ferait chercher une
         -- réponse qu'on a déjà.
         count(e.*) filter (where e.accuse_at is null
                              and e.repondu_at is null)             as sans_reponse,
         count(e.*) filter (where e.escalade_at is not null)        as escalades,
         count(e.*) filter (where e.vu_at is not null)              as vus,
         count(e.*) filter (where e.repondu_at is not null)         as repondus,
         count(e.*) filter (where e.reponse = 'j_arrive')           as attendus,
         count(e.*) filter (where e.reponse in ('indisponible',
                                                'suppleant'))       as excuses
    from mobilisation m
    left join mobilisation_envoi e on e.crise_id = m.crise_id and e.mobilisation_id = m.id
   group by m.crise_id, m.id, m.lancee_at, m.close_at, m.message;

-- ── L'origine « mobile » (EM-33) ────────────────────────────────────────────
--
-- Elle n'est pas une nuance de « humaine ». Au rejeu, il faut pouvoir
-- distinguer ce qui a été saisi sur le terrain, debout, sur cinq pouces, de ce
-- qui a été saisi en salle de crise devant un grand écran : les deux n'ont ni
-- la même fiabilité ni le même contexte, et une analyse d'après-crise qui les
-- confond tire de mauvaises leçons.
--
-- Modifier une contrainte ne touche aucune ligne : le journal reste en ajout
-- seul, et les déclencheurs qui refusent UPDATE et DELETE restent en place.
alter table evenement drop constraint evenement_origine_check;
alter table evenement add constraint evenement_origine_check
  check (origine in ('humaine', 'automatique', 'externe', 'mobile'));
+203 −11
Original line number Diff line number Diff line
@@ -49,6 +49,11 @@ export interface Resultat {
async function expedier(
  sql: Sql, criseId: string, mobilisationId: string, envoiId: string,
  cible: Cible, message: string, urlPublique: string,
  // EM-16 : porté jusqu'au heurtoir, pour que l'écran verrouillé le dise avant
  // tout autre mot. Un paramètre plutôt qu'une requête ici : cette fonction est
  // appelée une fois par destinataire, et la crise ne change pas de nature
  // entre deux envois.
  exercice = false,
): Promise<{ canaux: Canal[]; echecs: { canal: string; motif: string }[]; jeton: string }> {
  const jeton = engendrer();
  const lien = `${urlPublique}/mobilisation?jeton=${jeton}`;
@@ -99,7 +104,7 @@ async function expedier(
    // l'instance. Ni le message, ni le nom de la crise ne partent d'ici — il
    // n'existe aucun champ où les mettre.
    try {
      const remise = await frapper(sql, cible.compteId, envoiId, "mobilisation");
      const remise = await frapper(sql, cible.compteId, envoiId, "mobilisation", exercice);
      if (remise.remis > 0) canaux.push("mobile");
      // Un jeton mort est une panne de canal, pas un détail : il bascule sur
      // le SMS, déjà tenté ci-dessus, et il se voit (EM-15).
@@ -135,10 +140,15 @@ export async function lancer(
                            escalade_minutes = ${l.escaladeMinutes ?? 25}
     where crise_id = ${l.criseId} and id = ${mobilisationId}`;

  // Lu une fois : la crise ne change pas de nature entre deux destinataires.
  const [nature] = await sql<{ exercice: boolean }[]>`
    select exercice from crise where id = ${l.criseId}`;
  const exercice = nature?.exercice ?? false;

  for (const cible of l.cibles) {
    const envoiId = randomUUID();
    const expedition = await expedier(sql, l.criseId, mobilisationId, envoiId, cible,
      l.message, urlPublique);
      l.message, urlPublique, exercice);
    for (const c of expedition.canaux) canauxGlobaux.add(c);
    for (const e of expedition.echecs) echecs.push({ nom: cible.nom, ...e });

@@ -197,6 +207,158 @@ export async function accuser(
  return { nom: e.nom, delaiSecondes: delai };
}

// ═══════════════════════════════════════════════════════════════════════════
// Répondre depuis un téléphone (EM-11, EM-14)
// ═══════════════════════════════════════════════════════════════════════════

export type Reponse = "j_arrive" | "indisponible" | "suppleant";

export interface Sollicitation {
  criseId: string;
  /**
   * L'intitulé n'a jamais transité par le service de notification : il est lu
   * ici, après authentification de l'appareil, comme l'exige EM-09.
   */
  intitule: string;
  /** EM-16 : dit avant tout autre mot, y compris sur l'écran verrouillé. */
  exercice: boolean;
  mobilisationId: string;
  envoiId: string;
  message: string;
  envoye_at: Date;
  /** L'heure du coup frappé, distincte de celle de l'envoi dès la première relance. */
  notifie_at: Date | null;
  reponse: Reponse | null;
  repondu_at: Date | null;
  close_at: Date | null;
  /** Le compte que cet appareil porte : c'est lui l'acteur au journal. */
  compteId: string;
  /** Le modèle de l'appareil, inscrit à l'événement (EM-33). */
  appareil: string | null;
}

/**
 * Ce qu'une référence opaque désigne — pour l'appareil qui l'a reçue, et pour
 * lui seul.
 *
 * Trois conditions, et aucune n'est superflue : le heurtoir doit avoir été
 * frappé à cet appareil, l'envoi doit viser le compte de cet appareil, et la
 * référence doit exister. Sans la première, un appareil enrôlé pourrait
 * énumérer les mobilisations d'autres personnes en tirant des identifiants au
 * hasard.
 *
 * L'appel marque le heurtoir comme vu : résoudre une référence opaque, c'est
 * précisément avoir ouvert la notification (EM-14).
 */
export async function sollicitation(
  sql: Sql, appareilId: string, reference: string,
): Promise<Sollicitation | null> {
  const [r] = await sql<{ crise_id: string; intitule: string; exercice: boolean;
    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,
           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
      from heurtoir h
      join appareil a on a.id = h.appareil_id
      join mobilisation_envoi e on e.id = h.reference and e.compte_id = a.compte_id
      join mobilisation m on m.crise_id = e.crise_id and m.id = e.mobilisation_id
      join crise c on c.id = e.crise_id
     where h.appareil_id = ${appareilId} and h.reference = ${reference}
     order by h.emis_at desc
     limit 1`;
  if (!r) return null;

  await sql`
    update heurtoir set vu_at = coalesce(vu_at, now())
     where appareil_id = ${appareilId} and reference = ${reference}`;
  await sql`
    update mobilisation_envoi set vu_at = coalesce(vu_at, now())
     where crise_id = ${r.crise_id} and id = ${r.envoi_id}`;

  return {
    criseId: r.crise_id, intitule: r.intitule, exercice: r.exercice,
    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,
    compteId: r.compte_id, appareil: r.modele,
  };
}

export interface ReponseEnregistree {
  reponse: Reponse;
  delaiSecondes: number;
  notifie_at: Date | null;
  dejaRepondu: boolean;
}

/**
 * Enregistrer l'une des trois réponses (EM-11).
 *
 * Le délai est compté depuis le coup frappé, pas depuis l'envoi de la
 * mobilisation : c'est le seul chiffre qui mesure ce qu'on veut mesurer —
 * combien de temps il a fallu à cette personne pour répondre à *cette*
 * sollicitation-là. Compté depuis l'envoi, il additionnerait le temps qu'a mis
 * la relance à partir, et flatterait un canal lent.
 *
 * Changer d'avis est permis, et chaque réponse est un événement de plus au
 * journal : « je ne peux pas » puis « finalement j'arrive » est un fait, pas
 * une correction. Répéter la même réponse, en revanche, n'en produit aucun —
 * un doigt qui appuie deux fois n'est pas un second avis.
 */
export async function repondre(
  sql: Sql, appareilId: string, reference: string, reponse: Reponse,
): Promise<ReponseEnregistree | null> {
  const s = await sollicitation(sql, appareilId, reference);
  if (!s) return null;

  const depuis = (s.notifie_at ?? s.envoye_at).getTime();
  const delai = Math.max(0, Math.round((Date.now() - depuis) / 1000));

  if (s.reponse === reponse) {
    return { reponse, delaiSecondes: delai, notifie_at: s.notifie_at, dejaRepondu: true };
  }

  const [nom] = await sql<{ nom: string }[]>`
    select nom from mobilisation_envoi
     where crise_id = ${s.criseId} and id = ${s.envoiId}`;

  await sql.begin(async (tx) => {
    await enregistrer(tx, {
      criseId: s.criseId, type: "mobilisation.repondue",
      charge: {
        mobilisation_id: s.mobilisationId, envoi_id: s.envoiId, nom: nom?.nom ?? "",
        reponse, canal: "mobile", delai_secondes: delai,
        notifie_at: s.notifie_at?.toISOString() ?? null,
        appareil: s.appareil,
      },
      // L'acteur est la personne, pas l'appareil : c'est elle qui a répondu.
      // L'appareil est un fait de contexte, il est dans la charge.
      ctx: { acteurId: s.compteId, origine: "mobile" },
    });
    await tx`
      update mobilisation_envoi
         set reponse = ${reponse}, repondu_at = now(), repondu_canal = 'mobile',
             notifie_at = ${s.notifie_at},
             -- « Accusé » veut dire présent. Seul « j'arrive » l'est ; changer
             -- d'avis dans l'autre sens doit donc retirer la présence, sans
             -- quoi la cellule compterait sur quelqu'un qui vient de dire non.
             accuse_at = ${reponse === "j_arrive" ? sql`now()` : sql`null`},
             accuse_canal = ${reponse === "j_arrive" ? "mobile" : null}
       where crise_id = ${s.criseId} and id = ${s.envoiId}`;
  });

  await publier({
    criseId: s.criseId, seq: null, genre: "mobilisation.repondue",
    charge: { mobilisation_id: s.mobilisationId, envoi_id: s.envoiId,
      nom: nom?.nom ?? "", reponse },
  });

  return { reponse, delaiSecondes: delai, notifie_at: s.notifie_at, dejaRepondu: false };
}

export interface EtatMobilisation {
  id: string;
  lancee_at: Date;
@@ -204,14 +366,20 @@ export interface EtatMobilisation {
  message: string;
  envoyes: string;
  accuses: string;
  /** Le silence, et lui seul : quelqu'un qui a dit non a répondu. */
  sans_reponse: string;
  escalades: string;
  vus: string;
  repondus: string;
  attendus: string;
  excuses: string;
}

export async function etat(sql: Sql, criseId: string): Promise<EtatMobilisation[]> {
  return sql<EtatMobilisation[]>`
    select id, lancee_at, close_at, message, envoyes::text, accuses::text,
           sans_reponse::text, escalades::text
           sans_reponse::text, escalades::text, vus::text, repondus::text,
           attendus::text, excuses::text
      from mobilisation_etat where crise_id = ${criseId}
     order by lancee_at desc`;
}
@@ -272,12 +440,14 @@ export async function relancerEtEscalader(
  // ── Relances ─────────────────────────────────────────────────────────────
  const aRelancer = await sql<{ crise_id: string; id: string; mobilisation_id: string;
    nom: string; email: string | null; telephone: string | null; compte_id: string | null;
    relances: number; message: string }[]>`
    relances: number; message: string; exercice: boolean }[]>`
    select e.crise_id, e.id, e.mobilisation_id, e.nom, e.email, e.telephone, e.compte_id,
           e.relances, m.message
           e.relances, m.message, c.exercice
      from mobilisation_envoi e
      join mobilisation m on m.crise_id = e.crise_id and m.id = e.mobilisation_id
     where e.accuse_at is null and m.close_at is null and e.escalade_at is null
      join crise c on c.id = e.crise_id
     where e.accuse_at is null and e.repondu_at is null
       and m.close_at is null and e.escalade_at is null
       and coalesce(e.derniere_relance_at, e.envoye_at)
           < now() - (m.relance_minutes || ' minutes')::interval
       and e.relances < 3
@@ -287,7 +457,7 @@ export async function relancerEtEscalader(
    const rang = e.relances + 1;
    await expedier(sql, e.crise_id, e.mobilisation_id, e.id, {
      nom: e.nom, email: e.email, telephone: e.telephone, compteId: e.compte_id,
    }, `Rappel ${rang}${e.message}`, urlPublique).catch(() => undefined);
    }, `Rappel ${rang}${e.message}`, urlPublique, e.exercice).catch(() => undefined);

    await sql.begin((tx) => enregistrer(tx, {
      criseId: e.crise_id, type: "mobilisation.relancee",
@@ -300,14 +470,30 @@ export async function relancerEtEscalader(
  }

  // ── Escalade vers le suppléant ───────────────────────────────────────────
  //
  // Deux chemins mènent ici, et le délai ne s'applique qu'au premier.
  //
  //   Le silence — on ne sait pas, on attend le délai prévu, puis on cherche
  //   ailleurs.
  //
  //   Le refus explicite — « je ne peux pas » ou « appelez mon suppléant ». On
  //   sait. Attendre vingt minutes de plus pour honorer une demande qui vient
  //   d'être formulée n'ajouterait rien qu'un retard.
  const aEscalader = await sql<{ crise_id: string; id: string; mobilisation_id: string;
    nom: string; escalade_vers: string; envoye_at: Date; message: string }[]>`
    select e.crise_id, e.id, e.mobilisation_id, e.nom, e.escalade_vers, e.envoye_at, m.message
    nom: string; escalade_vers: string; envoye_at: Date; message: string;
    reponse: Reponse | null; exercice: boolean }[]>`
    select e.crise_id, e.id, e.mobilisation_id, e.nom, e.escalade_vers, e.envoye_at,
           m.message, e.reponse, c.exercice
      from mobilisation_envoi e
      join mobilisation m on m.crise_id = e.crise_id and m.id = e.mobilisation_id
      join crise c on c.id = e.crise_id
     where e.accuse_at is null and m.close_at is null and e.escalade_at is null
       and e.escalade_vers is not null
       and e.envoye_at < now() - (m.escalade_minutes || ' minutes')::interval
       and (
         e.reponse in ('indisponible', 'suppleant')
         or (e.repondu_at is null
             and e.envoye_at < now() - (m.escalade_minutes || ' minutes')::interval)
       )
     limit 100`;

  for (const e of aEscalader) {
@@ -323,7 +509,7 @@ export async function relancerEtEscalader(
      email: suppleant.email_perso ?? suppleant.email_pro,
      telephone: suppleant.tel_perso,
      compteId: suppleant.compte_id,
    }, `Vous êtes sollicité en suppléance de ${e.nom}. ${e.message}`, urlPublique)
    }, `Vous êtes sollicité en suppléance de ${e.nom}. ${e.message}`, urlPublique, e.exercice)
      .catch(() => null);

    const minutes = Math.round((Date.now() - e.envoye_at.getTime()) / 60000);
@@ -333,6 +519,12 @@ export async function relancerEtEscalader(
        charge: {
          mobilisation_id: e.mobilisation_id, envoi_id: e.id, nom: e.nom,
          vers: suppleant.nom, apres_minutes: minutes,
          // Au rejeu, « personne n'a répondu » et « la personne a dit non » ne
          // s'enseignent pas la même chose. L'un accuse le canal, l'autre le
          // dispositif d'astreinte.
          motif: e.reponse === "suppleant" ? "suppleant_demande"
            : e.reponse === "indisponible" ? "indisponible"
            : "silence",
        },
        ctx: { acteurId: null, origine: "automatique" },
      });
+2 −1
Original line number Diff line number Diff line
@@ -230,6 +230,7 @@ export interface Remise {
 */
export async function frapper(
  sql: Sql, compteId: string, reference: string, urgence: Urgence,
  exercice = false,
): Promise<Remise> {
  const appareils = await sql<{ id: string; jeton_poussee: string;
    plateforme: "ios" | "android" }[]>`
@@ -247,7 +248,7 @@ export async function frapper(
      if (!pousseeConfiguree()) throw new Error("Aucun relais configuré.");
      await poussee().frapper({
        jeton: a.jeton_poussee, plateforme: a.plateforme,
        reference, urgence,
        reference, urgence, exercice,
        // Seule la mobilisation demande à réveiller. Un arbitrage attendu peut
        // patienter jusqu'au prochain regard sur l'écran.
        reveiller: urgence === "mobilisation",
+60 −0
Original line number Diff line number Diff line
@@ -5,6 +5,7 @@ import type { Env } from "../env.js";
import * as sessions from "../auth/sessions.js";
import { effacerTentatives, noterTentative, verifierLimite } from "../auth/limites.js";
import * as appareils from "../mobile/appareils.js";
import * as mobilisation from "../domaine/mobilisation.js";
import { pousseeConfiguree, poussee } from "../adaptateurs/poussee.js";

/**
@@ -158,6 +159,65 @@ export function enregistrerRoutesMobile(app: FastifyInstance, sql: Sql, env: Env
    return reply.code(204).send();
  });

  // ── Ce qu'une référence opaque désigne (EM-09, EM-11, EM-16) ─────────────
  //
  // C'est ici que le heurtoir prend un sens. La notification ne portait qu'un
  // identifiant ; le contenu se lit ici, après authentification de l'appareil,
  // et ne transite donc jamais par Apple ou Google.
  app.get("/appareils/moi/heurtoirs/:reference", depuisLAppareil, async (req, reply) => {
    const { reference } = z.object({ reference: z.string().uuid() }).parse(req.params);
    const s = await mobilisation.sollicitation(sql, req.appareilId!, reference);
    if (!s) {
      // Le même message qu'une référence inconnue : un appareil enrôlé ne doit
      // pas pouvoir distinguer « cette mobilisation n'existe pas » de « elle
      // ne vous concerne pas », sans quoi il pourrait sonder les autres.
      return reply.code(404).send({
        code: "heurtoir_inconnu",
        message: "Cette notification ne correspond à rien que cet appareil puisse voir.",
      });
    }
    return reply.send({
      genre: "mobilisation",
      // 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 },
      mobilisation_id: s.mobilisationId,
      envoi_id: s.envoiId,
      message: s.message,
      envoye_at: s.envoye_at,
      notifie_at: s.notifie_at,
      close_at: s.close_at,
      reponse: s.reponse,
      repondu_at: s.repondu_at,
    });
  });

  // ── Répondre, en un geste (EM-11) ────────────────────────────────────────
  app.post("/appareils/moi/heurtoirs/:reference/reponse", depuisLAppareil, async (req, reply) => {
    const { reference } = z.object({ reference: z.string().uuid() }).parse(req.params);
    const { reponse } = z.object({
      reponse: z.enum(["j_arrive", "indisponible", "suppleant"]),
    }).parse(req.body);

    const r = await mobilisation.repondre(sql, req.appareilId!, reference, reponse);
    if (!r) {
      return reply.code(404).send({
        code: "heurtoir_inconnu",
        message: "Cette notification ne correspond à rien que cet appareil puisse voir.",
      });
    }
    // Répondre deux fois la même chose n'est pas une erreur : un doigt qui
    // appuie deux fois n'est pas un second avis, et l'appareil n'a pas à
    // s'excuser d'un réseau qui a coupé entre l'envoi et l'accusé (EM-31).
    return reply.code(r.dejaRepondu ? 200 : 201).send({
      reponse: r.reponse,
      delai_secondes: r.delaiSecondes,
      notifie_at: r.notifie_at,
      deja_repondu: r.dejaRepondu,
    });
  });

  /**
   * Ce que l'appareil sait de lui-même au réveil. C'est aussi le contrôle de
   * vie du jeton : une application non ouverte depuis des mois appelle ceci en
Loading