Commit a2e30c8e authored by Kourser's avatar Kourser
Browse files

L'annuaire se dépose, il ne se synchronise pas

Tous les outils de ce marché synchronisent l'annuaire d'entreprise. Nous ne le
pouvons pas de la même façon, et pas par retard : le principe fondateur interdit
de dépendre du système d'information qu'on secourt. Aller interroger un Active
Directory au moment où il est chiffré est exactement le contresens que ce
produit refuse.

Le sens inverse
  C'est le client qui dépose, quand il veut, par une clé d'intégration de portée
  « annuaire ». Nous ne joignons jamais sa source. Ce qui est ici survit intact
  à la chute de son SI — et une synchronisation qui échoue le jour J n'existe
  pas, puisqu'il n'y a rien à synchroniser ce jour-là.

  Le manque était réel : personne ne saisit à la main l'annuaire de cinq mille
  personnes. La réponse n'était pas de faire comme les autres, c'était de faire
  l'inverse.

Trois garde-fous, chacun contre un défaut précis
  Le rapprochement se fait sur l'adresse personnelle d'abord, le nom ensuite.
  L'adresse personnelle parce que c'est elle qui identifie durablement quelqu'un
  ici — celle qui survit au changement de poste comme à la chute de la
  messagerie d'entreprise.

  Une seule version de dossier pour tout le dépôt. Trois cents fiches qui
  produiraient trois cents versions rendraient l'historique illisible et
  feraient de chaque dépôt un événement de crise.

  Le retrait des absents est facultatif, et faux par défaut. Un script qui
  échoue à mi-parcours envoie un annuaire tronqué, et un dépôt incomplet ne doit
  jamais pouvoir vider un dossier de préparation.

Ce qui disparaît de la source disparaît du dossier en vigueur, jamais des
versions gelées : une crise ancienne reste lisible avec l'annuaire de son jour.

Non éprouvé : pnpm verif n'a pas tourné, faute de base sur la machine de
travail. Aucune garantie n'accompagne ce lot.

Signed-off-by: default avatarKourser <contact@kourser.bzh>
Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent bf1e72a4
Loading
Loading
Loading
Loading
+80 −0
Changes for apps/api/src/preparation/annuaire.ts: 80 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -63,6 +63,86 @@ export async function modifier(
  });
}

export interface Depot {
  ajoutees: number;
  modifiees: number;
  retirees: number;
  inchangees: number;
}

/**
 * Un annuaire déposé en bloc par le système du client.
 *
 * Le rapprochement se fait sur l'adresse personnelle d'abord, le nom ensuite.
 * L'adresse personnelle plutôt que professionnelle parce que c'est elle qui
 * identifie durablement quelqu'un dans ce produit — celle qui survit au
 * changement de poste comme à la chute de la messagerie d'entreprise.
 *
 * Une seule version du dossier pour tout le dépôt, et non une par fiche : un
 * import de trois cents personnes qui produirait trois cents versions rendrait
 * l'historique illisible et ferait de chaque dépôt un événement de crise.
 *
 * Ce qui n'est pas dans le dépôt n'est retiré que si on le demande. Un script
 * qui échoue à mi-parcours enverrait un annuaire tronqué, et un dépôt
 * incomplet ne doit jamais pouvoir vider un dossier de préparation.
 */
export async function deposer(
  sql: Sql, organisationId: string, personnes: SaisiePersonne[],
  retirerLesAbsents = false,
): Promise<Depot> {
  const r: Depot = { ajoutees: 0, modifiees: 0, retirees: 0, inchangees: 0 };
  const existantes = await lister(sql, organisationId);
  const parCle = new Map<string, Personne>();
  for (const p of existantes) {
    if (p.email_perso) parCle.set(p.email_perso.toLowerCase(), p);
    parCle.set(`nom:${p.nom.trim().toLowerCase()}`, p);
  }

  const vues = new Set<string>();
  const identique = (a: Personne, b: SaisiePersonne): boolean =>
    a.nom === b.nom
    && (a.fonction ?? null) === (b.fonction ?? null)
    && (a.role_crise ?? null) === (b.role_crise ?? null)
    && (a.email_pro ?? null) === (b.email_pro ?? null)
    && (a.tel_perso ?? null) === (b.tel_perso ?? null)
    && JSON.stringify([...(a.etiquettes ?? [])].sort())
       === JSON.stringify([...(b.etiquettes ?? [])].sort());

  await sql.begin(async (tx) => {
    const version = await dossier.nouvelleVersion(tx, organisationId, null,
      `Dépôt d'annuaire : ${personnes.length} fiche(s)`);

    for (const saisie of personnes) {
      const cle = saisie.email_perso?.toLowerCase()
        ?? `nom:${saisie.nom.trim().toLowerCase()}`;
      const courante = parCle.get(cle);
      if (courante) {
        vues.add(courante.id);
        if (identique(courante, saisie)) {
          r.inchangees += 1;
          continue;
        }
        await dossier.ecrire(tx, "personne", organisationId, version, courante.id,
          { ...saisie, verifie_at: new Date() });
        r.modifiees += 1;
      } else {
        await dossier.ecrire(tx, "personne", organisationId, version, null,
          { ...saisie, verifie_at: new Date() });
        r.ajoutees += 1;
      }
    }

    if (retirerLesAbsents) {
      for (const p of existantes) {
        if (vues.has(p.id)) continue;
        await dossier.supprimer(tx, "personne", organisationId, version, p.id);
        r.retirees += 1;
      }
    }
  });
  return r;
}

