Commit 32365e66 authored by Kourser's avatar Kourser
Browse files

Mobilisation : ce qui compte, c'est qui ne répond pas

Lot L8. Campagnes multicanal, accusés de réception, relances et escalade.

L'information utile de la première heure
  Ce n'est pas « le message est parti » mais « qui a confirmé, et qui manque ».
  C'est elle qui dit s'il faut appeler le suppléant ou envoyer quelqu'un sonner
  à la porte. Le tableau distingue les deux, et mesure le délai de chaque
  réponse.

Trois canaux en parallèle, jamais l'un après l'autre
  On ne sait pas lequel fonctionne encore, et attendre pour l'apprendre coûte
  des minutes qu'on n'a pas. Le SMS ne transite par aucune infrastructure du
  client : c'est le canal qui survit au jour J.

  Une absence de coordonnées produit un envoi sans canal, pas un silence : la
  personne apparaît dans le tableau comme injoignable, plutôt que de manquer.

Confirmer se fait sans compte
  À 3 h du matin, personne ne se connectera pour dire qu'il est là. Un lien,
  un clic. Un second clic ne compte pas double.

Relancer et escalader sans intervention humaine
  Une cellule de crise n'a personne dont le travail soit de surveiller un
  tableau de réponses. Si la relance dépend d'une attention, elle n'a pas lieu.
  L'entretien relance, puis sollicite le suppléant désigné à l'annuaire — qui
  apprend au passage pour qui il supplée. Chaque geste est journalisé avec son
  délai, donc mesurable et rejouable.

Deux passerelles, avec bascule
  Une panne de passerelle le soir d'une crise majeure n'est pas théorique, et
  c'est le moment où l'on ne peut pas attendre le rétablissement. Les deux
  tombées, l'échec remonte plutôt que de se taire.

Vérification
  `pnpm verif` passe de 361 à 387 contrôles. Le jeu d'essai faisait d'abord
  confirmer la seule personne dotée d'un suppléant : il ne restait donc
  personne à escalader. Corrigé côté test.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 3340c538
Loading
Loading
Loading
Loading
+2 −1
Original line number Diff line number Diff line
@@ -20,7 +20,8 @@ l'enregistre intégralement — de manière à pouvoir la rembobiner.
| **L5** | Gestion documentaire, stockage objet | 🟢 fait — indexation des PDF à venir avec l'aperçu |
| **L6** | Visioconférence, enregistrement, rétention | 🟢 fait côté serveur — les fonctions d'appel (partage d'écran, panneau flottant, repli audio) vivent dans le client |
| **L7** | Tableau blanc, modèles, instantanés | 🟢 fait côté serveur |
| L8+ | Mobilisation, restitution, durcissement | ⚪ |
| **L8** | Mobilisation multicanal, relances, escalade | 🟢 fait |
| L9+ | Restitution, durcissement | ⚪ |

Ce qui fonctionne aujourd'hui : le journal inviolable et son chaînage
d'empreintes, le vérificateur indépendant, la reconstitution d'un état passé
+58 −0
Original line number Diff line number Diff line
/**
 * Passerelle SMS (EF-1105).
 *
 * Le SMS est le canal qui survit à l'indisponibilité du système d'information
 * du client — c'est-à-dire le canal du jour J. Il ne transite par aucune de ses
 * infrastructures.
 *
 * Deux fournisseurs sont prévus, avec bascule : une panne de passerelle le soir
 * d'une crise majeure n'est pas un scénario théorique, et c'est exactement le
 * moment où l'on ne peut pas attendre le rétablissement.
 */

export interface Sms {
  destinataire: string;
  texte: string;
}

export interface Passerelle {
  readonly nom: string;
  envoyer(sms: Sms): Promise<void>;
}

export const passerelleConsole: Passerelle = {
  nom: "console",
  async envoyer(sms) {
    console.log(`\n┌─ SMS (non envoyé, passerelle « console »)\n`
      + `│ à : ${sms.destinataire}\n`
      + `${sms.texte.split("\n").map((l) => `│ ${l}`).join("\n")}\n└─\n`);
  },
};

