Commit 65ed3a1e authored by Kourser's avatar Kourser
Browse files

Quand la donnée tombe, le téléphone reste

Le produit joignait par SMS, par courriel et par heurtoir. Trois canaux qui
passent tous par la donnée. Une cellule reste donc injoignable dès que
l'opérateur mobile sature ou que la messagerie est chiffrée — c'est-à-dire le
jour où l'on en a besoin.

L'appel vocal, troisième genre de canal
  Une passerelle de synthèse lit le texte au téléphone. Elle s'ajoute au
  catalogue comme les autres, avec la même vérification de destination : une
  passerelle d'alerte hébergée dans le SI qu'on secourt ne répondrait pas le
  jour J, et le contrôle sortant le refuse.

  Un message vocal ne se rédige pas comme un SMS, et l'écran le dit à l'endroit
  où on l'écrit : phrases courtes, l'essentiel d'abord, et le numéro répété
  deux fois — on n'écoute qu'une seule fois, et souvent mal.

Le pont, qu'on n'héberge pas
  Une conférence téléphonique demande une infrastructure SIP que nous n'avons
  pas et que nous ferions mal. Ce qui manquait n'était d'ailleurs pas le pont —
  il s'achète — mais le fait de l'avoir sous la main.

  Kastell en retient donc le numéro et le code, au journal parce qu'« à quelle
  heure a-t-on ouvert le pont, et lequel » est une question du retour
  d'expérience. La variable « pont » les glisse dans n'importe quel message.

Éprouvé : dans un texte vocal, le pont se résout bien deux fois, et son absence
est signalée au lieu de partir telle quelle.

