Commit eec76a85 authored by Kourser's avatar Kourser
Browse files

Se parler à deux, à quelques-uns, ou entre soi

Les messages directs étaient écrits, routés et éprouvés depuis EF-708 — et
aucun écran ne les appelait. La fonction existait ; le chemin vers elle,
non. C'est le même genre de panne que le bouton de déclenchement rendu
inatteignable : ce qui manque n'est pas dans le code qu'on relit, c'est entre
les écrans.

Deux axes, et ils ne se confondent jamais
  La visibilité — publique ou privée — dit qui voit et qui peut entrer. Elle se
  change à tout moment : ce n'est qu'un droit d'accès.

  Le journal — au journal ou hors journal — est fixé à la création et ne se
  change jamais. Un registre en ajout seul ne se dé-écrit pas : refermer un
  canal journalisé n'effacerait rien, et journaliser après coup un groupe qui
  ne l'était pas transformerait des paroles privées en preuve.

  D'où la répartition. Les canaux de travail sont des salons, journalisés,
  qui gagnent ici une visibilité et des membres. Les groupes privés restent des
  conversations, hors journal — et la table de participants en acceptait déjà
  autant qu'il fallait : seule la règle « exactement deux » vivait dans le code.

  L'écran le dit à l'endroit où l'on bascule, pas en note de bas de page : un
  canal privé protège la suite, jamais le passé.

Un canal fermé ne s'annonce pas
  Il disparaît des listes de qui n'en est pas — il n'y figure pas grisé.
  Signaler l'existence d'un canal fermé revient à en divulguer le sujet à qui
  n'y a pas droit, et transforme la liste des canaux en carte des conversations
  qu'on ne devrait pas savoir tenues. Les gardes répondent « introuvable » là où
  « interdit » aurait confirmé.

La voix d'un canal emprunte ses droits
  Une salle adossée à un canal n'a pas de droits propres. Sans cela, « privé »
  voudrait dire deux choses différentes selon qu'on écrit ou qu'on parle — et un
  canal fermé dont la salle resterait ouverte ne serait pas fermé.

  L'enregistrement suit la même règle : le directeur de crise n'enregistre pas
  une réunion dont il ne serait pas. Un témoin d'enregistrement que quiconque
  peut allumer depuis dehors ne vaut rien.

  Une salle sans canal reste ouverte à toute la crise, comme EF-801 le veut pour
  les cellules et les points inter-cellules.

Ce que le rail montre désormais : les canaux, leur voix en dessous, et qui s'y
trouve — mesuré en base, pas déduit d'une présence générale. La colonne de
contexte dit qui est en ligne. L'essentiel de cela n'était que du rendu : les
données étaient servies depuis toujours et personne ne les affichait.

Quinze garanties ajoutées, dont la seule qui compte vraiment : un message de
canal privé est au journal, un message de groupe n'y est pas.

Non éprouvé : la migration 0023 n'a jamais été appliquée, et pnpm verif n'a pas
tourné — ni Docker ni PostgreSQL sur la machine de travail. Typecheck et
construction verts.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 494f0c44
Loading
Loading
Loading
Loading
+65 −0
Changes for apps/api/src/db/migrations/0023_canaux.sql: 65 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- Canaux : ce qui est ouvert, ce qui ne l'est pas (EF-708, EF-801, EB-10)
--
-- Une liste de canaux où l'on voit qui est là et où l'on entre librement —
-- sauf ceux qu'on a passés en privé. Deux axes, et ils ne se confondent pas :
--
--   La visibilité — publique ou privée — dit qui voit et qui peut entrer.
--   Elle se change à tout moment : ce n'est qu'un droit d'accès.
--
--   Le journal — au journal ou hors journal — est fixé à la création et ne
--   se change jamais. Un registre en ajout seul ne se dé-écrit pas : basculer
--   en privé un canal journalisé n'effacerait rien, et journaliser après coup
--   un groupe qui ne l'était pas transformerait des paroles privées en preuve.
--
-- D'où la répartition : les canaux journalisés sont des salons, qui gagnent
-- ici une visibilité et des membres. Les groupes hors journal restent des
-- conversations — la table de participants en accepte déjà autant qu'il faut,
-- seule la règle « exactement deux » vivait dans le code.
-- ═══════════════════════════════════════════════════════════════════════════