/** Bascule : le second fournisseur prend le relais dès que le premier échoue. */
export function avecSecours(principal: Passerelle, secours: Passerelle): Passerelle {
  return {
    nom: `${principal.nom}+${secours.nom}`,
    async envoyer(sms) {
      try {
        await principal.envoyer(sms);
      } catch (erreur) {
        await secours.envoyer(sms).catch(() => {
          // Les deux passerelles sont tombées : on remonte la première cause,
          // la seconde n'apprendrait rien de plus.
          throw erreur;
        });
      }
    },
  };
}

let courante: Passerelle = passerelleConsole;

export function definirPasserelle(p: Passerelle): void {
  courante = p;
}

export function passerelle(): Passerelle {
  return courante;
}
+60 −0
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- 0012 — Mobilisation (§6.11)
--
-- La première heure d'une crise se joue ici. L'information la plus utile n'est
-- pas « le message est parti » mais « qui a confirmé, et qui ne répond pas » :
-- c'est elle qui dit s'il faut appeler le suppléant ou aller sonner à la porte.
-- ═══════════════════════════════════════════════════════════════════════════

create table mobilisation (
  crise_id       uuid        not null references crise(id) on delete restrict,
  id             uuid        not null,
  lancee_par     uuid        references compte(id) on delete set null,
  lancee_at      timestamptz not null,
  seq_lancement  bigint      not null,
  message        text        not null,
  -- Au-delà, on relance ; puis on escalade vers le suppléant (EF-1103).
  relance_minutes    integer not null default 10 check (relance_minutes between 1 and 240),
  escalade_minutes   integer not null default 25 check (escalade_minutes between 2 and 480),
  close_at       timestamptz,
  primary key (crise_id, id)
);
create index mobilisation_ouvertes on mobilisation (crise_id) where close_at is null;

create table mobilisation_envoi (
  crise_id        uuid        not null,
  id              uuid        not null,
  mobilisation_id uuid        not null,
  -- Une personne de l'annuaire n'a pas nécessairement de compte, et un tiers
  -- activable n'en a jamais.
  personne_id     uuid,
  tiers_id        uuid,
  compte_id       uuid        references compte(id) on delete set null,
  nom             text        not null,
  email           text,
  telephone       text,
  canaux          text[]      not null default '{}',
  envoye_at       timestamptz not null,
  accuse_at       timestamptz,
  accuse_canal    text,
  relances        integer     not null default 0,
  derniere_relance_at timestamptz,
  escalade_vers   uuid,
  escalade_at     timestamptz,
  empreinte_jeton bytea       unique,
  primary key (crise_id, id)
);
create index envoi_mobilisation on mobilisation_envoi (crise_id, mobilisation_id);
create index envoi_sans_reponse on mobilisation_envoi (envoye_at)
  where accuse_at is null;

/** Ce que le cockpit montre de la première heure : qui répond, qui manque. */
create view mobilisation_etat as
  select m.crise_id, m.id, m.lancee_at, m.close_at, m.message,
         count(e.*)                                             as envoyes,
         count(e.*) filter (where e.accuse_at is not null)       as accuses,
         count(e.*) filter (where e.accuse_at is null)           as sans_reponse,
         count(e.*) filter (where e.escalade_at is not null)     as escalades
    from mobilisation m
    left join mobilisation_envoi e on e.crise_id = m.crise_id and e.mobilisation_id = m.id
   group by m.crise_id, m.id, m.lancee_at, m.close_at, m.message;
+376 −0
Original line number Diff line number Diff line
import { randomUUID } from "node:crypto";
import type { Sql } from "../db/client.js";
import { enregistrer, type Contexte } from "../noyau/enregistrer.js";
import { envoyer as envoyerCourriel } from "../adaptateurs/notify.js";
import { passerelle } from "../adaptateurs/sms.js";
import { publier } from "../adaptateurs/diffusion.js";
import { empreinte as empreinteJeton, engendrer } from "../auth/jetons.js";

/**
 * Mobilisation (§6.11).
 *
 * L'information la plus utile de la première heure n'est pas « le message est
 * parti » mais « qui a confirmé, et qui ne répond pas ». C'est elle qui dit
 * s'il faut appeler le suppléant, ou envoyer quelqu'un sonner à la porte.
 *
 * Trois canaux en parallèle, jamais l'un après l'autre : on ne sait pas lequel
 * fonctionne encore, et attendre pour l'apprendre coûte des minutes qu'on n'a
 * pas.
 */

export type Canal = "notification" | "email" | "sms";

