Commit 71671ae5 authored by Kourser's avatar Kourser
Browse files

Interface : la coque, et le panneau qui survit à la navigation

Le chemin critique est écrit : connexion, veille, déclenchement, cockpit, main
courante, salons, décisions et actions, points de situation, documents, dossier
gelé, rembobinage.

Ce qui justifiait d'écrire l'interface plutôt que d'en encapsuler une
  Le panneau de visioconférence est monté dans la coque, hors des écrans. Il
  n'est jamais démonté quand on change de module : on continue de parler
  pendant qu'on ouvre un document, qu'on écrit en main courante, qu'on relit le
  dossier gelé.

  C'est précisément ce qu'une application tierce dans une iframe rendrait
  impossible — quitter la page, c'est quitter l'appel — et c'est la seule
  raison pour laquelle toute l'architecture du §3 a été retenue. Elle tient.

Le rembobinage ne peut pas se confondre avec le direct
  La coque entière se teinte, la gravité cède la place à un bandeau « lecture
  seule », et les boutons d'écriture disparaissent. On ne consigne pas depuis
  le passé, on ne rejoint pas un appel depuis le passé.

Consigner est à un geste, et ne bloque rien
  Bouton permanent, raccourci ⌘J, pré-rempli par le contexte d'où on l'appelle.
  Le panneau flotte sans voile modal : §10 interdit qu'une boîte de dialogue
  puisse empêcher la consignation d'un fait.

Un artefact, un port, une origine
  L'API sert l'interface. Le cookie de session n'a donc aucune frontière à
  franchir, et l'auto-hébergeur n'a pas de reverse proxy à composer pour que
  l'application fonctionne. L'image passe de 167 à 171 Mo.

EX-12 tenu
  Le cœur pèse 88 Ko compressés. La visioconférence, qui pèse plus que tout le
  reste réuni, n'est téléchargée qu'au moment où quelqu'un rejoint un appel.

Défaut corrigé au passage
  L'API ne renvoyait pas le nom de l'auteur d'un message : la conversation
  affichait « Membre » partout. Une conversation où chacun s'appelle Membre ne
  se relit pas trois mois plus tard.

Éprouvé dans le navigateur
  Parcours complet déroulé sur l'instance conteneurisée : création de compte,
  écran de veille avec son indicateur à 30/100, déclenchement, coque, présence
  temps réel, consignation, salon, rembobinage. Thèmes clair et sombre.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 6aec5700
Loading
Loading
Loading
Loading
+40 −3
Original line number Diff line number Diff line
@@ -23,7 +23,8 @@ l'enregistre intégralement — de manière à pouvoir la rembobiner.
| **L8** | Mobilisation multicanal, relances, escalade | 🟢 fait |
| **L9** | Rembobinage complet, dossier de crise, retour d'expérience | 🟢 fait |
| **L10** | Intégration exercice, page d'état, vérificateur, CI, documentation | 🟢 fait côté serveur — application installable, accessibilité et bilinguisme suivent le client |
| L11–L12 | Applications mobiles, téléphonie, transcription | ⚪ hors d'atteinte sans client ni infrastructure SIP |
| **Interface** | Coque à trois zones, panneau visio flottant, rembobinage, chat, cockpit | 🟢 chemin critique fait |
| L11–L12 | Applications mobiles, téléphonie, transcription | ⚪ hors d'atteinte sans infrastructure SIP |

Ce qui fonctionne aujourd'hui : le journal inviolable et son chaînage
d'empreintes, le vérificateur indépendant, la reconstitution d'un état passé
@@ -50,13 +51,16 @@ pnpm install
pnpm up          # postgres, redis, minio, application
```

L'application écoute sur `http://localhost:8080` (`/sante`, `/pret`).
L'interface et l'API sont servies par le **même artefact, sur le même port** :
`http://localhost:8080`. Un seul port, une seule origine — le cookie de session
n'a donc jamais de frontière à franchir, et l'auto-hébergeur n'a pas de reverse
proxy à composer pour que l'application fonctionne.

Pour travailler sur le code sans reconstruire l'image à chaque fois :

```bash
docker compose -f docker/compose.yaml --env-file .env up -d postgres redis minio
pnpm dev
pnpm dev     # API sur 8080, interface sur 5173 avec rechargement
```

