Commit 08dd0881 authored by Kourser's avatar Kourser
Browse files

Canaux de diffusion par organisation, et envois de masse

Chaque organisation branche ses propres passerelles : OVHcloud pour le SMS,
Brevo pour le courriel, et un fournisseur générique décrit par un gabarit HTTP
pour tous les autres.

Le générique n'est pas un pis-aller
  Sarbacane, Mailjet, Twilio, Vonage, un agrégateur régional, une passerelle
  interne — tous parlent HTTP avec un jeton et un corps JSON. Décrire la
  requête plutôt que coder un adaptateur par opérateur évite que « brancher
  notre fournisseur » devienne une demande d'évolution. Pendant une crise,
  attendre une version n'est pas une option.

Une clé d'API n'est pas un mot de passe
  Un mot de passe se hache : on n'a jamais besoin de le relire. Une clé d'API
  doit être relue pour servir, donc chiffrée — AES-256-GCM, clé dérivée de
  SECRET_SESSION avec un contexte distinct. Confondre les deux est l'erreur
  courante ; elle mérite d'être nommée dans le code plutôt que découverte.

  Les identifiants ne ressortent jamais : ni par l'API, qui n'en rend qu'un
  aperçu de quatre caractères, ni par la console root. Administrer une instance
  n'est pas lire les secrets de ses clients, et un exploitant capable d'envoyer
  des SMS au nom de ses clients serait un risque pour eux, pas un service.

  Le journal retient qu'un canal a été branché, par qui, chez quel fournisseur.
  Jamais la clé : un journal en ajout seul est le pire endroit où poser un
  secret, puisqu'on ne peut pas l'en retirer. Un contrôle relit le journal
  entier pour s'en assurer.

Écrire à tout un annuaire est un acte, pas une commodité
  L'aperçu d'abord : on voit qui recevra, nommément, et surtout qui ne recevra
  pas et pourquoi. Les injoignables ne sont pas masqués — ce sont eux qu'il
  faudra appeler, et c'est maintenant qu'on doit le savoir.

  Le décompte se recopie à la main. Ce n'est pas une case à cocher : recopier
  « 84 » oblige à avoir lu l'aperçu. Une confirmation qu'on valide sans lire ne
  protège de rien, et c'est exactement le geste qu'on fait à trois heures du
  matin. Le serveur revérifie le nombre de son côté : le garde-fou n'est pas
  dans l'interface.

  Plafond à 2000, envois par vagues de cinq — une passerelle qui reçoit deux
  cents requêtes en une seconde coupe le robinet, et c'est le moment où l'on en
  a besoin. Et un essai vers soi-même : un canal jamais éprouvé est une
  hypothèse, on ne découvre pas une clé fausse la nuit d'une mobilisation.

Deux garde-fous ajoutés en chemin
  Le fournisseur générique refuse une adresse d'API en clair hors machine
  locale : un jeton envoyé en clair est un jeton perdu. Et l'écran dit ce que
  personne ne dira à la place du produit — si le compte du fournisseur s'ouvre
  avec l'authentification de l'entreprise, il tombe avec elle.

532 → 557 garanties. Éprouvé au navigateur : une passerelle branchée n'affiche
que « ••••••••6789 » et son jeton n'apparaît nulle part dans la page ; le bouton
d'envoi reste inerte tant que le décompte n'est pas recopié juste. Les envois
sont éprouvés contre un vrai serveur HTTP local, ce qui valide aussi le contrat
du gabarit — en-tête, substitution, corps transmis.

Ce qui n'est pas vérifié : le format de fil d'OVH et de Brevo contre leurs API
réelles. Je n'ai ni identifiants ni intention d'envoyer de vrais messages.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 5c82369d
Loading
Loading
Loading
Loading
+27 −0
Original line number Diff line number Diff line
@@ -411,6 +411,33 @@ domaine résolvable. En production, activez `tls_port: 443` dans
pare-feux laissent passer, et souvent le seul chemin depuis l'hôtel où la
cellule s'est repliée.

## Canaux de diffusion et envois de masse

Chaque organisation branche **ses propres passerelles** — OVHcloud pour le SMS,
Brevo pour le courriel, ou n'importe quel fournisseur parlant HTTP décrit par
un gabarit. C'est ce qui permet de joindre tout un annuaire sans passer par
l'infrastructure de l'éditeur, donc sans que celui-ci devienne un point de
passage obligé le jour où il tombe, ni un intermédiaire qui voit passer les
messages de crise de ses clients.