-- ── Un salon peut se fermer aux non-membres ────────────────────────────────
alter table salon add column prive boolean not null default false;

-- L'appartenance est portée par le journal (salon.acces.modifie) et projetée
-- ici : « qui pouvait lire quoi » est une question qui se pose au retour
-- d'expérience, et une table qu'on modifierait sans trace n'y répondrait pas.
create table salon_membre (
  crise_id  uuid not null,
  salon_id  uuid not null,
  compte_id uuid not null references compte(id) on delete cascade,
  primary key (crise_id, salon_id, compte_id),
  foreign key (crise_id, salon_id) references salon (crise_id, id) on delete cascade
);
create index salon_membre_compte on salon_membre (crise_id, compte_id);

-- ── Une salle peut s'adosser à un canal ────────────────────────────────────
--
-- Sans cela, une salle est un objet flottant : on ne peut ni dire de quel
-- canal elle est la voix, ni lui appliquer les droits de ce canal.
alter table salle add column salon_id uuid;

-- La contrainte de genre se cherche au lieu de se deviner : son nom vient
-- d'une convention de PostgreSQL, et une migration qui parie sur un nom
-- échoue au déploiement, jamais à l'écriture.
do $$
declare nom text;
begin
  select conname into nom
    from pg_constraint
   where conrelid = 'salle'::regclass and contype = 'c'
     and pg_get_constraintdef(oid) like '%genre%';
  if nom is not null then
    execute format('alter table salle drop constraint %I', nom);
  end if;
end $$;

alter table salle add constraint salle_genre_check
  check (genre in ('cellule', 'ponctuelle', 'canal'));
create index salle_salon on salle (crise_id, salon_id);

-- ── Un groupe hors journal porte un nom ────────────────────────────────────
--
-- À deux, le nom est celui d'en face. À plusieurs, il en faut un — et il ne
-- s'invente pas à l'affichage : ce sont les membres qui le choisissent.
alter table conversation_directe add column libelle text;
+79 −4
Changes for apps/api/src/domaine/chat.ts: 79 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -20,6 +20,7 @@ export interface Salon {
  code: string;
  libelle: string;
  genre: "cellule" | "thematique";
  prive: boolean;
  cree_at: Date;
  ferme_at: Date | null;
}
@@ -75,15 +76,76 @@ export async function creerSalonDeCellule(
export async function creerSalon(
  sql: Sql, criseId: string, celluleId: string | null,
  code: string, libelle: string, ctx: Contexte,
  acces: { prive: boolean; membres: string[] } = { prive: false, membres: [] },
): Promise<string> {
  const salonId = randomUUID();
  await sql.begin((tx) => enregistrer(tx, {
  await sql.begin(async (tx) => {
    await enregistrer(tx, {
      criseId, celluleId, type: "salon.cree",
    charge: { salon_id: salonId, code, libelle, genre: "thematique" }, ctx,
  }));
      charge: { salon_id: salonId, code, libelle, genre: "thematique", prive: acces.prive },
      ctx,
    });
    // Deux événements plutôt qu'un seul plus gros : la création dit ce qu'est
    // le salon, l'accès dit qui le voit. Le second se rejouera vingt fois au
    // cours d'une crise ; le premier une seule.
    if (acces.prive || acces.membres.length > 0) {
      await enregistrer(tx, {
        criseId, type: "salon.acces.modifie",
        charge: { salon_id: salonId, prive: acces.prive, membres: acces.membres }, ctx,
      });
    }
  });
  return salonId;
}

/**
 * Ouvrir ou refermer un salon, après coup.
 *
 * La visibilité se change ; le journal, non. Un salon reste journalisé qu'on
 * le passe en privé ou qu'on le rouvre — c'est ce qui permet de le refermer
 * sans mentir sur ce qui y a déjà été écrit.
 */
export async function definirAcces(
  sql: Sql, criseId: string, salonId: string,
  prive: boolean, membres: string[], ctx: Contexte,
): Promise<void> {
  await sql.begin((tx) => enregistrer(tx, {
    criseId, type: "salon.acces.modifie",
    charge: { salon_id: salonId, prive, membres: [...new Set(membres)] }, ctx,
  }));
}

/**
 * Qui a le droit de lire ce salon.
 *
 * Un salon public est ouvert à toute la crise — c'est le cas courant, et la
 * liste des canaux n'aurait aucun intérêt si chacun n'y voyait que le sien.
 * Un salon privé se limite à ses membres.
 */
export async function estAccessible(
  sql: Sql, criseId: string, salonId: string, compteId: string,
): Promise<boolean> {
  const [l] = await sql<{ prive: boolean; membre: boolean }[]>`
    select s.prive,
           exists (select 1 from salon_membre m
                    where m.crise_id = s.crise_id and m.salon_id = s.id
                      and m.compte_id = ${compteId}) as membre
      from salon s where s.crise_id = ${criseId} and s.id = ${salonId}`;
  if (!l) return false;
  return !l.prive || l.membre;
}

/** Les membres d'un salon privé, pour l'afficher — vide s'il est public. */
export async function membresDe(
  sql: Sql, criseId: string, salonId: string,
): Promise<{ compte_id: string; nom: string }[]> {
  return sql<{ compte_id: string; nom: string }[]>`
    select m.compte_id, c.nom from salon_membre m
      join compte c on c.id = m.compte_id
     where m.crise_id = ${criseId} and m.salon_id = ${salonId}
     order by c.nom`;
}

export async function fermerSalon(
  sql: Sql, criseId: string, salonId: string, motif: string | null, ctx: Contexte,
): Promise<void> {
@@ -92,12 +154,25 @@ export async function fermerSalon(
  }));
}

/**
 * Les salons qu'une personne peut voir.
 *
 * Un salon privé dont on n'est pas membre n'apparaît pas — il n'est pas
 * affiché grisé, ni annoncé comme inaccessible. Signaler l'existence d'un
 * canal fermé revient à en divulguer le sujet à qui n'y a pas droit, et
 * transforme la liste des canaux en carte des conversations qu'on ne devrait
 * pas savoir tenues.
 */
export async function salons(
  sql: Sql, criseId: string, celluleId?: string,
  sql: Sql, criseId: string, compteId: string, celluleId?: string,
): Promise<Salon[]> {
  return sql<Salon[]>`
    select * from salon
     where crise_id = ${criseId} and ferme_at is null
       and (prive = false
            or exists (select 1 from salon_membre m
                        where m.crise_id = salon.crise_id and m.salon_id = salon.id
                          and m.compte_id = ${compteId}))
       ${celluleId ? sql`and (cellule_id = ${celluleId} or cellule_id is null)` : sql``}
     order by genre, libelle`;
}
+38 −1
Changes for apps/api/src/domaine/directs.ts: 38 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -27,6 +27,8 @@ export interface ConversationDirecte {
  id: string;
  crise_id: string;
  cree_at: Date;
  /** Nul pour une conversation à deux : le nom, c'est celui d'en face. */
  libelle: string | null;
  participants: { compte_id: string; nom: string }[];
  dernier_message_at: Date | null;
}
@@ -69,6 +71,41 @@ export async function ouvrirConversation(
  })) as string;
}

/**
 * Un groupe hors journal, à trois ou plus.
 *
 * Deux règles le séparent de la conversation à deux, et aucune n'est
 * arbitraire.
 *
 * Il porte un nom, parce qu'à plusieurs il n'y a plus de « celui d'en face »
 * pour en tenir lieu — et le nom est choisi par qui l'ouvre plutôt que
 * fabriqué à l'affichage, qui rendrait le même groupe différent selon qui le
 * regarde.
 *
 * Il n'est pas dédupliqué, là où la paire l'est. Deux personnes n'ont qu'un
 * fil : en rouvrir un second perdrait la moitié de leur échange. Trois
 * personnes peuvent légitimement vouloir deux groupes distincts, et c'est à
 * elles d'en juger — pas à une clause SQL.
 */
export async function ouvrirGroupe(
  sql: Sql, criseId: string, membres: string[], libelle: string,
): Promise<string> {
  const distincts = [...new Set(membres)];
  if (distincts.length < 3) {
    throw new Error("Un groupe demande au moins trois personnes ; à deux, c'est une conversation.");
  }
  return (await sql.begin(async (tx) => {
    const [creee] = await tx<{ id: string }[]>`
      insert into conversation_directe (crise_id, libelle)
      values (${criseId}, ${libelle}) returning id`;
    for (const compteId of distincts) {
      await tx`insert into conversation_participant (conversation_id, compte_id)
               values (${creee!.id}, ${compteId})`;
    }
    return creee!.id;
  })) as string;
}

async function participe(sql: Sql, conversationId: string, compteId: string): Promise<boolean> {
  const [p] = await sql`
    select 1 from conversation_participant
@@ -125,7 +162,7 @@ export async function conversationsDe(
  sql: Sql, criseId: string, compteId: string,
): Promise<ConversationDirecte[]> {
  return sql<ConversationDirecte[]>`
    select c.id, c.crise_id, c.cree_at,
    select c.id, c.crise_id, c.cree_at, c.libelle,
           (select jsonb_agg(jsonb_build_object('compte_id', p.compte_id, 'nom', cp.nom))
              from conversation_participant p join compte cp on cp.id = p.compte_id
             where p.conversation_id = c.id) as participants,
+67 −6
Changes for apps/api/src/domaine/reunions.ts: 67 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -19,9 +19,11 @@ export interface Salle {
  id: string;
  crise_id: string;
  cellule_id: string | null;
  /** Le canal dont cette salle est la voix, quand elle en est la voix. */
  salon_id: string | null;
  code: string;
  libelle: string;
  genre: "cellule" | "ponctuelle";
  genre: "cellule" | "ponctuelle" | "canal";
  nom_fournisseur: string;
  ouverte_at: Date;
  fermee_at: Date | null;
@@ -77,6 +79,26 @@ export async function ouvrirSallePonctuelle(
  return salleId;
}

/**
 * La voix d'un canal.
 *
 * Elle n'a pas de droits propres : elle emprunte ceux du canal. C'est la seule
 * façon d'éviter que « privé » veuille dire deux choses différentes selon
 * qu'on écrit ou qu'on parle — un canal fermé dont la salle resterait ouverte
 * ne serait pas fermé.
 */
export async function ouvrirSalleDeCanal(
  sql: Sql, criseId: string, salonId: string,
  code: string, libelle: string, ctx: Contexte,
): Promise<string> {
  const salleId = randomUUID();
  await sql.begin((tx) => enregistrer(tx, {
    criseId, type: "salle.ouverte",
    charge: { salle_id: salleId, code, libelle, genre: "canal", salon_id: salonId }, ctx,
  }));
  return salleId;
}

export async function fermerSalle(
  sql: Sql, criseId: string, salleId: string, motif: string | null, ctx: Contexte,
): Promise<void> {
@@ -88,14 +110,53 @@ export async function fermerSalle(
  }));
}

/**
 * Les salles qu'une personne peut voir.
 *
 * Une salle adossée à un canal privé disparaît pour qui n'est pas du canal —
 * y compris de la liste. La montrer sans permettre d'entrer reviendrait à
 * afficher, sous le nez de tous, qui se parle en ce moment sans eux : la
 * présence est justement ce que cette liste rend visible.
 */
export async function salles(
  sql: Sql, criseId: string, celluleId?: string,
  sql: Sql, criseId: string, compteId: string, celluleId?: string,
): Promise<Salle[]> {
  return sql<Salle[]>`
    select * from salle
     where crise_id = ${criseId} and fermee_at is null
       ${celluleId ? sql`and (cellule_id = ${celluleId} or cellule_id is null)` : sql``}
     order by genre, libelle`;
    select s.* from salle s
     where s.crise_id = ${criseId} and s.fermee_at is null
       and (s.salon_id is null
            or exists (select 1 from salon sa
                        where sa.crise_id = s.crise_id and sa.id = s.salon_id
                          and (sa.prive = false
                               or exists (select 1 from salon_membre m
                                           where m.crise_id = sa.crise_id
                                             and m.salon_id = sa.id
                                             and m.compte_id = ${compteId}))))
       ${celluleId ? sql`and (s.cellule_id = ${celluleId} or s.cellule_id is null)` : sql``}
     order by s.genre, s.libelle`;
}

/**
 * Le droit d'entrer dans une salle.
 *
 * Il ne se déduit pas de l'accès à la crise : c'est précisément la règle que
 * les canaux privés ajoutent. Une salle sans canal reste ouverte à la crise,
 * comme EF-801 le veut pour les cellules et les points inter-cellules.
 */
export async function salleAccessible(
  sql: Sql, criseId: string, salleId: string, compteId: string,
): Promise<boolean> {
  const [l] = await sql<{ ouvert: boolean }[]>`
    select (s.salon_id is null
            or exists (select 1 from salon sa
                        where sa.crise_id = s.crise_id and sa.id = s.salon_id
                          and (sa.prive = false
                               or exists (select 1 from salon_membre m
                                           where m.crise_id = sa.crise_id
                                             and m.salon_id = sa.id
                                             and m.compte_id = ${compteId})))) as ouvert
      from salle s where s.crise_id = ${criseId} and s.id = ${salleId}`;
  return l?.ouvert ?? false;
}

export async function lireSalle(
+26 −5
Changes for apps/api/src/noyau/projections.ts: 26 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -152,10 +152,10 @@ const projecteurs: Partial<Record<TypeEvenement, Projecteur>> = {
  "salle.ouverte": async (tx, ev) => {
    const ch = relire("salle.ouverte", ev.charge);
    await tx`
      insert into salle (crise_id, id, cellule_id, code, libelle, genre,
      insert into salle (crise_id, id, cellule_id, salon_id, code, libelle, genre,
                         nom_fournisseur, seq_creation, ouverte_at)
      values (${ev.crise_id}, ${ch.salle_id}, ${ev.cellule_id}, ${ch.code}, ${ch.libelle},
              ${ch.genre},
      values (${ev.crise_id}, ${ch.salle_id}, ${ev.cellule_id}, ${ch.salon_id ?? null},
              ${ch.code}, ${ch.libelle}, ${ch.genre},
              ${`kastell-${ev.crise_id.slice(0, 8)}-${ch.salle_id.slice(0, 8)}`},
              ${ev.seq}, ${ev.occurred_at})`;
  },
@@ -328,9 +328,30 @@ const projecteurs: Partial<Record<TypeEvenement, Projecteur>> = {
  "salon.cree": async (tx, ev) => {
    const ch = relire("salon.cree", ev.charge);
    await tx`
      insert into salon (crise_id, id, cellule_id, code, libelle, genre, seq_creation, cree_at)
      insert into salon (crise_id, id, cellule_id, code, libelle, genre, prive,
                         seq_creation, cree_at)
      values (${ev.crise_id}, ${ch.salon_id}, ${ev.cellule_id}, ${ch.code}, ${ch.libelle},
              ${ch.genre}, ${ev.seq}, ${ev.occurred_at})`;
              ${ch.genre}, ${ch.prive ?? false}, ${ev.seq}, ${ev.occurred_at})`;
  },

  /**
   * L'accès est donné en entier, jamais par différence.
   *
   * On efface la liste et on la réécrit : rejouer deux fois le même événement
   * doit donner le même état, et une projection qui ajoute sans retirer
   * laisserait au rembobinage des membres qu'on avait retirés.
   */
  "salon.acces.modifie": async (tx, ev) => {
    const ch = relire("salon.acces.modifie", ev.charge);
    await tx`update salon set prive = ${ch.prive}
              where crise_id = ${ev.crise_id} and id = ${ch.salon_id}`;
    await tx`delete from salon_membre
              where crise_id = ${ev.crise_id} and salon_id = ${ch.salon_id}`;
    for (const compteId of ch.membres) {
      await tx`insert into salon_membre (crise_id, salon_id, compte_id)
               values (${ev.crise_id}, ${ch.salon_id}, ${compteId})
               on conflict do nothing`;
    }
  },

  "salon.ferme": async (tx, ev) => {
Loading