### Vérifier l'installation et les garanties
@@ -101,6 +105,15 @@ apps/api/src/
    actions.ts       registre, responsables, échéances, « ce qui m'incombe »
    points.ts        trame fixe, brouillon depuis le journal, export autonome
    cockpit.ts       ce qui appelle une décision, en dix secondes
apps/web/src/
  coque/          la coque à trois zones (§10)
    Coque.tsx        barre d'état, rail, contexte — et ce qui flotte au-dessus
    PanneauVisio.tsx le panneau qui survit à la navigation
    Consigner.tsx    toujours à un geste, jamais bloquant
    Rembobinage.tsx  le curseur, et la teinte qui interdit la confusion
  ecrans/         un seul écran par module

apps/api/src/
  preparation/    la moitié amont du produit
    dossier.ts       versionnement à temps de validité
    annuaire.ts      personnes, suppléants, astreintes
@@ -169,6 +182,30 @@ promesse « cloner et composer suffit » est une exigence produit. Le format
stocke ses paramètres, une migration reste possible sans invalider les
empreintes.

## L'interface, et pourquoi elle existe

La coque tient en trois zones fixes et une barre d'état qui ne bouge jamais.
Trois niveaux de navigation, pas quatre : crise → cellule → module.

**Le panneau de visioconférence est monté dans la coque, hors des écrans.** Il
n'est jamais démonté quand on change de module : on continue de parler pendant
qu'on ouvre un document, qu'on écrit en main courante, qu'on relit le dossier
gelé. C'est exactement ce qu'une application encapsulée dans une iframe rendrait
impossible — quitter la page, c'est quitter l'appel — et **c'est la seule raison
pour laquelle toute l'architecture du §3 a été retenue.**

Le **rembobinage teinte toute la coque**, remplace la gravité par un bandeau
« lecture seule », et fait disparaître les boutons d'écriture. Aucune confusion
avec le direct n'est possible.

**Consigner est toujours à un geste** (⌘J), pré-rempli par le contexte d'où on
l'appelle, et ne bloque rien : §10 interdit qu'une boîte de dialogue puisse
empêcher la consignation d'un fait.

Le cœur pèse **88 Ko compressés** ; la visioconférence, qui pèse plus que tout
le reste réuni, n'est téléchargée qu'au moment où quelqu'un rejoint un appel
(EX-12).

## Vérifier sans nous faire confiance