Non éprouvé : la migration 0025 n'a jamais été appliquée, et pnpm verif n'a pas
tourné — ni Docker ni PostgreSQL sur la machine de travail. Aucune garantie
n'accompagne ce lot, contrairement aux précédents.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 72e43622
Loading
Loading
Loading
Loading
+24 −4
Changes for apps/api/src/canaux/fournisseurs.ts: 24 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -23,14 +23,14 @@ export interface Envoi {

export interface Fournisseur {
  readonly nom: string;
  readonly genre: "sms" | "courriel";
  readonly genre: "sms" | "courriel" | "voix";
  envoyer(e: Envoi): Promise<void>;
}

export interface Definition {
  cle: string;
  libelle: string;
  genre: "sms" | "courriel";
  genre: "sms" | "courriel" | "voix";
  /** Ce qu'il faut coller, et où le trouver. */
  identifiants: { cle: string; libelle: string; aide: string }[];
  reglages: { cle: string; libelle: string; aide: string; requis: boolean }[];
@@ -157,7 +157,7 @@ export function generique(
  id: { jeton: string },
  r: { url: string; entete?: string | undefined; gabarit: string;
    expediteur?: string | undefined },
  genre: "sms" | "courriel",
  genre: "sms" | "courriel" | "voix",
): Fournisseur {
  return {
    nom: "generique",
@@ -194,7 +194,7 @@ export function generique(
}

/** Passerelle d'essai : rien ne part, tout est retenu. */
export function essai(genre: "sms" | "courriel"): Fournisseur & { envoyes: Envoi[] } {
export function essai(genre: "sms" | "courriel" | "voix"): Fournisseur & { envoyes: Envoi[] } {
  const etat = {
    nom: "essai" as const, genre,
    envoyes: [] as Envoi[],
@@ -271,6 +271,26 @@ export const CATALOGUE: Definition[] = [
    ],
    note: "Pour Sarbacane, Mailjet, Postmark, ou le relais de votre hébergeur.",
  },
  {
    cle: "generique_voix", libelle: "Passerelle d'appel vocal (HTTP)", genre: "voix",
    identifiants: [{ cle: "jeton", libelle: "Jeton d'authentification",
      aide: "Fourni par votre opérateur de téléphonie." }],
    reglages: [
      { cle: "url", libelle: "Adresse de la passerelle", requis: true,
        aide: "En HTTPS. Le réseau interne est refusé : une passerelle d'alerte "
          + "hébergée dans le SI qu'on secourt ne répondrait pas le jour J." },
      { cle: "entete", libelle: "En-tête d'authentification", requis: false,
        aide: "« authorization » par défaut." },
      { cle: "expediteur", libelle: "Numéro appelant", requis: false, aide: "" },
      { cle: "gabarit", libelle: "Corps de la requête", requis: true,
        aide: "JSON, avec {{destinataire}} et {{texte}}. Le texte est lu par la "
          + "synthèse vocale de l'opérateur." },
    ],
    note: "Le dernier chemin quand la donnée tombe. Un message vocal se rédige "
      + "autrement qu'un SMS : phrases courtes, l'essentiel d'abord, et le "
      + "numéro du pont répété deux fois — on n'écoute qu'une seule fois, et "
      + "souvent mal.",
  },
];

export function construire(
+2 −2
Changes for apps/api/src/canaux/masse.ts: 2 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -113,7 +113,7 @@ export interface Destinataire {
 * ce sont eux qu'il faudra appeler.
 */
export async function apercu(
  sql: Sql, organisationId: string, genre: "sms" | "courriel", s: Selection,
  sql: Sql, organisationId: string, genre: "sms" | "courriel" | "voix", s: Selection,
): Promise<{ destinataires: Destinataire[]; joignables: number; injoignables: number }> {
  const exclus = new Set(s.exclus ?? []);
  const destinataires_: Destinataire[] = [];
@@ -190,7 +190,7 @@ export async function apercu(
}

export interface Lancement {
  genre: "sms" | "courriel";
  genre: "sms" | "courriel" | "voix";
  objet?: string | undefined;
  message: string;
  selection: Selection;
+6 −6
Changes for apps/api/src/canaux/organisation.ts: 6 added lines, 6 removed lines.
Original line number Diff line number Diff line
@@ -14,7 +14,7 @@ import { CATALOGUE, construire, type Fournisseur } from "./fournisseurs.js";

export interface Canal {
  id: string;
  genre: "sms" | "courriel";
  genre: "sms" | "courriel" | "voix";
  fournisseur: string;
  libelle: string;
  apercu: Record<string, string>;
@@ -36,7 +36,7 @@ export async function lister(sql: Sql, organisationId: string): Promise<Canal[]>
}

export interface Configuration {
  genre: "sms" | "courriel";
  genre: "sms" | "courriel" | "voix";
  fournisseur: string;
  libelle: string;
  identifiants: Record<string, string>;
@@ -94,7 +94,7 @@ export async function activer(
  sql: Sql, organisationId: string, canalId: string, parCompteId: string,
): Promise<void> {
  await sql.begin(async (tx) => {
    const [c] = await tx<{ genre: "sms" | "courriel"; libelle: string }[]>`
    const [c] = await tx<{ genre: "sms" | "courriel" | "voix"; libelle: string }[]>`
      select genre, libelle from canal_organisation
       where id = ${canalId} and organisation_id = ${organisationId}`;
    if (!c) throw new Error("Canal inconnu.");
@@ -116,7 +116,7 @@ export async function retirer(
  sql: Sql, organisationId: string, canalId: string, parCompteId: string,
): Promise<boolean> {
  return (await sql.begin(async (tx) => {
    const [c] = await tx<{ genre: "sms" | "courriel"; libelle: string }[]>`
    const [c] = await tx<{ genre: "sms" | "courriel" | "voix"; libelle: string }[]>`
      delete from canal_organisation
       where id = ${canalId} and organisation_id = ${organisationId}
      returning genre, libelle`;
@@ -137,7 +137,7 @@ export async function retirer(
 * plus par envoi — négligeable devant l'appel réseau qui suit.
 */
export async function fournisseurActif(
  sql: Sql, secretInstance: string, organisationId: string, genre: "sms" | "courriel",
  sql: Sql, secretInstance: string, organisationId: string, genre: "sms" | "courriel" | "voix",
): Promise<Fournisseur | null> {
  const [c] = await sql<{ fournisseur: string; identifiants: Buffer;
    reglages: Record<string, string> }[]>`
@@ -159,7 +159,7 @@ export async function eprouver(
  sql: Sql, secretInstance: string, organisationId: string, canalId: string,
  destinataire: string,
): Promise<{ ok: boolean; detail: string }> {
  const [c] = await sql<{ genre: "sms" | "courriel"; fournisseur: string;
  const [c] = await sql<{ genre: "sms" | "courriel" | "voix"; fournisseur: string;
    identifiants: Buffer; reglages: Record<string, string> }[]>`
    select genre, fournisseur, identifiants, reglages from canal_organisation
     where id = ${canalId} and organisation_id = ${organisationId}`;
+62 −0
Changes for apps/api/src/db/migrations/0025_voix.sql: 62 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- La voix, et le pont téléphonique (EF-1105, L12 partiel)
--
-- Quand la donnée tombe, le téléphone reste. C'est le dernier chemin d'alerte,
-- et c'était le seul canal que le produit n'avait pas : une cellule joignable
-- par SMS et par courriel ne l'est plus dès que l'opérateur mobile sature ou
-- que le SI de messagerie est chiffré.
--
-- Ce qui est ajouté ici est la diffusion vocale — un texte lu par une
-- passerelle de synthèse. Ce qui ne l'est pas, et ne le sera pas ici : le pont
-- de conférence lui-même, qui demande une infrastructure SIP. On enregistre
-- son numéro et son code, parce qu'un pont acheté ailleurs et introuvable le
-- jour J ne sert à rien.
-- ═══════════════════════════════════════════════════════════════════════════

-- ── Un genre de canal de plus ──────────────────────────────────────────────
--
-- Les contraintes se cherchent au lieu de se deviner : leur nom vient d'une
-- convention de PostgreSQL, et une migration qui parie sur un nom échoue au
-- déploiement plutôt qu'à l'écriture.
do $$
declare nom text;
begin
  for nom in
    select conname from pg_constraint
     where conrelid in ('canal_organisation'::regclass, 'envoi_masse'::regclass)
       and contype = 'c'
       and pg_get_constraintdef(oid) like '%sms%'
  loop
    execute format('alter table %s drop constraint %I',
      (select conrelid::regclass from pg_constraint where conname = nom), nom);
  end loop;
end $$;

alter table canal_organisation add constraint canal_organisation_genre_check
  check (genre in ('sms', 'courriel', 'voix'));
alter table envoi_masse add constraint envoi_masse_genre_check
  check (genre in ('sms', 'courriel', 'voix'));

-- Les modèles de message aussi : on prépare un texte à faire lire au
-- téléphone comme on prépare un communiqué.
do $$
declare nom text;
begin
  select conname into nom from pg_constraint
   where conrelid = 'modele_message'::regclass and contype = 'c'
     and pg_get_constraintdef(oid) like '%sms%';
  if nom is not null then
    execute format('alter table modele_message drop constraint %I', nom);
  end if;
end $$;
alter table modele_message add constraint modele_message_genre_check
  check (genre in ('sms', 'courriel', 'voix'));

-- ── Le pont téléphonique d'une crise ───────────────────────────────────────
--
-- Pas une conférence que nous hébergeons : un numéro et un code, saisis par la
-- cellule, affichés partout où ils servent et insérables dans un message par
-- la variable {{pont}}. Le pont est acheté ailleurs ; ce qui manquait, c'est
-- qu'il soit sous la main au moment où plus rien d'autre ne répond.
alter table crise add column pont_numero text;
alter table crise add column pont_code text;
+17 −0
Changes for apps/api/src/domaine/crise.ts: 17 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -99,6 +99,23 @@ export async function changerGravite(
  })) as EvenementEnregistre;
}

/**
 * Le pont de conférence de la crise.
 *
 * Nous n'hébergeons pas de pont : celui-ci est acheté ailleurs, et ce qui
 * manquait n'était pas la conférence mais le fait de l'avoir sous la main. Un
 * numéro introuvable le jour J ne sert à rien, et c'est le canal qui reste
 * quand la donnée est tombée.
 */
export async function definirPont(
  sql: Sql, criseId: string, numero: string | null, code: string | null, ctx: Contexte,
): Promise<EvenementEnregistre> {
  return (await sql.begin((tx) => enregistrer(tx, {
    criseId, type: "crise.pont_defini",
    charge: { numero: numero?.trim() || null, code: code?.trim() || null }, ctx,
  }))) as EvenementEnregistre;
}

export async function ajouterMembre(
  sql: Sql, criseId: string, celluleId: string, compteId: string,
  role: "animateur" | "membre" | "externe" | "observateur",
Loading