export interface Cible {
  personneId?: string | null | undefined;
  tiersId?: string | null | undefined;
  compteId?: string | null | undefined;
  nom: string;
  email?: string | null | undefined;
  telephone?: string | null | undefined;
  /** Identité de l'annuaire vers qui escalader si personne ne répond. */
  suppleantId?: string | null | undefined;
}

export interface Lancement {
  criseId: string;
  message: string;
  cibles: Cible[];
  relanceMinutes?: number;
  escaladeMinutes?: number;
}

export interface Resultat {
  mobilisationId: string;
  envoyes: number;
  echecs: { nom: string; canal: string; motif: string }[];
}

async function expedier(
  criseId: string, mobilisationId: string, envoiId: string,
  cible: Cible, message: string, urlPublique: string,
): Promise<{ canaux: Canal[]; echecs: { canal: string; motif: string }[]; jeton: string }> {
  const jeton = engendrer();
  const lien = `${urlPublique}/mobilisation?jeton=${jeton}`;
  const canaux: Canal[] = [];
  const echecs: { canal: string; motif: string }[] = [];

  if (cible.email) {
    try {
      await envoyerCourriel({
        destinataire: cible.email,
        sujet: "Mobilisation de crise — réponse attendue",
        texte:
          `${cible.nom},\n\n${message}\n\n`
          + `Confirmez que vous êtes joignable :\n${lien}\n\n`
          + `Un seul clic suffit. Sans réponse de votre part, votre suppléant sera\n`
          + `sollicité — ce n'est pas un reproche, c'est la procédure.`,
      });
      canaux.push("email");
    } catch (e) {
      echecs.push({ canal: "email", motif: e instanceof Error ? e.message : "envoi refusé" });
    }
  }

  if (cible.telephone) {
    try {
      // Le SMS ne transite par aucune infrastructure du client : c'est le canal
      // qui survit au jour J.
      await passerelle().envoyer({
        destinataire: cible.telephone,
        texte: `Mobilisation de crise. ${message}\nConfirmez : ${lien}`,
      });
      canaux.push("sms");
    } catch (e) {
      echecs.push({ canal: "sms", motif: e instanceof Error ? e.message : "envoi refusé" });
    }
  }

  if (cible.compteId) {
    await publier({
      criseId, seq: null, genre: "mobilisation",
      charge: { mobilisation_id: mobilisationId, envoi_id: envoiId, compte_id: cible.compteId,
        message },
    });
    canaux.push("notification");
  }

  return { canaux, echecs, jeton };
}

export async function lancer(
  sql: Sql, l: Lancement, urlPublique: string, ctx: Contexte,
): Promise<Resultat> {
  if (l.cibles.length === 0) throw new Error("Aucun destinataire : la mobilisation serait vide.");
  const mobilisationId = randomUUID();
  const canauxGlobaux = new Set<Canal>();
  const echecs: Resultat["echecs"] = [];

  await sql.begin((tx) => enregistrer(tx, {
    criseId: l.criseId, type: "mobilisation.lancee",
    charge: {
      mobilisation_id: mobilisationId, message: l.message,
      destinataires: l.cibles.length, canaux: [],
    },
    ctx,
  }));
  await sql`
    update mobilisation set relance_minutes = ${l.relanceMinutes ?? 10},
                            escalade_minutes = ${l.escaladeMinutes ?? 25}
     where crise_id = ${l.criseId} and id = ${mobilisationId}`;

  for (const cible of l.cibles) {
    const envoiId = randomUUID();
    const expedition = await expedier(l.criseId, mobilisationId, envoiId, cible,
      l.message, urlPublique);
    for (const c of expedition.canaux) canauxGlobaux.add(c);
    for (const e of expedition.echecs) echecs.push({ nom: cible.nom, ...e });

    await sql.begin(async (tx) => {
      await enregistrer(tx, {
        criseId: l.criseId, type: "mobilisation.envoyee",
        charge: {
          mobilisation_id: mobilisationId, envoi_id: envoiId, nom: cible.nom,
          canaux: expedition.canaux, echecs: expedition.echecs,
        },
        ctx: { ...ctx, origine: "automatique" },
      });
      await tx`
        update mobilisation_envoi
           set personne_id = ${cible.personneId ?? null}, tiers_id = ${cible.tiersId ?? null},
               compte_id = ${cible.compteId ?? null}, email = ${cible.email ?? null},
               telephone = ${cible.telephone ?? null},
               escalade_vers = ${cible.suppleantId ?? null},
               empreinte_jeton = ${empreinteJeton(expedition.jeton)}
         where crise_id = ${l.criseId} and id = ${envoiId}`;
    });
  }

  return { mobilisationId, envoyes: l.cibles.length, echecs };
}