```bash
+1 −0
Original line number Diff line number Diff line
@@ -13,6 +13,7 @@
    "@aws-sdk/client-s3": "^3.1116.0",
    "@aws-sdk/s3-request-presigner": "^3.1116.0",
    "@fastify/cookie": "^11.1.2",
    "@fastify/static": "^10.1.3",
    "@fastify/websocket": "^11.3.0",
    "@kastell/shared": "workspace:*",
    "fastify": "^5.2.0",
+19 −10
Original line number Diff line number Diff line
@@ -30,6 +30,9 @@ export interface Message {
  salon_id: string;
  seq: string;
  auteur_id: string | null;
  /** Résolu ici plutôt que côté client : une conversation où chacun s'appelle
   *  « Membre » ne se relit pas trois mois plus tard. */
  auteur_nom: string | null;
  texte: string | null;
  repond_a: string | null;
  mentions: string[];
@@ -288,7 +291,7 @@ export async function messages(
): Promise<Message[]> {
  const limite = Math.min(f.limite ?? 100, 500);
  const lignes = await sql<(Message & { supprime_at: Date | null })[]>`
    select m.*, coalesce(
    select m.*, c.nom as auteur_nom, coalesce(
             (select jsonb_agg(jsonb_build_object('emoji', r.emoji, 'comptes', r.comptes))
                from (select emoji, array_agg(compte_id::text) as comptes
                        from message_reaction
@@ -296,6 +299,7 @@ export async function messages(
                       group by emoji) r),
             '[]'::jsonb) as reactions
      from message m
      left join compte c on c.id = m.auteur_id
     where m.crise_id = ${criseId} and m.salon_id = ${salonId}
       ${f.depuisSeq !== undefined ? sql`and m.seq > ${f.depuisSeq}` : sql``}
       ${f.avantSeq !== undefined ? sql`and m.seq < ${f.avantSeq}` : sql``}
@@ -314,10 +318,11 @@ function masquerSiSupprime(m: Message & { supprime_at: Date | null }): Message {

export async function epingles(sql: Sql, criseId: string, salonId: string): Promise<Message[]> {
  const lignes = await sql<(Message & { supprime_at: Date | null })[]>`
    select * from message
     where crise_id = ${criseId} and salon_id = ${salonId}
       and epingle_at is not null and supprime_at is null
     order by epingle_at desc`;
    select m.*, c.nom as auteur_nom from message m
      left join compte c on c.id = m.auteur_id
     where m.crise_id = ${criseId} and m.salon_id = ${salonId}
       and m.epingle_at is not null and m.supprime_at is null
     order by m.epingle_at desc`;
  return lignes.map(masquerSiSupprime);
}

@@ -332,10 +337,12 @@ export async function rechercher(
): Promise<Trouvaille[]> {
  if (salonIds.length === 0 || requete.trim().length < 2) return [];
  const lignes = await sql<(Trouvaille & { supprime_at: Date | null })[]>`
    select m.*, s.libelle as salon_libelle,
    select m.*, c.nom as auteur_nom, s.libelle as salon_libelle,
           ts_headline('french', m.texte, websearch_to_tsquery('french', ${requete}),
                       'StartSel=«, StopSel=», MaxWords=24, MinWords=8') as extrait
      from message m join salon s on s.crise_id = m.crise_id and s.id = m.salon_id
      from message m
      join salon s on s.crise_id = m.crise_id and s.id = m.salon_id
      left join compte c on c.id = m.auteur_id
     where m.crise_id = ${criseId}
       and m.salon_id in ${sql(salonIds)}
       and m.supprime_at is null
@@ -348,8 +355,10 @@ export async function rechercher(

export async function fil(sql: Sql, criseId: string, messageId: string): Promise<Message[]> {
  const lignes = await sql<(Message & { supprime_at: Date | null })[]>`
    select * from message
     where crise_id = ${criseId} and (id = ${messageId} or repond_a = ${messageId})
     order by seq`;
    select m.*, c.nom as auteur_nom from message m
      left join compte c on c.id = m.auteur_id
     where m.crise_id = ${criseId}
       and (m.id = ${messageId} or m.repond_a = ${messageId})
     order by m.seq`;
  return lignes.map(masquerSiSupprime);
}
+21 −0
Original line number Diff line number Diff line
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import statique from "@fastify/static";
import Fastify, { type FastifyInstance } from "fastify";
import { enregistrerRoutesAdministration } from "./routes/administration.js";
import { enregistrerRoutesAuth } from "./routes/auth.js";
@@ -71,5 +75,22 @@ export async function construireServeur(
  enregistrerRoutesIntegration(app, sql);
  enregistrerRoutesEtat(app, sql);

  // ── L'interface, servie par la même instance (EX-35) ─────────────────────
  // Un seul artefact, un seul port, une seule origine : le cookie de session
  // n'a donc jamais à franchir de frontière, et l'auto-hébergeur n'a pas de
  // reverse proxy à composer pour que l'application fonctionne.
  const interfaceWeb = join(dirname(fileURLToPath(import.meta.url)), "web");
  if (existsSync(interfaceWeb)) {
    await app.register(statique, { root: interfaceWeb, prefix: "/", wildcard: false });
    app.setNotFoundHandler((req, reply) => {
      // Les chemins d'API inconnus restent des 404 franches ; le reste relève
      // du routeur de l'interface.
      if (req.method !== "GET" || req.headers.accept?.includes("application/json")) {
        return reply.code(404).send({ code: "introuvable", message: "Ressource inconnue." });
      }
      return reply.sendFile("index.html");
    });
  }

  return app;
}

apps/web/index.html

0 → 100644
+19 −0
Original line number Diff line number Diff line
<!doctype html>
<html lang="fr">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
    <meta name="color-scheme" content="light dark" />
    <title>Kastell</title>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      rel="stylesheet"
      href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&family=JetBrains+Mono:wght@400;500&display=swap"
    />
  </head>
  <body>
    <div id="racine"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
Loading