Les identifiants sont **chiffrés au repos** (AES-256-GCM, clé dérivée de
`SECRET_SESSION` avec un contexte distinct) et ne ressortent jamais : ni par
l'API, ni par la console root. Administrer une instance n'est pas lire les
secrets de ses clients. Le journal d'administration retient qu'un canal a été
branché, par qui, chez quel fournisseur — jamais la clé : **un journal en ajout
seul est le pire endroit où poser un secret, puisqu'on ne peut pas l'en
retirer.**

Écrire à tout un annuaire est un acte, pas une commodité :

- **L'aperçu d'abord.** On voit qui recevra, nommément, et surtout qui ne
  recevra pas et pourquoi — ce sont ceux-là qu'il faudra appeler.
- **Le décompte se recopie à la main.** Une confirmation qu'on peut valider
  sans lire ne protège de rien, et c'est exactement le geste qu'on fait à trois
  heures du matin.
- **Un plafond à 2000**, un débit par vagues de cinq, et un essai vers soi-même
  avant tout. Un canal jamais éprouvé est une hypothèse.

## Thème et signaux

Trois états dans la barre d'état — **auto, clair, sombre**. « Auto » est le
+280 −0
Original line number Diff line number Diff line
import { createHash } from "node:crypto";

/**
 * Fournisseurs de diffusion, branchés par l'organisation elle-même.
 *
 * Chaque organisation apporte ses propres identifiants. C'est ce qui permet
 * de joindre tout un annuaire sans passer par l'infrastructure de l'éditeur —
 * donc sans que l'éditeur devienne un point de passage obligé le jour où il
 * tombe, ni un intermédiaire qui voit passer les messages de crise de ses
 * clients.
 *
 * Deux implémentations propres par genre, et un fournisseur générique. Le
 * générique n'est pas un pis-aller : c'est ce qui évite qu'ajouter un
 * opérateur devienne une demande d'évolution à attendre pendant une crise.
 */

export interface Envoi {
  destinataire: string;
  objet?: string | undefined;
  texte: string;
}

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

export interface Definition {
  cle: string;
  libelle: string;
  genre: "sms" | "courriel";
  /** 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 }[];
  /** Ce que l'exploitant doit savoir avant de choisir celui-là. */
  note: string;
}

const DELAI_MS = 12_000;

async function poster(url: string, init: RequestInit): Promise<Response> {
  const r = await fetch(url, { ...init, signal: AbortSignal.timeout(DELAI_MS) });
  if (!r.ok) {
    const corps = await r.text().catch(() => "");
    throw new Error(`${r.status} ${r.statusText} ${corps.slice(0, 200)}`.trim());
  }
  return r;
}

// ═══════════════════════════════════════════════════════════════════════════
// SMS
// ═══════════════════════════════════════════════════════════════════════════

/**
 * OVHcloud SMS.
 *
 * L'API OVH signe chaque requête plutôt que de porter un jeton : la signature
 * couvre la méthode, l'URL, le corps et l'horodatage. C'est plus verbeux qu'un
 * en-tête « Bearer », et c'est aussi ce qui fait qu'une requête interceptée ne
 * se rejoue pas.
 */
export function ovhSms(id: {
  applicationKey: string; applicationSecret: string; consumerKey: string;
}, r: { region?: string | undefined; service: string; expediteur?: string | undefined }): Fournisseur {
  const base = `https://${r.region === "ca" ? "ca.api.ovh.com" : "eu.api.ovh.com"}/1.0`;

  return {
    nom: "ovh",
    genre: "sms",
    async envoyer(e) {
      const url = `${base}/sms/${encodeURIComponent(r.service)}/jobs`;
      const corps = JSON.stringify({
        message: e.texte,
        receivers: [e.destinataire],
        charset: "UTF-8",
        // Le SMS de crise n'est pas un message commercial : il ne porte pas de
        // mention de désinscription, qui prendrait la place du contenu utile
        // et n'a pas de sens pour une mobilisation.
        noStopClause: true,
        priority: "high",
        senderForResponse: !r.expediteur,
        ...(r.expediteur ? { sender: r.expediteur } : {}),
      });

      // L'horloge d'OVH fait foi : la nôtre peut dériver, et une signature
      // horodatée de travers est refusée sans expliquer pourquoi.
      const t = await fetch(`${base}/auth/time`, { signal: AbortSignal.timeout(DELAI_MS) });
      const horodatage = (await t.text()).trim();

      const signature = "$1$" + createHash("sha1")
        .update([id.applicationSecret, id.consumerKey, "POST", url, corps, horodatage].join("+"))
        .digest("hex");

      await poster(url, {
        method: "POST",
        headers: {
          "content-type": "application/json",
          "X-Ovh-Application": id.applicationKey,
          "X-Ovh-Consumer": id.consumerKey,
          "X-Ovh-Timestamp": horodatage,
          "X-Ovh-Signature": signature,
        },
        body: corps,
      });
    },
  };
}