/** Confirmer, c'est un clic depuis un lien : le destinataire n'a pas de compte. */
export async function accuser(
  sql: Sql, jeton: string, canal = "lien",
): Promise<{ nom: string; delaiSecondes: number } | null> {
  const [e] = await sql<{ crise_id: string; id: string; mobilisation_id: string;
    nom: string; envoye_at: Date; accuse_at: Date | null }[]>`
    select crise_id, id, mobilisation_id, nom, envoye_at, accuse_at
      from mobilisation_envoi where empreinte_jeton = ${empreinteJeton(jeton)}`;
  if (!e) return null;
  if (e.accuse_at) {
    return {
      nom: e.nom,
      delaiSecondes: Math.round((e.accuse_at.getTime() - e.envoye_at.getTime()) / 1000),
    };
  }

  const delai = Math.max(0, Math.round((Date.now() - e.envoye_at.getTime()) / 1000));
  await sql.begin((tx) => enregistrer(tx, {
    criseId: e.crise_id, type: "mobilisation.accusee",
    charge: {
      mobilisation_id: e.mobilisation_id, envoi_id: e.id, nom: e.nom,
      canal, delai_secondes: delai,
    },
    ctx: { acteurId: null, origine: "externe" },
  }));
  await publier({
    criseId: e.crise_id, seq: null, genre: "mobilisation.accusee",
    charge: { mobilisation_id: e.mobilisation_id, envoi_id: e.id, nom: e.nom },
  });
  return { nom: e.nom, delaiSecondes: delai };
}

export interface EtatMobilisation {
  id: string;
  lancee_at: Date;
  close_at: Date | null;
  message: string;
  envoyes: string;
  accuses: string;
  sans_reponse: string;
  escalades: string;
}

export async function etat(sql: Sql, criseId: string): Promise<EtatMobilisation[]> {
  return sql<EtatMobilisation[]>`
    select id, lancee_at, close_at, message, envoyes::text, accuses::text,
           sans_reponse::text, escalades::text
      from mobilisation_etat where crise_id = ${criseId}
     order by lancee_at desc`;
}

export interface Ligne {
  id: string;
  nom: string;
  canaux: string[];
  envoye_at: Date;
  accuse_at: Date | null;
  relances: number;
  escalade_at: Date | null;
  delai_secondes: number | null;
}

/** EF-1102 — le tableau qu'on regarde vraiment pendant la première heure. */
export async function detail(
  sql: Sql, criseId: string, mobilisationId: string,
): Promise<Ligne[]> {
  return sql<Ligne[]>`
    select id, nom, canaux, envoye_at, accuse_at, relances, escalade_at,
           case when accuse_at is null then null
                else extract(epoch from (accuse_at - envoye_at))::int end as delai_secondes
      from mobilisation_envoi
     where crise_id = ${criseId} and mobilisation_id = ${mobilisationId}
     order by (accuse_at is not null), nom`;
}

export async function clore(
  sql: Sql, criseId: string, mobilisationId: string, ctx: Contexte,
): Promise<void> {
  const [e] = await sql<{ accuses: string; sans_reponse: string }[]>`
    select accuses::text, sans_reponse::text from mobilisation_etat
     where crise_id = ${criseId} and id = ${mobilisationId}`;
  await sql.begin((tx) => enregistrer(tx, {
    criseId, type: "mobilisation.close",
    charge: {
      mobilisation_id: mobilisationId,
      accuses: Number(e?.accuses ?? 0), sans_reponse: Number(e?.sans_reponse ?? 0),
    },
    ctx,
  }));
}

// ── Relances et escalade (EF-1103) ─────────────────────────────────────────

/**
 * Relancer puis escalader, sans que personne ait à y penser. Une cellule de
 * crise n'a pas quelqu'un dont le travail est de surveiller un tableau de
 * réponses ; si la relance dépend d'une attention humaine, elle n'a pas lieu.
 */
