Files
formation-manager-itinova/server/rappelScheduler.ts
Manus 6c4ca249a9 Checkpoint: Correction du système d'envoi automatique des rappels :
1. Ajout de la vérification de l'heure configurée avant l'envoi (tolérance de 20 minutes)
2. Réduction de l'intervalle du scheduler de 1 heure à 15 minutes pour plus de précision
3. Ajout de logs détaillés pour le débogage
4. Les rappels sont maintenant envoyés à l'heure exacte configurée (ex: 13h51)
2026-01-18 08:22:08 -05:00

291 lines
11 KiB
TypeScript

import { getDb } from "./db";
import { rappels, sequences, inscriptions, apprenants, formations, datesFormation, formateurs } from "../drizzle/schema";
import { eq, and, gte, lte, sql, inArray } from "drizzle-orm";
import { sendRappelJ7Email, sendRappelJ1Email } from "./emailService";
import { logRappelEnvoye, rappelDejaEnvoye } from "./rappelDb";
import { notifyOwner } from "./_core/notification";
import { processRemerciementsAutomatiques } from "./remerciementScheduler";
/**
* Job automatique qui vérifie quotidiennement les séquences à venir
* et envoie les rappels J-7 et J-1 aux apprenants inscrits
*/
export async function processRappelsAutomatiques() {
console.log("[Rappels] Démarrage du traitement des rappels automatiques...");
const db = await getDb();
if (!db) {
console.error("[Rappels] Base de données non disponible");
return;
}
try {
// Récupérer tous les rappels actifs
const rappelsActifs = await db
.select()
.from(rappels)
.where(eq(rappels.actif, true));
console.log(`[Rappels] ${rappelsActifs.length} rappel(s) actif(s) trouvé(s)`);
for (const rappel of rappelsActifs) {
await processRappel(rappel);
}
console.log("[Rappels] Traitement terminé");
} catch (error) {
console.error("[Rappels] Erreur lors du traitement des rappels:", error);
}
}
async function processRappel(rappel: any) {
console.log(`[Rappels] Traitement du rappel: ${rappel.nom} (${rappel.timing === 'pre_formation' ? 'J-' : 'J+'}${rappel.joursAvant})`);
const db = await getDb();
if (!db) return;
try {
// Vérifier si l'heure actuelle correspond à l'heure configurée du rappel
const maintenant = new Date();
const heureActuelle = maintenant.getHours();
const minuteActuelle = maintenant.getMinutes();
// Parser l'heure configurée (format "HH:MM")
const [heureRappel, minuteRappel] = (rappel.heure || "09:00").split(":").map(Number);
// Vérifier si on est dans la bonne heure (avec une tolérance de 20 minutes)
const diffMinutes = Math.abs((heureActuelle * 60 + minuteActuelle) - (heureRappel * 60 + minuteRappel));
if (diffMinutes > 20) {
console.log(`[Rappels] Heure actuelle ${heureActuelle}:${minuteActuelle.toString().padStart(2, '0')} ne correspond pas à l'heure configurée ${rappel.heure} (différence: ${diffMinutes} minutes) - rappel ignoré`);
return;
}
console.log(`[Rappels] Heure actuelle ${heureActuelle}:${minuteActuelle.toString().padStart(2, '0')} correspond à l'heure configurée ${rappel.heure} - traitement du rappel`);
// Calculer la date cible selon le timing (pré ou post-formation)
const dateCible = new Date();
if (rappel.timing === 'pre_formation') {
dateCible.setDate(dateCible.getDate() + rappel.joursAvant);
} else {
dateCible.setDate(dateCible.getDate() - rappel.joursAvant);
}
dateCible.setHours(0, 0, 0, 0);
const dateCibleFin = new Date(dateCible);
dateCibleFin.setHours(23, 59, 59, 999);
console.log(`[Rappels] Recherche des dates de formation le ${dateCible.toLocaleDateString()}`);
// Vérifier si ce rappel est associé à des dates spécifiques
const datesAssociees = rappel.dateFormationIds || [];
const filtreDates = datesAssociees.length > 0;
console.log(`[Rappels] ${filtreDates ? `Filtré sur ${datesAssociees.length} date(s) spécifique(s)` : 'Toutes les dates'}`);
// Récupérer les dates de formation concernées
let datesConcernees;
if (filtreDates) {
// Seulement les dates associées à ce rappel
datesConcernees = await db
.select({
dateFormation: datesFormation,
sequence: sequences,
formation: formations,
formateur: formateurs,
})
.from(datesFormation)
.leftJoin(sequences, eq(datesFormation.sequenceId, sequences.id))
.leftJoin(formations, eq(sequences.formationId, formations.id))
.leftJoin(formateurs, eq(sequences.formateurId, formateurs.id))
.where(
and(
inArray(datesFormation.id, datesAssociees),
gte(datesFormation.dateDebut, dateCible),
lte(datesFormation.dateDebut, dateCibleFin)
)
);
} else {
// Toutes les dates correspondant à la date cible
datesConcernees = await db
.select({
dateFormation: datesFormation,
sequence: sequences,
formation: formations,
formateur: formateurs,
})
.from(datesFormation)
.leftJoin(sequences, eq(datesFormation.sequenceId, sequences.id))
.leftJoin(formations, eq(sequences.formationId, formations.id))
.leftJoin(formateurs, eq(sequences.formateurId, formateurs.id))
.where(
and(
gte(datesFormation.dateDebut, dateCible),
lte(datesFormation.dateDebut, dateCibleFin)
)
);
}
console.log(`[Rappels] ${datesConcernees.length} date(s) de formation trouvée(s)`);
// Grouper les dates par séquence pour envoyer un seul email par apprenant
const sequencesMap = new Map<number, { sequence: any; formation: any; formateur: any; dates: any[] }>();
for (const { dateFormation, sequence, formation, formateur } of datesConcernees) {
if (!dateFormation || !sequence || !formation) continue;
if (!sequencesMap.has(sequence.id)) {
sequencesMap.set(sequence.id, {
sequence,
formation,
formateur,
dates: [],
});
}
sequencesMap.get(sequence.id)!.dates.push(dateFormation);
}
console.log(`[Rappels] ${sequencesMap.size} séquence(s) concernée(s)`);
for (const { sequence, formation, formateur, dates: datesRappel } of Array.from(sequencesMap.values())) {
if (!sequence || !formation) continue;
// Récupérer toutes les dates de cette séquence pour l'email
const toutesLesDates = await db
.select()
.from(datesFormation)
.where(eq(datesFormation.sequenceId, sequence.id))
.orderBy(datesFormation.ordre);
const formateurNom = formateur?.nom || "";
// Récupérer tous les apprenants inscrits à cette séquence
const inscrits = await db
.select({
inscription: inscriptions,
apprenant: apprenants,
})
.from(inscriptions)
.leftJoin(apprenants, eq(inscriptions.apprenantId, apprenants.id))
.where(
and(
eq(inscriptions.sequenceId, sequence.id),
eq(inscriptions.statut, "confirmee")
)
);
console.log(`[Rappels] ${inscrits.length} apprenant(s) inscrit(s) à la séquence ${sequence.nom}`);
// Envoyer le rappel à chaque apprenant
let nbEnvoyes = 0;
let nbEchecs = 0;
const echecs: Array<{ email: string; erreur: string }> = [];
for (const { apprenant } of inscrits) {
if (!apprenant || !apprenant.email) continue;
// Vérifier si le rappel a déjà été envoyé
const dejaEnvoye = await rappelDejaEnvoye(rappel.id, sequence.id, apprenant.id);
if (dejaEnvoye) {
console.log(`[Rappels] Rappel déjà envoyé à ${apprenant.email} pour la séquence ${sequence.nom} - ignoré`);
continue;
}
try {
// Utiliser la fonction générique pour tous les types de rappels
const { sendRappelEmail } = await import('./emailService');
await sendRappelEmail({
apprenantEmail: apprenant.email,
apprenantPrenom: apprenant.prenom,
apprenantNom: apprenant.nom,
apprenantFonction: apprenant.fonction,
formationNom: formation.nom,
sequenceNom: sequence.nom,
dates: toutesLesDates.map(d => ({
dateDebut: d.dateDebut,
dateFin: d.dateFin,
ordre: d.ordre
})),
lieu: sequence.lieu || "",
formateur: formateurNom,
templateType: rappel.templateType,
attachmentUrl: rappel.urlFichier || undefined,
attachmentFilename: rappel.nomFichier || undefined,
attachmentMimeType: rappel.typeFichier || undefined,
});
console.log(`[Rappels] Email ${rappel.templateType} envoyé à ${apprenant.email} pour la séquence ${sequence.nom}`);
// Logger le succès
await logRappelEnvoye({
rappelId: rappel.id,
sequenceId: sequence.id,
apprenantId: apprenant.id,
email: apprenant.email,
type: rappel.templateType,
typeEnvoi: 'automatique',
statut: "succes",
});
nbEnvoyes++;
} catch (emailError: any) {
const messageErreur = emailError?.message || String(emailError);
console.error(`[Rappels] Erreur lors de l'envoi à ${apprenant.email}:`, emailError);
// Logger l'échec
await logRappelEnvoye({
rappelId: rappel.id,
sequenceId: sequence.id,
apprenantId: apprenant.id,
email: apprenant.email,
type: rappel.templateType,
typeEnvoi: 'automatique',
statut: "echec",
messageErreur: messageErreur,
});
nbEchecs++;
echecs.push({ email: apprenant.email, erreur: messageErreur });
}
}
// Notifier l'administrateur en cas d'échecs
if (nbEchecs > 0) {
const listeEchecs = echecs.map(e => `- ${e.email}: ${e.erreur}`).join("\n");
await notifyOwner({
title: `⚠️ Échecs d'envoi de rappels`,
content: `${nbEchecs} rappel(s) n'ont pas pu être envoyés pour la séquence "${sequence.nom}" (${formation.nom}):\n\n${listeEchecs}\n\nRappel: ${rappel.nom} (J-${rappel.joursAvant})\nEnvois réussis: ${nbEnvoyes}`,
});
}
console.log(`[Rappels] Séquence ${sequence.nom}: ${nbEnvoyes} envoi(s) réussi(s), ${nbEchecs} échec(s)`);
}
// Mettre à jour la date de dernière exécution
await db
.update(rappels)
.set({ derniereExecution: new Date() })
.where(eq(rappels.id, rappel.id));
} catch (error) {
console.error(`[Rappels] Erreur lors du traitement du rappel ${rappel.nom}:`, error);
}
}
/**
* Initialise le scheduler pour exécuter les rappels automatiques
* Vérifie toutes les heures s'il y a des rappels à envoyer
*/
export function initRappelScheduler() {
console.log("[Rappels] Initialisation du scheduler de rappels automatiques");
// Exécuter immédiatement au démarrage
processRappelsAutomatiques();
processRemerciementsAutomatiques();
// Puis exécuter toutes les 15 minutes
setInterval(() => {
processRappelsAutomatiques();
processRemerciementsAutomatiques();
}, 15 * 60 * 1000); // 15 minutes en millisecondes
console.log("[Rappels] Scheduler initialisé - vérification toutes les 15 minutes (rappels + remerciements)");
}