export async function retirer(
  sql: Sql, organisationId: string, id: string, auteurId: string | null,
): Promise<void> {
+45 −2
Changes for apps/api/src/routes/integration.ts: 45 added lines, 2 removed lines.
Original line number Diff line number Diff line
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { GENRES, GRAVITES } from "@kastell/shared";
import { GENRES, GRAVITES, personneSchema } from "@kastell/shared";
import type { Sql } from "../db/client.js";
import { clore, consigner, ouvrirCrise } from "../domaine/crise.js";
import * as annuaire from "../preparation/annuaire.js";
import { empreinte, engendrer } from "../auth/jetons.js";
import { REDACTEURS, requerirMembre } from "./garde.js";

@@ -18,7 +19,7 @@ import { REDACTEURS, requerirMembre } from "./garde.js";
 * envoyé — sans quoi un exercice relu ressemblerait à une crise réelle.
 */

export const PORTEES = ["exercice", "lecture"] as const;
export const PORTEES = ["exercice", "lecture", "annuaire"] as const;
export type Portee = (typeof PORTEES)[number];

declare module "fastify" {
@@ -186,6 +187,48 @@ export function enregistrerRoutesIntegration(app: FastifyInstance, sql: Sql): vo
    });

  /** Lecture pour l'évaluation : l'outil d'exercice note ce qui s'est passé. */
  /**
   * L'annuaire déposé par le SIRH du client (P1).
   *
   * Tous nos concurrents synchronisent l'annuaire d'entreprise. Nous ne le
   * pouvons pas de la même façon : le principe fondateur interdit de dépendre
   * du système d'information qu'on secourt. Aller interroger un AD au moment
   * où il est chiffré serait exactement le contresens que ce produit refuse.
   *
   * D'où le sens inverse. C'est le client qui dépose, quand il veut, par une
   * clé d'intégration. Nous ne joignons jamais sa source ; nous gardons ce
   * qu'il a déposé, et ce dépôt survit intact à la chute de son SI. Une
   * synchronisation qui échoue le jour J n'existe pas, puisqu'il n'y a rien à
   * synchroniser ce jour-là.
   *
   * Le dépôt est complet, jamais différentiel : on envoie l'annuaire tel qu'il
   * est. Ce qui a disparu de la source est retiré du dossier en vigueur — mais
   * les versions gelées le gardent, et une crise ancienne reste lisible.
   */
  app.post("/integration/annuaire", { preHandler: requerirCle("annuaire") },
    async (req, reply) => {
      const c = z.object({
        personnes: z.array(personneSchema).max(5000),
        /**
         * Retirer ce qui n'est pas dans le dépôt.
         *
         * Faux par défaut, et le défaut est le bon : un script qui échoue à
         * mi-parcours enverrait un annuaire tronqué, et un dépôt incomplet ne
         * doit jamais pouvoir vider un dossier de préparation.
         */
        retirerLesAbsents: z.boolean().default(false),
      }).parse(req.body);

      const organisationId = req.integration!.organisationId;
      const r = await annuaire.deposer(sql, organisationId, c.personnes,
        c.retirerLesAbsents);
      return reply.send({
        ...r,
        note: "Dépôt conservé tel quel. Kastell ne joint jamais votre annuaire : "
          + "ce qui est ici survit à l'indisponibilité de votre système.",
      });
    });

  app.get("/integration/exercices/:criseId/journal", { preHandler: requerirCle("lecture") },
    async (req, reply) => {
      const { criseId } = z.object({ criseId: z.string().uuid() }).parse(req.params);