// ═══════════════════════════════════════════════════════════════════════════
// Courriel
// ═══════════════════════════════════════════════════════════════════════════

/** Brevo, API transactionnelle v3. */
export function brevo(id: { apiKey: string },
  r: { expediteur: string; nomExpediteur?: string | undefined }): Fournisseur {
  return {
    nom: "brevo",
    genre: "courriel",
    async envoyer(e) {
      await poster("https://api.brevo.com/v3/smtp/email", {
        method: "POST",
        headers: { "content-type": "application/json", "api-key": id.apiKey },
        body: JSON.stringify({
          sender: { email: r.expediteur, ...(r.nomExpediteur ? { name: r.nomExpediteur } : {}) },
          to: [{ email: e.destinataire }],
          subject: e.objet ?? "Message de crise",
          textContent: e.texte,
        }),
      });
    },
  };
}

/**
 * Fournisseur générique : une requête HTTP décrite par l'organisation.
 *
 * Sarbacane, Mailjet, Twilio, Vonage, un agrégateur régional, une passerelle
 * interne — tous parlent HTTP avec un jeton et un corps JSON. Décrire la
 * requête plutôt que coder un adaptateur par opérateur évite que « brancher
 * notre fournisseur » devienne une demande d'évolution à attendre. Et pendant
 * une crise, attendre une version n'est pas une option.
 *
 * Les substitutions disponibles dans le gabarit : {{destinataire}}, {{texte}},
 * {{objet}}, {{expediteur}}.
 */
export function generique(
  id: { jeton: string },
  r: { url: string; entete?: string | undefined; gabarit: string;
    expediteur?: string | undefined },
  genre: "sms" | "courriel",
): Fournisseur {
  return {
    nom: "generique",
    genre,
    async envoyer(e) {
      // Une clé d'API qui part en clair est une clé publiée. On ne l'accepte
      // qu'en clair vers la machine locale, pour l'essai et le développement.
      const cible = new URL(r.url);
      const locale = ["localhost", "127.0.0.1", "::1"].includes(cible.hostname);
      if (cible.protocol !== "https:" && !locale) {
        throw new Error("L'adresse de l'API doit être en HTTPS : "
          + "un jeton envoyé en clair est un jeton perdu.");
      }
      const remplacer = (t: string) => t
        .replaceAll("{{destinataire}}", e.destinataire)
        .replaceAll("{{texte}}", JSON.stringify(e.texte).slice(1, -1))
        .replaceAll("{{objet}}", JSON.stringify(e.objet ?? "").slice(1, -1))
        .replaceAll("{{expediteur}}", r.expediteur ?? "");

      const entete = r.entete?.trim() || "authorization";
      const valeur = entete.toLowerCase() === "authorization"
        ? `Bearer ${id.jeton}` : id.jeton;

      await poster(r.url, {
        method: "POST",
        headers: { "content-type": "application/json", [entete]: valeur },
        body: remplacer(r.gabarit),
      });
    },
  };
}

/** Passerelle d'essai : rien ne part, tout est retenu. */
export function essai(genre: "sms" | "courriel"): Fournisseur & { envoyes: Envoi[] } {
  const etat = {
    nom: "essai" as const, genre,
    envoyes: [] as Envoi[],
    async envoyer(e: Envoi) { etat.envoyes.push(e); },
  };
  return etat;
}

// ═══════════════════════════════════════════════════════════════════════════
// Catalogue : ce qu'on propose, et ce qu'on en dit
// ═══════════════════════════════════════════════════════════════════════════

