Commit b1fe6db5 authored by Kourser's avatar Kourser
Browse files

M3 complet : rejoindre l'appel, audio seul, écran verrouillé

Le dernier geste des six, et le dernier jalon du volet mobile.

Ce que le web mobile ne sait pas faire
  C'est la deuxième chose après le réveil : dans un navigateur, l'appel tombe
  dès que l'écran se verrouille. « En voiture. L'audio seul, en arrière-plan,
  sans regarder l'écran. » Sans le mode audio d'arrière-plan déclaré à
  l'application, tout le reste de ce travail ne servirait qu'à quelqu'un qui
  regarde son téléphone — c'est-à-dire jamais en conduisant.

Trois refus, et ce ne sont pas des limitations provisoires
  Aucune vidéo, ni envoyée ni reçue. Ce n'est pas une caméra qu'on branchera
  plus tard : quelqu'un qui conduit ne regarde pas son écran, et une vidéo
  qu'on ne regarde pas consomme batterie et réseau pour rien (EM-69).

  Le micro coupé à l'entrée. On rejoint pour écouter ; on parle quand on décide
  de parler. L'inverse ferait entrer dans une cellule de crise le bruit d'un
  habitacle et la conversation d'un passager.

  Ouvrir une salle, la fermer, lancer un enregistrement, poser un marque-page :
  gestes de salle de crise, faits devant un grand écran. Le serveur les refuse
  à un appareil, et deux garanties le vérifient.

L'ordre des opérations compte
  La session audio est ouverte avant la connexion, pas après : c'est elle qui
  demande à iOS le droit de continuer écran verrouillé, et l'inverse laisserait
  une fenêtre où le système couperait l'appel. Une garantie mesure cet ordre
  dans le code, parce qu'il se perd au premier remaniement.

  Quitter l'écran ferme la salle et la session : un micro resté ouvert dans une
  cellule de crise est pire qu'un appel manqué.

Une personne arrivée depuis son téléphone apparaît sous son nom, pas sous celui
de l'appareil — dans une salle de crise, on doit savoir qui parle. Le journal,
lui, distingue une entrée faite depuis une voiture (EM-33).

806 garanties, dont 13 nouvelles. Les quatre jalons du volet mobile sont livrés.

Ce qui reste hors de portée du code, et qui n'a pas bougé : le compte éditeur
Apple, et l'instruction de la demande d'alerte critique.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 3109e841
Loading
Loading
Loading
Loading
Loading
+20 −4
Original line number Diff line number Diff line
@@ -22,6 +22,14 @@ export const REDACTEURS: RoleOrganisation[] = ["proprietaire", "preparation", "d
 */
export interface Porteur {
  compteId: string;
  /**
   * Le nom du compte, tel qu'il s'affiche aux autres.
   *
   * Une salle de réunion montre qui est là. Le porteur doit donc le connaître,
   * quel que soit le chemin d'authentification — sans quoi une personne
   * arrivée depuis son téléphone apparaîtrait sans nom.
   */
  nom: string;
  /** La session web, ou null quand c'est un appareil. */
  sessionId: string | null;
  /** L'appareil enrôlé, ou null quand c'est un navigateur (EM-33). */
@@ -49,7 +57,10 @@ async function session(sql: Sql, req: FastifyRequest, reply: FastifyReply) {
    return null;
  }
  req.auth = resolue;
  req.porteur = { compteId: resolue.compte.id, sessionId: resolue.session.id, appareil: null };
  req.porteur = {
    compteId: resolue.compte.id, nom: resolue.compte.nom,
    sessionId: resolue.session.id, appareil: null,
  };
  return resolue;
}

@@ -72,10 +83,15 @@ async function appareil(sql: Sql, req: FastifyRequest): Promise<Porteur | null>

  const resolu = await appareils.resoudre(sql, id, secret);
  if (!resolu) return null;
  const [a] = await sql<{ compte_id: string; modele: string | null }[]>`
    select compte_id, modele from appareil where id = ${id}`;
  const [a] = await sql<{ compte_id: string; modele: string | null; nom: string }[]>`
    select a.compte_id, a.modele, c.nom
      from appareil a join compte c on c.id = a.compte_id
     where a.id = ${id}`;
  if (!a) return null;
  return { compteId: a.compte_id, sessionId: null, appareil: { id, modele: a.modele } };
  return {
    compteId: a.compte_id, nom: a.nom, sessionId: null,
    appareil: { id, modele: a.modele },
  };
}

