import { getDb } from "./db"; import { rappels, sequences, inscriptions, apprenants, formations, datesFormation, formateurs, rappelDates } 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.heureEnvoi || "09:00").split(":").map(Number); // Vérifier si on est dans la bonne heure (avec une tolérance de 30 minutes pour couvrir 2 cycles de vérification) const diffMinutes = Math.abs((heureActuelle * 60 + minuteActuelle) - (heureRappel * 60 + minuteRappel)); if (diffMinutes > 30) { console.log(`[Rappels] Heure actuelle ${heureActuelle}:${minuteActuelle.toString().padStart(2, '0')} ne correspond pas à l'heure configurée ${rappel.heureEnvoi} (différence: ${diffMinutes} minutes) - rappel ignoré`); return; } console.log(`[Rappels] Heure actuelle ${heureActuelle}:${minuteActuelle.toString().padStart(2, '0')} correspond à l'heure configurée ${rappel.heureEnvoi} - 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 via la table rappelDates const rappelDatesAssociees = await db .select({ dateFormationId: rappelDates.dateFormationId }) .from(rappelDates) .where(eq(rappelDates.rappelId, rappel.id)); const datesAssociees = rappelDatesAssociees.map(rd => rd.dateFormationId); 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(); 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)"); }