export const CATALOGUE: Definition[] = [
  {
    cle: "ovh", libelle: "OVHcloud SMS", genre: "sms",
    identifiants: [
      { cle: "applicationKey", libelle: "Application Key",
        aide: "Créée sur eu.api.ovh.com/createToken" },
      { cle: "applicationSecret", libelle: "Application Secret", aide: "Donnée une seule fois" },
      { cle: "consumerKey", libelle: "Consumer Key",
        aide: "Le jeton doit porter le droit POST sur /sms/*" },
    ],
    reglages: [
      { cle: "service", libelle: "Compte SMS", aide: "Par exemple sms-ab12345-1", requis: true },
      { cle: "expediteur", libelle: "Expéditeur",
        aide: "Nom d'expéditeur déclaré chez OVH. À vide : un numéro court permettant la réponse.",
        requis: false },
      { cle: "region", libelle: "Région", aide: "eu ou ca", requis: false },
    ],
    note: "Le crédit SMS s'épuise. Un compte à sec le jour d'une crise ne prévient pas : "
      + "surveillez-le comme une pièce du dossier de préparation.",
  },
  {
    cle: "brevo", libelle: "Brevo", genre: "courriel",
    identifiants: [
      { cle: "apiKey", libelle: "Clé d'API v3", aide: "Compte → SMTP & API → Clés d'API" },
    ],
    reglages: [
      { cle: "expediteur", libelle: "Adresse d'expédition",
        aide: "Le domaine doit être authentifié chez Brevo, sinon tout part en indésirable.",
        requis: true },
      { cle: "nomExpediteur", libelle: "Nom affiché", aide: "Cellule de crise — Nom de l'entreprise",
        requis: false },
    ],
    note: "Vérifiez SPF, DKIM et DMARC avant d'en avoir besoin. Un courriel de mobilisation "
      + "classé en indésirable est un courriel qui n'existe pas.",
  },
  {
    cle: "generique_sms", libelle: "Autre passerelle SMS (HTTP)", genre: "sms",
    identifiants: [{ cle: "jeton", libelle: "Jeton d'API", aide: "Porté par l'en-tête choisi" }],
    reglages: [
      { cle: "url", libelle: "Adresse de l'API", aide: "Le point d'envoi, en HTTPS", requis: true },
      { cle: "entete", libelle: "En-tête d'authentification",
        aide: "authorization (Bearer), ou le nom exact attendu par le fournisseur", requis: false },
      { cle: "gabarit", libelle: "Corps de la requête",
        aide: 'JSON avec {{destinataire}} et {{texte}}. Exemple : {"to":"{{destinataire}}","body":"{{texte}}"}',
        requis: true },
      { cle: "expediteur", libelle: "Expéditeur", aide: "Substitué par {{expediteur}}", requis: false },
    ],
    note: "Pour Sarbacane, Twilio, Vonage, un agrégateur régional ou une passerelle interne. "
      + "Décrire la requête évite d'attendre une version du produit pendant une crise.",
  },
  {
    cle: "generique_courriel", libelle: "Autre routeur de courriel (HTTP)", genre: "courriel",
    identifiants: [{ cle: "jeton", libelle: "Jeton d'API", aide: "Porté par l'en-tête choisi" }],
    reglages: [
      { cle: "url", libelle: "Adresse de l'API", aide: "Le point d'envoi, en HTTPS", requis: true },
      { cle: "entete", libelle: "En-tête d'authentification",
        aide: "authorization (Bearer), ou le nom exact attendu par le fournisseur", requis: false },
      { cle: "gabarit", libelle: "Corps de la requête",
        aide: 'JSON avec {{destinataire}}, {{objet}}, {{texte}}, {{expediteur}}',
        requis: true },
      { cle: "expediteur", libelle: "Adresse d'expédition", aide: "", requis: false },
    ],
    note: "Pour Sarbacane, Mailjet, Postmark, ou le relais de votre hébergeur.",
  },
];

export function construire(
  fournisseur: string, identifiants: Record<string, string>,
  reglages: Record<string, string>,
): Fournisseur {
  switch (fournisseur) {
    case "ovh":
      return ovhSms(identifiants as never, reglages as never);
    case "brevo":
      return brevo(identifiants as never, reglages as never);
    case "generique_sms":
      return generique(identifiants as never, reglages as never, "sms");
    case "generique_courriel":
      return generique(identifiants as never, reglages as never, "courriel");
    default:
      throw new Error(`Fournisseur inconnu : ${fournisseur}`);
  }
}
+212 −0
Original line number Diff line number Diff line
import type { Sql } from "../db/client.js";
import { journaliser } from "../noyau/administration.js";
import * as annuaire from "../preparation/annuaire.js";
import * as tiers from "../preparation/tiers.js";
import { fournisseurActif } from "./organisation.js";