/**
+20 −11
Original line number Diff line number Diff line
@@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyRequest } from "fastify";
import { z } from "zod";
import type { Sql } from "../db/client.js";
import * as reunions from "../domaine/reunions.js";
import { requerirAccesCrise } from "./garde.js";
import { contexteDe, requerirAccesCrise, requerirAccesCriseOuAppareil } from "./garde.js";

/**
 * Visioconférence (§6.8).
@@ -14,19 +14,28 @@ import { requerirAccesCrise } from "./garde.js";

export function enregistrerRoutesMedia(app: FastifyInstance, sql: Sql): void {
  const acces = { preHandler: requerirAccesCrise(sql) };
  /**
   * Rejoindre l'appel de sa cellule, depuis un téléphone (M3, EM-04).
   *
   * « En voiture. L'audio seul, en arrière-plan, sans regarder l'écran. »
   * Entrer, sortir, savoir qui est là — et rien d'autre : ouvrir une salle, la
   * fermer, lancer un enregistrement ou poser un marque-page restent des
   * gestes de salle de crise, faits devant un grand écran.
   */
  const accesMobile = { preHandler: requerirAccesCriseOuAppareil(sql) };
  const criseId = (req: FastifyRequest) =>
    z.object({ criseId: z.string().uuid() }).parse(req.params).criseId;
  const sousId = (req: FastifyRequest) =>
    z.object({ sousId: z.string().uuid() }).parse(req.params).sousId;
  const ctx = (req: FastifyRequest) => ({
    acteurId: req.auth!.compte.id, sessionId: req.auth!.session.id, origine: "humaine" as const,
  });
  // L'origine suit le porteur : rejoindre un appel depuis une voiture ne
  // s'enseigne pas comme rejoindre depuis la salle de crise (EM-33).
  const ctx = (req: FastifyRequest) => contexteDe(req);

  const estAnimateur = async (req: FastifyRequest, celluleId: string | null) => {
    if (!celluleId) return false;
    const [r] = await sql`
      select 1 from cellule_membre
       where cellule_id = ${celluleId} and compte_id = ${req.auth!.compte.id}
       where cellule_id = ${celluleId} and compte_id = ${req.porteur!.compteId}
         and role = 'animateur' and retire_at is null`;
    return r !== undefined;
  };
