Commit 5f657365 authored by Kourser's avatar Kourser
Browse files

Répéter sans parler au public, et un studio qui a son adresse

On ne pouvait éprouver le studio qu'en passant à l'antenne : cadrage, son,
mise en page se découvraient devant la presse. Une destination peut désormais
être marquée « chaîne de répétition, non répertoriée », et une diffusion se
lance en répétition.

La répétition ne vaut que si elle est étanche. Une répétition ne peut atteindre
que les chaînes de répétition, un vrai point presse ne peut pas les atteindre,
et l'on ne franchit pas la frontière en cours de diffusion. Le refus porte un
code, pas seulement une phrase : un contrôle qui ne reconnaissait que « refusé »
aurait pris n'importe quel autre refus — une diffusion déjà en cours — pour la
preuve de l'étanchéité. C'est ce que l'injection de défaut a montré.

La main courante le dit : « Répétition de … — rien n'est parti vers le
public », puis « Répétition terminée ». Six mois plus tard, on ne doit pas
croire avoir parlé au public ce jour-là. Le témoin passe à l'ambre : la
répétition n'emprunte pas le rouge du direct.

L'entrée au studio vit dans l'adresse. « Retour » faisait sortir de la salle de
presse entière, et un rechargement renvoyait à la liste — en plein direct.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 8e27181e
Loading
Loading
Loading
Loading
+27 −0
Changes for apps/api/src/db/migrations/0053_repetition.sql: 27 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- Répéter sans parler au public (EF-808).
--
-- ╔═════════════════════════════════════════════════════════════════════════╗
-- ║  Un essai qui part sur la vraie chaîne n'est pas un essai.              ║
-- ╚═════════════════════════════════════════════════════════════════════════╝
--
-- On ne pouvait éprouver le studio qu'en passant à l'antenne : cadrage, son,
-- écran partagé, mise en page — tout se découvrait devant la presse. La
-- répétition existe donc, et elle ne vaut que si elle est ÉTANCHE : une
-- destination de répétition est une chaîne non répertoriée, et une répétition
-- ne peut atteindre qu'elle. Mélanger les deux mondes, c'est répéter en public
-- — l'erreur exacte qu'on cherche à rendre impossible.
--
-- Le drapeau vit sur la destination (préparée à froid, comme les autres) et sur
-- la diffusion (pour que la main courante ne dise pas « diffusé » d'un essai).
-- ═══════════════════════════════════════════════════════════════════════════

alter table destination_diffusion
  add column if not exists repetition boolean not null default false;
alter table diffusion
  add column if not exists repetition boolean not null default false;

comment on column destination_diffusion.repetition is
  'EF-808 : chaîne non répertoriée, réservée aux répétitions.';
comment on column diffusion.repetition is
  'EF-808 : répétition — rien n''est parti vers le public.';
+17 −8
Changes for apps/api/src/diffusion/destinations.ts: 17 added lines, 8 removed lines.
Original line number Diff line number Diff line
@@ -30,13 +30,20 @@ export interface Destination {
  /** De quoi reconnaître sa propre destination, jamais de quoi diffuser. */
  apercu: string;
  actif: boolean;
  /**
   * Une chaîne non répertoriée, réservée aux répétitions.
   *
   * Une répétition ne vaut que si elle est étanche : elle ne peut atteindre que
   * ces destinations-là, et un vrai point presse ne peut pas les atteindre.
   */
  repetition: boolean;
  cree_at: Date;
  modifie_at: Date | null;
}

export async function lister(sql: Sql, organisationId: string): Promise<Destination[]> {
  return sql<Destination[]>`
    select id, plateforme, libelle, apercu, actif, cree_at, modifie_at
    select id, plateforme, libelle, apercu, actif, repetition, cree_at, modifie_at
      from destination_diffusion
     where organisation_id = ${organisationId}
     order by actif desc, plateforme, libelle`;
@@ -152,7 +159,7 @@ export async function eprouver(

export async function configurer(
  sql: Sql, secretInstance: string, organisationId: string,
  d: { plateforme: Plateforme; libelle: string; url: string },
  d: { plateforme: Plateforme; libelle: string; url: string; repetition?: boolean },
  parCompteId: string,
): Promise<{ id: string; apercu: string }> {
  verifierUrl(d.url);
@@ -161,9 +168,10 @@ export async function configurer(
  return (await sql.begin(async (tx) => {
    const [ligne] = await tx<{ id: string }[]>`
      insert into destination_diffusion
        (organisation_id, plateforme, libelle, url, apercu, cree_par)
        (organisation_id, plateforme, libelle, url, apercu, repetition, cree_par)
      values (${organisationId}, ${d.plateforme}, ${d.libelle},
              ${sceller(secretInstance, d.url.trim())}, ${vue}, ${parCompteId})
              ${sceller(secretInstance, d.url.trim())}, ${vue},
              ${d.repetition ?? false}, ${parCompteId})
      returning id`;
    // Au journal : qu'une destination existe, chez qui, sous quel nom. Jamais
    // l'URL — elle porte la clé de flux, et un journal en ajout seul est le
@@ -230,16 +238,17 @@ export async function retirer(
 */
export async function urlsArmees(
  sql: Sql, secretInstance: string, organisationId: string, ids: readonly string[],
): Promise<{ id: string; libelle: string; plateforme: Plateforme; url: string }[]> {
): Promise<{ id: string; libelle: string; plateforme: Plateforme; url: string;
  repetition: boolean }[]> {
  if (ids.length === 0) return [];
  const lignes = await sql<{
    id: string; libelle: string; plateforme: Plateforme; url: Buffer;
    id: string; libelle: string; plateforme: Plateforme; url: Buffer; repetition: boolean;
  }[]>`
    select id, libelle, plateforme, url from destination_diffusion
    select id, libelle, plateforme, url, repetition from destination_diffusion
     where organisation_id = ${organisationId} and actif
       and id = any(${sql.array(ids as string[])}::uuid[])`;
  return lignes.map((l) => ({
    id: l.id, libelle: l.libelle, plateforme: l.plateforme,
    id: l.id, libelle: l.libelle, plateforme: l.plateforme, repetition: l.repetition,
    url: ouvrir(secretInstance, l.url),
  }));
}
+73 −11
Changes for apps/api/src/domaine/presse.ts: 73 added lines, 11 removed lines.
Original line number Diff line number Diff line
@@ -26,6 +26,25 @@ import * as masse from "../canaux/masse.js";
 * entre au journal de la crise avec le nom de la plateforme — jamais sa clé.
 */

/**
 * Un refus à la frontière entre répétition et point presse.
 *
 * Il porte un code et non seulement une phrase. Les phrases se reformulent, et
 * un contrôle qui ne reconnaîtrait pas la raison accepterait n'importe quel
 * autre refus — une salle vide, une destination désarmée — comme preuve que la
 * frontière tient. C'est ainsi qu'une garantie devient vide sans que rien ne le
 * signale.
 */
export class FrontiereRepetition extends Error {
  constructor(
    readonly code: "repetition_vers_public" | "presse_vers_repetition",
    message: string,
  ) {
    super(message);
    this.name = "FrontiereRepetition";
  }
}

export async function ouvrirSallePresse(
  sql: Sql, criseId: string, libelle: string, ctx: Contexte,
): Promise<string> {
@@ -55,6 +74,8 @@ export interface Diffusion {
  demarre_at: Date;
  arrete_at: Date | null;
  statut: "en_cours" | "arretee" | "echec";
  /** Une répétition : rien n'est parti vers le public. */
  repetition: boolean;
  destinations: EtatDestination[];
}

@@ -87,7 +108,7 @@ export async function enCours(
export async function demarrer(
  sql: Sql, secretInstance: string, criseId: string, organisationId: string,
  salleId: string, destinationIds: readonly string[],
  miseEnPage: MiseEnPage, ctx: Contexte,
  miseEnPage: MiseEnPage, repetition: boolean, ctx: Contexte,
): Promise<{ diffusionId: string; destinations: number }> {
  const salle = await lireSallePresse(sql, criseId, salleId);
  if (await enCours(sql, criseId, salleId)) {
@@ -101,6 +122,26 @@ export async function demarrer(
  if (sorties.length === 0) {
    throw new Error("Aucune destination armée parmi celles demandées.");
  }
  /*
    L'étanchéité entre répétition et point presse.

    On ne pouvait éprouver le studio qu'en passant à l'antenne : cadrage, son,
    mise en page se découvraient devant la presse. La répétition ne vaut que si
    elle ne peut PAS atteindre le public — sinon c'est répéter en public,
    l'erreur exacte qu'on veut rendre impossible. Et l'inverse : un point presse
    envoyé vers une chaîne d'essai laisserait la presse devant un écran noir
    pendant que la main courante dirait « diffusé ».
  */
  const horsMonde = sorties.filter((x) => x.repetition !== repetition);
  if (horsMonde.length > 0) {
    throw new FrontiereRepetition(
      repetition ? "repetition_vers_public" : "presse_vers_repetition",
      repetition
        ? `Une répétition n'atteint que les chaînes de répétition — `
          + `« ${horsMonde.map((x) => x.libelle).join(", ")} » est publique.`
        : `Un point presse ne part pas vers une chaîne de répétition — `
          + `« ${horsMonde.map((x) => x.libelle).join(", ")} ».`);
  }

  await media().ouvrirSalle(salle.nom_fournisseur);
  /*
@@ -134,6 +175,7 @@ export async function demarrer(
        destinations: sorties.map((s) => ({
          destination_id: s.id, plateforme: s.plateforme, libelle: s.libelle,
        })),
        repetition,
      },
      ctx,
    });
@@ -145,8 +187,11 @@ export async function demarrer(
    await enregistrer(tx, {
      criseId, type: "main_courante.consignee",
      charge: {
        genre: "communication",
        texte: `« ${salle.libelle} » diffusé vers `
        genre: repetition ? "fait" : "communication",
        texte: repetition
          ? `Répétition de « ${salle.libelle} » vers `
            + `${sorties.map((x) => x.libelle).join(", ")} — rien n'est parti vers le public.`
          : `« ${salle.libelle} » diffusé vers `
            + `${sorties.map((x) => x.libelle).join(", ")}.`,
        diffusion: "cellule",
      },
@@ -160,14 +205,24 @@ export async function ouvrirDestination(
  sql: Sql, secretInstance: string, criseId: string, organisationId: string,
  diffusionId: string, destinationId: string, ctx: Contexte,
): Promise<void> {
  const [d] = await sql<{ tache: string | null; statut: string }[]>`
    select tache, statut from diffusion
  const [d] = await sql<{ tache: string | null; statut: string; repetition: boolean }[]>`
    select tache, statut, repetition from diffusion
     where crise_id = ${criseId} and id = ${diffusionId}`;
  if (!d || d.statut !== "en_cours") throw new Error("Aucune diffusion en cours.");

  const [sortie] = await destinations.urlsArmees(
    sql, secretInstance, organisationId, [destinationId]);
  if (!sortie) throw new Error("Destination inconnue ou non armée.");
  // Élargir une répétition à la vraie chaîne, c'est passer en public sans
  // l'avoir décidé : le chemin le plus court vers l'erreur que la répétition
  // devait éviter. On ne franchit pas la frontière en cours de route.
  if (sortie.repetition !== d.repetition) {
    throw new FrontiereRepetition(
      d.repetition ? "repetition_vers_public" : "presse_vers_repetition",
      d.repetition
        ? "On n'élargit pas une répétition à une chaîne publique : arrêtez-la et passez à l'antenne."
        : "On n'élargit pas un point presse à une chaîne de répétition.");
  }

  if (d.tache) await media().majDiffusion(d.tache, [sortie.url], []);
  await sql.begin(async (tx) => {
@@ -234,8 +289,8 @@ export async function arreter(
  sql: Sql, criseId: string, diffusionId: string, ctx: Contexte,
): Promise<void> {
  const [d] = await sql<{ tache: string | null; salle_id: string;
    demarre_at: Date; statut: string }[]>`
    select tache, salle_id, demarre_at, statut from diffusion
    demarre_at: Date; statut: string; repetition: boolean }[]>`
    select tache, salle_id, demarre_at, statut, repetition from diffusion
     where crise_id = ${criseId} and id = ${diffusionId}`;
  if (!d || d.statut !== "en_cours") throw new Error("Aucune diffusion en cours.");
  if (d.tache) await media().arreterDiffusion(d.tache).catch(() => undefined);
@@ -249,7 +304,11 @@ export async function arreter(
    await enregistrer(tx, {
      criseId, type: "main_courante.consignee",
      charge: { genre: "communication", diffusion: "cellule",
        texte: `Point presse terminé, après ${Math.round(duree / 60)} minute(s) `
        // Une répétition qui s'achève ne se relit pas « point presse terminé » :
        // on croirait, six mois plus tard, avoir parlé au public ce jour-là.
        texte: d.repetition
          ? `Répétition terminée, après ${Math.round(duree / 60)} minute(s).`
          : `Point presse terminé, après ${Math.round(duree / 60)} minute(s) `
            + "de diffusion." },
      ctx: { ...ctx, origine: "automatique" },
    });
@@ -440,7 +499,7 @@ export async function lire(
  sql: Sql, criseId: string, diffusionId: string,
): Promise<Diffusion | null> {
  const [d] = await sql<Omit<Diffusion, "destinations">[]>`
    select id, salle_id, demarre_at, arrete_at, statut from diffusion
    select id, salle_id, demarre_at, arrete_at, statut, repetition from diffusion
     where crise_id = ${criseId} and id = ${diffusionId}`;
  if (!d) return null;
  return { ...d, destinations: await sql<EtatDestination[]>`
@@ -454,11 +513,14 @@ export async function lire(
/** Les salles de presse d'une crise, ouvertes ou non. */
export async function salles(sql: Sql, criseId: string) {
  return sql<{ id: string; libelle: string; fermee_at: Date | null;
    diffusion_id: string | null }[]>`
    diffusion_id: string | null; repetition: boolean | null }[]>`
    select s.id, s.libelle, s.fermee_at,
           (select d.id from diffusion d
             where d.crise_id = s.crise_id and d.salle_id = s.id
               and d.statut = 'en_cours') as diffusion_id
               and d.statut = 'en_cours') as diffusion_id,
           (select d.repetition from diffusion d
             where d.crise_id = s.crise_id and d.salle_id = s.id
               and d.statut = 'en_cours') as repetition
      from salle s
     where s.crise_id = ${criseId} and s.genre = 'presse'
     order by s.ouverte_at desc`;
+1 −0
Changes for apps/api/src/langues/en.ts: 1 added line, 0 removed lines.
Original line number Diff line number Diff line
@@ -71,6 +71,7 @@ export const EN: Record<string, string> = {
  "Salle de presse fermée.": "Press room closed.",
  "Cette salle diffuse encore : arrêtez la diffusion avant de la fermer.":
    "This room is still broadcasting: stop the broadcast before closing it.",
  "Répétition du point presse vers {ou}": "Press briefing rehearsal to {ou}",
  "Canal inconnu.": "Unknown channel.",
  "Ce code est inconnu ou périmé. Demandez-en un nouveau depuis votre session web.":
    "This code is unknown or out of date. Ask for a new one from your web session.",
+9 −5
Changes for apps/api/src/noyau/formuler.ts: 9 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -85,11 +85,15 @@ export function resumer(
    case "salle.ouverte":
      return t("Salle de visioconférence ouverte — {libelle}",
        { libelle: (c.libelle ?? "").replace(/^Visio /, "") });
    case "diffusion.demarree":
      return t("Point presse diffusé vers {ou}", {
        ou: ((c.destinations ?? []) as { libelle?: string }[])
          .map((d) => d.libelle ?? "").join(", "),
      });
    case "diffusion.demarree": {
      const ou = ((c.destinations ?? []) as { libelle?: string }[])
        .map((d) => d.libelle ?? "").join(", ");
      // Une répétition ne se relit pas comme un point presse : dire « diffusé »
      // d'un essai ferait croire au retour d'expérience qu'on a parlé au public.
      return c.repetition
        ? t("Répétition du point presse vers {ou}", { ou })
        : t("Point presse diffusé vers {ou}", { ou });
    }
    case "diffusion.destination_ouverte":
      return t("Diffusion élargie à {ou}", { ou: (c.libelle ?? "") as string });
    case "diffusion.destination_retiree":
Loading