/**
 * Envoi de masse (EF-1105).
 *
 * Écrire à tout un annuaire est un acte, pas une commodité. Trois garde-fous,
 * et aucun n'est décoratif :
 *
 *   — On voit qui recevra avant d'envoyer, nommément, avec le décompte de ceux
 *     qu'on ne peut pas joindre et pourquoi.
 *   — On s'écrit à soi-même d'abord. Un message parti à quatre-vingts personnes
 *     avec une faute de destinataire ne se rattrape pas.
 *   — Un plafond, et un débit. Une passerelle qui reçoit deux cents requêtes
 *     en une seconde coupe le robinet, et c'est le moment où l'on en a besoin.
 *
 * Ce que la trace retient : qui a lancé, vers combien de personnes, ce qui est
 * parti et ce qui a échoué. Jamais les identifiants de la passerelle.
 */

/** Au-delà, c'est probablement une erreur de sélection plutôt qu'une intention. */
export const PLAFOND = 2000;

/** Envois simultanés. Assez pour ne pas traîner, assez peu pour ne pas être coupé. */
const FRONT = 5;

export interface Selection {
  /** Toutes les fiches de l'annuaire portant un rôle de crise, ou toutes. */
  annuaire?: "tous" | "roles_de_crise" | "aucun" | undefined;
  /** Les tiers activables, par catégorie ou tous. */
  tiers?: "tous" | "aucun" | string[] | undefined;
  /** Des destinataires ajoutés à la main, hors dossier. */
  ajoutes?: string[] | undefined;
  /** Des fiches explicitement écartées de cet envoi. */
  exclus?: string[] | undefined;
}

export interface Destinataire {
  origine: "annuaire" | "tiers" | "manuel";
  id: string | null;
  nom: string;
  adresse: string | null;
  /** Renseigné quand on ne peut pas joindre cette personne par ce canal. */
  injoignable: string | null;
}

/**
 * Qui recevra, qui ne recevra pas, et pourquoi.
 *
 * L'aperçu est la pièce maîtresse : c'est lui qui transforme « envoyer à tout
 * le monde » en décision informée. Il liste les injoignables sans les cacher —
 * ce sont eux qu'il faudra appeler.
 */
export async function apercu(
  sql: Sql, organisationId: string, genre: "sms" | "courriel", s: Selection,
): Promise<{ destinataires: Destinataire[]; joignables: number; injoignables: number }> {
  const exclus = new Set(s.exclus ?? []);
  const destinataires: Destinataire[] = [];

  if (s.annuaire && s.annuaire !== "aucun") {
    for (const p of await annuaire.lister(sql, organisationId)) {
      if (exclus.has(p.id)) continue;
      if (s.annuaire === "roles_de_crise" && !p.role_crise) continue;
      // Le canal personnel d'abord : c'est celui qui survit à la panne du SI,
      // et c'est tout le propos du dossier de préparation.
      const adresse = genre === "sms"
        ? p.tel_perso ?? p.tel_astreinte ?? p.tel_pro ?? null
        : p.email_perso ?? p.email_pro ?? null;
      destinataires.push({
        origine: "annuaire", id: p.id, nom: p.nom, adresse,
        injoignable: adresse ? null
          : genre === "sms" ? "Aucun numéro renseigné." : "Aucune adresse renseignée.",
      });
    }
  }

  if (s.tiers && s.tiers !== "aucun") {
    const categories = Array.isArray(s.tiers) ? new Set(s.tiers) : null;
    for (const t of await tiers.lister(sql, organisationId)) {
      if (exclus.has(t.id)) continue;
      if (categories && !categories.has(t.categorie)) continue;
      const adresse = genre === "sms"
        ? t.numero_urgence ?? t.interlocuteur_tel ?? null
        : t.interlocuteur_email ?? null;
      destinataires.push({
        origine: "tiers", id: t.id, nom: t.nom, adresse,
        injoignable: adresse ? null
          : genre === "sms" ? "Aucun numéro d'urgence." : "Aucune adresse d'interlocuteur.",
      });
    }
  }

  for (const a of s.ajoutes ?? []) {
    const v = a.trim();
    if (v) destinataires.push({ origine: "manuel", id: null, nom: v, adresse: v, injoignable: null });
  }

  return {
    destinataires,
    joignables: destinataires.filter((d) => d.injoignable === null).length,
    injoignables: destinataires.filter((d) => d.injoignable !== null).length,
  };
}