@@ -34,13 +43,13 @@ export function enregistrerRoutesMedia(app: FastifyInstance, sql: Sql): void {
  const dirigeLaCrise = async (req: FastifyRequest, id: string) => {
    const [r] = await sql`
      select 1 from crise c join membre m on m.organisation_id = c.organisation_id
       where c.id = ${id} and m.compte_id = ${req.auth!.compte.id}
       where c.id = ${id} and m.compte_id = ${req.porteur!.compteId}
         and m.role in ('directeur_crise', 'proprietaire')`;
    return r !== undefined;
  };

  // ── Salles (EF-801) ──────────────────────────────────────────────────────
  app.get("/crises/:criseId/salles", acces, async (req) => {
  app.get("/crises/:criseId/salles", accesMobile, async (req) => {
    const { celluleId } = z.object({ celluleId: z.string().uuid().optional() }).parse(req.query);
    const id = criseId(req);
    const liste = await reunions.salles(sql, id, celluleId);
@@ -70,12 +79,12 @@ export function enregistrerRoutesMedia(app: FastifyInstance, sql: Sql): void {
  });

  /** Entrer, c'est demander un jeton. Rien n'est planifié, rien n'est convoqué. */
  app.post("/crises/:criseId/salles/:sousId/entree", acces, async (req, reply) => {
  app.post("/crises/:criseId/salles/:sousId/entree", accesMobile, async (req, reply) => {
    const id = criseId(req);
    const salle = await reunions.lireSalle(sql, id, sousId(req));
    if (!salle) return reply.code(404).send({ code: "introuvable", message: "Salle inconnue." });

    const entree = await reunions.rejoindre(sql, id, sousId(req), req.auth!.compte.nom,
    const entree = await reunions.rejoindre(sql, id, sousId(req), req.porteur!.nom,
      await estAnimateur(req, salle.cellule_id), ctx(req));
    return reply.send({
      ...entree,
@@ -86,12 +95,12 @@ export function enregistrerRoutesMedia(app: FastifyInstance, sql: Sql): void {
    });
  });

  app.post("/crises/:criseId/participations/:sousId/sortie", acces, async (req, reply) => {
  app.post("/crises/:criseId/participations/:sousId/sortie", accesMobile, async (req, reply) => {
    const duree = await reunions.quitter(sql, criseId(req), sousId(req), ctx(req));
    return reply.send({ dureeSecondes: duree });
  });

  app.get("/crises/:criseId/salles/:sousId/presents", acces, async (req) => ({
  app.get("/crises/:criseId/salles/:sousId/presents", accesMobile, async (req) => ({
    presents: await reunions.presents(sql, criseId(req), sousId(req)),
  }));

+25 −0
Original line number Diff line number Diff line
@@ -372,6 +372,23 @@ export async function verifierApplicationMobile(r: Rapport): Promise<void> {
  r.verifie("EM-05", "le passage demande un jeton au moment d'ouvrir, pas à l'avance",
    /passage-web/.test(versWeb) && !/useEffect/.test(versWeb));

  // ── M3 : l'appel, et ce qu'il refuse d'être ─────────────────────────────
  //
  // « En voiture. L'audio seul, en arrière-plan, sans regarder l'écran. »
  // Chacune de ces trois exigences se vérifie ici, parce que chacune se perd
  // par inadvertance : une vidéo ajoutée « puisqu'on a la caméra », un micro
  // ouvert par défaut « pour que ce soit plus simple », un mode d'arrière-plan
  // oublié à la prochaine régénération du projet natif.
  const ecranAppel = sansCommentaires(lus.get("src/ecrans/Appel.tsx") ?? "");
  r.verifie("EM-04", "l'appel n'envoie ni ne reçoit de vidéo",
    !/setCameraEnabled|VideoTrack|videoCaptureDefaults|startVideo/.test(ecranAppel));
  r.verifie("EM-04", "le micro est coupé à l'entrée",
    /setMicrophoneEnabled\(false\)/.test(ecranAppel));
  r.verifie("EM-04", "la session audio est démarrée avant la connexion",
    ecranAppel.indexOf("startAudioSession") < ecranAppel.indexOf("r.connect("));
  r.verifie("EM-04", "quitter l'écran ferme la salle et la session audio",
    /stopAudioSession/.test(ecranAppel) && /disconnect\(\)/.test(ecranAppel));

  // ── EM-42, EM-74 : la clause réseau ne s'arrête pas au navigateur ───────
  const apropos = lus.get("src/ecrans/APropos.tsx") ?? "";
  r.verifie("EM-74", "l'écran « à propos » affiche la licence et l'adresse du code",
@@ -384,4 +401,12 @@ export async function verifierApplicationMobile(r: Rapport): Promise<void> {
    /git rev-parse HEAD/.test(configuration));
  r.verifie("EM-43", "un plancher de version de système est déclaré",
    /deploymentTarget/.test(configuration) && /minSdkVersion/.test(configuration));

  // M3 : sans ce mode, l'appel tombe dès que l'écran se verrouille — et tout
  // le travail sur l'appel ne servirait qu'à quelqu'un qui regarde son
  // téléphone, c'est-à-dire jamais en conduisant.
  r.verifie("EM-04", "le mode audio d'arrière-plan est déclaré",
    /UIBackgroundModes/.test(configuration) && /"audio"/.test(configuration));
  r.verifie("EM-37", "et le micro s'explique en une phrase",
    /NSMicrophoneUsageDescription/.test(configuration));
}
+84 −0
Original line number Diff line number Diff line
@@ -7,6 +7,8 @@ import * as totp from "../auth/totp.js";
import * as appareils from "../mobile/appareils.js";
import * as annuaire from "../preparation/annuaire.js";
import * as mobilisation from "../domaine/mobilisation.js";
import * as reunions from "../domaine/reunions.js";
import { definirMedia, mediaEssai } from "../adaptateurs/media.js";
import { ouvrirCrise } from "../domaine/crise.js";
import { empreinte } from "../auth/jetons.js";
import {
@@ -667,6 +669,88 @@ export async function verifierMobile(
      rep.statusCode === 401, `reçu ${rep.statusCode}`);
  }

  // ── Rejoindre l'appel de sa cellule (M3, EM-04) ──────────────────────────
  //
  // « En voiture. L'audio seul, en arrière-plan, sans regarder l'écran. »
  // Entrer, sortir, savoir qui est là — et rien d'autre. Ouvrir une salle,
  // lancer un enregistrement ou poser un marque-page restent des gestes de
  // salle de crise, faits devant un grand écran.
  r.titre("Rejoindre l'appel depuis le téléphone");

  // Un fournisseur de visioconférence d'essai : ce qu'on éprouve ici est le
  // chemin d'accès depuis un téléphone, pas LiveKit.
  definirMedia(mediaEssai());

  // La salle s'ouvre depuis le web — c'est un geste de salle de crise. Le
  // téléphone la rejoint ensuite, ce qui est tout le sujet.
  const salleOuverte = await reunions.ouvrirSallePonctuelle(
    sql, crise.criseId, null, "cellule-decision", "Cellule de décision",
    { acteurId: inscription.compteId, origine: "humaine" });

  const salles = await app.inject({
    method: "GET", url: `/api/crises/${crise.criseId}/salles`, headers: entete,
  });
  const listeSalles = (salles.json() as { salles: { id: string; code: string }[] }).salles;
  r.verifie("EM-04", "l'appareil voit les salles ouvertes de la crise",
    salles.statusCode === 200 && listeSalles.length > 0,
    `reçu ${salles.statusCode}, ${listeSalles.length} salle(s)`);

  const salleId = listeSalles[0]?.id ?? salleOuverte;
  const entree = await app.inject({
    method: "POST", url: `/api/crises/${crise.criseId}/salles/${salleId}/entree`,
    headers: entete, payload: {},
  });
  const jetonSalle = entree.json() as {
    jeton?: { jeton: string; url: string }; participationId?: string;
    avertissement?: string | null;
  };
  r.verifie("EM-04", "il obtient un jeton pour entrer dans la salle",
    entree.statusCode === 200 && typeof jetonSalle.jeton?.jeton === "string"
      && typeof jetonSalle.jeton.url === "string",
    `reçu ${entree.statusCode}`);

  const presents = await app.inject({
    method: "GET", url: `/api/crises/${crise.criseId}/salles/${salleId}/presents`,
    headers: entete,
  });
  const qui = (presents.json() as { presents?: { nom_affiche: string }[] }).presents ?? [];
  // Le nom affiché est celui de la personne, pas « iPhone 14 » : dans une
  // salle de crise, on doit savoir qui parle.
  r.verifie("EM-04", "et il apparaît sous son nom, pas sous celui de l'appareil",
    qui.some((x) => x.nom_affiche === "Maëlle Kerneur"),
    qui.map((x) => x.nom_affiche).join(", "));

  const [entreeInscrite] = await sql<{ origine: string; appareil_id: string | null }[]>`
    select origine, appareil_id from evenement
     where crise_id = ${crise.criseId} and type = 'reunion.rejointe'
     order by seq desc limit 1`;
  r.verifie("EM-33", "le journal distingue une entrée faite depuis une voiture",
    entreeInscrite?.origine === "mobile"
      && entreeInscrite.appareil_id === identite.appareilId);

  if (jetonSalle.participationId) {
    r.verifie("EM-04", "il ressort de la salle par la même route que le web",
      (await app.inject({
        method: "POST",
        url: `/api/crises/${crise.criseId}/participations/${jetonSalle.participationId}/sortie`,
        headers: entete, payload: {},
      })).statusCode === 200);
  }

  // Ce que le téléphone n'ouvre pas : la salle elle-même, et l'enregistrement.
  for (const [quoi, chemin] of [
    ["ouvrir une salle", `/api/crises/${crise.criseId}/salles`],
    ["lancer un enregistrement",
      `/api/crises/${crise.criseId}/salles/${salleId}/enregistrement`],
  ] as [string, string][]) {
    const rep = await app.inject({
      method: "POST", url: chemin, headers: entete,
      payload: { code: "ponctuelle", libelle: "Depuis un téléphone" },
    });
    r.verifie("EM-04", `${quoi} reste hors de portée d'un appareil`,
      rep.statusCode === 401, `reçu ${rep.statusCode}`);
  }

  // ── Le passage vers le web (M3, EM-05) ───────────────────────────────────
  //
  // C'est la contrepartie du périmètre restreint : l'application ne porte que
+14 −1
Original line number Diff line number Diff line
@@ -71,7 +71,20 @@ n'administre jamais** — c'est une colonne, pas un délai : une première versi
la datait de l'enrôlement en pariant sur la fenêtre de quinze minutes, et un
téléphone enrôlé cinq minutes plus tôt ouvrait la console entière.

Reste à M3 : rejoindre l'appel de sa cellule, en audio d'arrière-plan.
**Rejoindre l'appel de sa cellule** ferme le jalon. « En voiture. L'audio seul,
en arrière-plan, sans regarder l'écran. » Trois conséquences, chacune vérifiée :
aucune vidéo n'est envoyée ni reçue, le micro est coupé à l'entrée — on rejoint
pour écouter — et la session audio est ouverte avant la connexion, sans quoi le
système couperait l'appel au verrouillage de l'écran.

C'est la deuxième chose, après le réveil, que le web mobile ne sait pas faire :
dans un navigateur, l'appel tombe dès que l'écran se verrouille.

Ouvrir une salle, la fermer, lancer un enregistrement ou poser un marque-page
restent des gestes de salle de crise, faits devant un grand écran — le serveur
les refuse à un appareil.

M3 est complet. Les quatre jalons du volet mobile sont livrés.

Ce qui manque encore à M0, et qui ne dépend pas du code de ce dossier :

Loading