export async function relancerEtEscalader(
  sql: Sql, urlPublique: string,
): Promise<{ relances: number; escalades: number }> {
  let relances = 0;
  let escalades = 0;

  // ── Relances ─────────────────────────────────────────────────────────────
  const aRelancer = await sql<{ crise_id: string; id: string; mobilisation_id: string;
    nom: string; email: string | null; telephone: string | null; compte_id: string | null;
    relances: number; message: string }[]>`
    select e.crise_id, e.id, e.mobilisation_id, e.nom, e.email, e.telephone, e.compte_id,
           e.relances, m.message
      from mobilisation_envoi e
      join mobilisation m on m.crise_id = e.crise_id and m.id = e.mobilisation_id
     where e.accuse_at is null and m.close_at is null and e.escalade_at is null
       and coalesce(e.derniere_relance_at, e.envoye_at)
           < now() - (m.relance_minutes || ' minutes')::interval
       and e.relances < 3
     limit 100`;

  for (const e of aRelancer) {
    const rang = e.relances + 1;
    await expedier(e.crise_id, e.mobilisation_id, e.id, {
      nom: e.nom, email: e.email, telephone: e.telephone, compteId: e.compte_id,
    }, `Rappel ${rang}${e.message}`, urlPublique).catch(() => undefined);

    await sql.begin((tx) => enregistrer(tx, {
      criseId: e.crise_id, type: "mobilisation.relancee",
      charge: {
        mobilisation_id: e.mobilisation_id, envoi_id: e.id, nom: e.nom, rang,
      },
      ctx: { acteurId: null, origine: "automatique" },
    }));
    relances += 1;
  }

  // ── Escalade vers le suppléant ───────────────────────────────────────────
  const aEscalader = await sql<{ crise_id: string; id: string; mobilisation_id: string;
    nom: string; escalade_vers: string; envoye_at: Date; message: string }[]>`
    select e.crise_id, e.id, e.mobilisation_id, e.nom, e.escalade_vers, e.envoye_at, m.message
      from mobilisation_envoi e
      join mobilisation m on m.crise_id = e.crise_id and m.id = e.mobilisation_id
     where e.accuse_at is null and m.close_at is null and e.escalade_at is null
       and e.escalade_vers is not null
       and e.envoye_at < now() - (m.escalade_minutes || ' minutes')::interval
     limit 100`;

  for (const e of aEscalader) {
    const [suppleant] = await sql<{ nom: string; email_perso: string | null;
      email_pro: string | null; tel_perso: string | null; compte_id: string | null }[]>`
      select nom, email_perso, email_pro, tel_perso, compte_id
        from personne_courante where id = ${e.escalade_vers}`;
    if (!suppleant) continue;

    const envoiId = randomUUID();
    const expedition = await expedier(e.crise_id, e.mobilisation_id, envoiId, {
      nom: suppleant.nom,
      email: suppleant.email_perso ?? suppleant.email_pro,
      telephone: suppleant.tel_perso,
      compteId: suppleant.compte_id,
    }, `Vous êtes sollicité en suppléance de ${e.nom}. ${e.message}`, urlPublique)
      .catch(() => null);

    const minutes = Math.round((Date.now() - e.envoye_at.getTime()) / 60000);
    await sql.begin(async (tx) => {
      await enregistrer(tx, {
        criseId: e.crise_id, type: "mobilisation.escaladee",
        charge: {
          mobilisation_id: e.mobilisation_id, envoi_id: e.id, nom: e.nom,
          vers: suppleant.nom, apres_minutes: minutes,
        },
        ctx: { acteurId: null, origine: "automatique" },
      });
      if (expedition) {
        await enregistrer(tx, {
          criseId: e.crise_id, type: "mobilisation.envoyee",
          charge: {
            mobilisation_id: e.mobilisation_id, envoi_id: envoiId, nom: suppleant.nom,
            canaux: expedition.canaux, echecs: expedition.echecs,
          },
          ctx: { acteurId: null, origine: "automatique" },
        });
        await tx`
          update mobilisation_envoi
             set personne_id = ${e.escalade_vers}, compte_id = ${suppleant.compte_id},
                 email = ${suppleant.email_perso ?? suppleant.email_pro},
                 telephone = ${suppleant.tel_perso},
                 empreinte_jeton = ${empreinteJeton(expedition.jeton)}
           where crise_id = ${e.crise_id} and id = ${envoiId}`;
      }
    });
    escalades += 1;
  }

  return { relances, escalades };
}

/** Cibles tirées du dossier de préparation, dans la version gelée par la crise. */
export async function ciblesDepuisAnnuaire(
  sql: Sql, criseId: string, roles?: string[],
): Promise<Cible[]> {
  const [c] = await sql<{ organisation_id: string; preparation_version: number | null }[]>`
    select organisation_id, preparation_version from crise where id = ${criseId}`;
  if (!c) return [];

  const personnes = await sql<{ id: string; nom: string; role_crise: string | null;
    email_perso: string | null; email_pro: string | null; tel_perso: string | null;
    compte_id: string | null; suppleant_id: string | null }[]>`
    select id, nom, role_crise, email_perso, email_pro, tel_perso, compte_id, suppleant_id
      from personne
     where organisation_id = ${c.organisation_id} and not supprimee
       ${c.preparation_version === null
         ? sql`and jusqu_a_version is null`
         : sql`and depuis_version <= ${c.preparation_version}
               and (jusqu_a_version is null or jusqu_a_version > ${c.preparation_version})`}
     order by nom`;

  return personnes
    .filter((p) => !roles || roles.length === 0
      || (p.role_crise !== null && roles.includes(p.role_crise)))
    .map((p) => ({
      personneId: p.id, compteId: p.compte_id, nom: p.nom,
      // L'adresse personnelle d'abord : c'est celle qui survit au jour J.
      email: p.email_perso ?? p.email_pro,
      telephone: p.tel_perso,
      suppleantId: p.suppleant_id,
    }));
}
+16 −5
Original line number Diff line number Diff line
@@ -5,6 +5,7 @@ import { purgerTentatives } from "./auth/limites.js";
import { purgerSessions } from "./auth/sessions.js";
import { purgerEchus } from "./domaine/reunions.js";
import { instantanesPeriodiques } from "./domaine/tableaux.js";
import { relancerEtEscalader } from "./domaine/mobilisation.js";
import { mediaConfigure } from "./adaptateurs/media.js";

/**
@@ -21,7 +22,9 @@ import { mediaConfigure } from "./adaptateurs/media.js";

export const PERIODE_MS = 15 * 60 * 1000;

export async function passer(sql: Sql, log: FastifyBaseLogger): Promise<void> {
export async function passer(
  sql: Sql, log: FastifyBaseLogger, urlPublique = "http://localhost:8080",
): Promise<void> {
  try {
    const [acces, tentatives, sessions] = await Promise.all([
      fermerAccesEchus(sql),
@@ -33,21 +36,29 @@ export async function passer(sql: Sql, log: FastifyBaseLogger): Promise<void> {
    const enregistrements = mediaConfigure() ? await purgerEchus(sql) : 0;
    // EF-1004 : personne ne pense à figer un tableau au bon moment.
    const instantanes = await instantanesPeriodiques(sql);
    // EF-1103 : une cellule n'a personne dont le travail soit de surveiller un
    // tableau de réponses. Si la relance dépend d'une attention humaine, elle
    // n'a pas lieu.
    const { relances, escalades } = await relancerEtEscalader(sql, urlPublique);
    if (escalades > 0) log.warn({ escalades }, "mobilisations escaladées vers les suppléants");
    if (enregistrements > 0) {
      log.warn({ enregistrements }, "enregistrements purgés à l'échéance");
    }
    if (acces > 0) {
      log.warn({ acces }, "accès exceptionnels refermés à l'échéance");
    }
    log.debug({ acces, tentatives, sessions, enregistrements, instantanes }, "entretien");
    log.debug({ acces, tentatives, sessions, enregistrements, instantanes, relances, escalades },
      "entretien");
  } catch (e) {
    log.error({ err: e }, "entretien interrompu");
  }
}

export function demarrerEntretien(sql: Sql, log: FastifyBaseLogger): NodeJS.Timeout {
  void passer(sql, log);
  const minuteur = setInterval(() => void passer(sql, log), PERIODE_MS);
export function demarrerEntretien(
  sql: Sql, log: FastifyBaseLogger, urlPublique?: string,
): NodeJS.Timeout {
  void passer(sql, log, urlPublique);
  const minuteur = setInterval(() => void passer(sql, log, urlPublique), PERIODE_MS);
  minuteur.unref();
  return minuteur;
}
Loading