export interface Lancement {
  genre: "sms" | "courriel";
  objet?: string | undefined;
  message: string;
  selection: Selection;
  criseId?: string | null | undefined;
  /** Un essai n'écrit qu'à celui qui le lance. */
  essai?: { destinataire: string } | undefined;
}

export interface Resultat {
  envoiId: string;
  destinataires: number;
  remis: number;
  echecs: { nom: string; adresse: string | null; motif: string }[];
}

export async function lancer(
  sql: Sql, secretInstance: string, organisationId: string,
  l: Lancement, parCompteId: string,
): Promise<Resultat> {
  const f = await fournisseurActif(sql, secretInstance, organisationId, l.genre);
  if (!f) {
    throw new Error(
      l.genre === "sms"
        ? "Aucune passerelle SMS active. Branchez-en une dans les paramètres de l'organisation."
        : "Aucun routeur de courriel actif. Branchez-en un dans les paramètres de l'organisation.");
  }

  const cibles: Destinataire[] = l.essai
    ? [{ origine: "manuel", id: null, nom: "Essai", adresse: l.essai.destinataire,
        injoignable: null }]
    : (await apercu(sql, organisationId, l.genre, l.selection)).destinataires
      .filter((d) => d.injoignable === null);

  if (cibles.length > PLAFOND) {
    throw new Error(
      `${cibles.length} destinataires dépassent le plafond de ${PLAFOND}. `
      + "Au-delà, c'est probablement une erreur de sélection : restreignez, ou "
      + "découpez en plusieurs envois.");
  }
  if (cibles.length === 0) throw new Error("Aucun destinataire joignable dans cette sélection.");

  const [envoi] = await sql<{ id: string }[]>`
    insert into envoi_masse (organisation_id, crise_id, genre, objet, message, selection,
                             destinataires, essai, lance_par)
    values (${organisationId}, ${l.criseId ?? null}, ${l.genre}, ${l.objet ?? null},
            ${l.message}, ${sql.json(l.selection as never)}, ${cibles.length},
            ${l.essai !== undefined}, ${parCompteId})
    returning id`;

  const echecs: Resultat["echecs"] = [];
  let remis = 0;

  // Par vagues : une passerelle qui reçoit deux cents requêtes en une seconde
  // coupe le robinet, et c'est précisément le moment où l'on en a besoin.
  for (let i = 0; i < cibles.length; i += FRONT) {
    await Promise.all(cibles.slice(i, i + FRONT).map(async (d) => {
      try {
        await f.envoyer({ destinataire: d.adresse!, objet: l.objet, texte: l.message });
        remis += 1;
      } catch (e) {
        echecs.push({ nom: d.nom, adresse: d.adresse,
          motif: e instanceof Error ? e.message : "envoi refusé" });
      }
    }));
  }

  await sql.begin(async (tx) => {
    await tx`
      update envoi_masse
         set remis = ${remis}, echecs = ${echecs.length},
             detail_echecs = ${tx.json(echecs as never)}, termine_at = now()
       where id = ${envoi!.id}`;
    // Ce qui reste au journal d'administration : l'acte et son ampleur. Ni le
    // texte du message, ni la liste nominative — ils vivent dans la table,
    // qui se purge, alors que le journal ne se purge pas.
    await journaliser(tx, {
      type: "organisation.envoi_masse", acteurId: parCompteId, organisationId,
      charge: {
        envoi_id: envoi!.id, genre: l.genre, destinataires: cibles.length,
        remis, echecs: echecs.length, essai: l.essai !== undefined,
        crise_id: l.criseId ?? null,
      },
    });
  });

  return { envoiId: envoi!.id, destinataires: cibles.length, remis, echecs };
}

export interface Historique {
  id: string; genre: string; objet: string | null; message: string;
  destinataires: number; remis: number; echecs: number; essai: boolean;
  lance_at: Date; lance_par_nom: string | null; detail_echecs: unknown;
}

export async function historique(sql: Sql, organisationId: string): Promise<Historique[]> {
  return sql<Historique[]>`
    select e.id, e.genre, e.objet, e.message, e.destinataires, e.remis, e.echecs,
           e.essai, e.lance_at, e.detail_echecs, c.nom as lance_par_nom
      from envoi_masse e
      left join compte c on c.id = e.lance_par
     where e.organisation_id = ${organisationId}
     order by e.lance_at desc limit 50`;
}
+190 −0

File added.

Preview size limit exceeded, changes collapsed.

+73 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading