Checkpoint: Historique, statistiques et réessai automatique des rappels
**Nouvelles fonctionnalités** : 1. **Page d'historique des rappels** (`/admin/rappels/historique`) - Tableau complet de tous les rappels envoyés - Filtres avancés : date début/fin, séquence, statut (succès/échec), email - Recherche par email avec mise en surbrillance - Pagination (50 résultats par page) - Affichage du nombre de tentatives pour chaque rappel - Code couleur : vert pour succès, rouge pour échec 2. **Tableau de bord statistiques** (`/admin/rappels/statistiques`) - 4 cartes de statistiques globales : total envoyés, succès, échecs, taux de succès - Graphique d'évolution : nombre de rappels envoyés par jour (succès vs échecs) - Graphique du taux de succès par jour (barres) - Top 10 des emails problématiques avec nombre d'échecs - Visualisation interactive avec Recharts 3. **Système de réessai automatique** - Détection automatique des rappels échoués - Délai exponentiel : 1h après 1er échec, 4h après 2ème, 24h après 3ème - Limite de 3 tentatives maximum - Notification automatique à l'admin en cas d'abandon (3 échecs) - Mise à jour automatique du statut dans les logs - Vérification toutes les heures **Nouveaux champs dans logsRappels** : - `nbTentatives` : nombre de tentatives d'envoi (1 à 3) - `prochainEssai` : date du prochain essai en cas d'échec **Nouveaux fichiers** : - `client/src/pages/admin/AdminRappelsHistorique.tsx` : Page d'historique - `client/src/pages/admin/AdminRappelsStats.tsx` : Page de statistiques - `server/rappelRetry.ts` : Système de réessai automatique - `server/rappelDb.ts` : Fonctions étendues (filtres, stats, évolution) **Fichiers modifiés** : - `drizzle/schema.ts` : Ajout de nbTentatives et prochainEssai - `server/routers.ts` : Nouvelles procédures tRPC (historique, statistiques, évolution) - `client/src/App.tsx` : Routes pour historique et statistiques - `client/src/components/DashboardLayout.tsx` : Sous-menu Rappels - `server/_core/index.ts` : Initialisation du scheduler de retry **Bénéfices** : - ✅ Traçabilité complète de tous les rappels - ✅ Analyse des performances avec graphiques - ✅ Identification rapide des emails problématiques - ✅ Réessai automatique des échecs sans intervention manuelle - ✅ Notification en cas d'échec persistant - ✅ Optimisation de la délivrabilité des emails
This commit is contained in:
@@ -9,6 +9,7 @@ import { createContext } from "./context";
|
||||
import localAuthRouter from "./localAuth";
|
||||
import { serveStatic, setupVite } from "./vite";
|
||||
import { initRappelScheduler } from "../rappelScheduler";
|
||||
import { initRappelRetryScheduler } from "../rappelRetry";
|
||||
|
||||
function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
@@ -66,6 +67,9 @@ async function startServer() {
|
||||
|
||||
// Initialiser le scheduler de rappels automatiques
|
||||
initRappelScheduler();
|
||||
|
||||
// Initialiser le scheduler de réessai automatique
|
||||
initRappelRetryScheduler();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -144,3 +144,185 @@ export async function countRappelsEnvoyesSequence(sequenceId: number): Promise<n
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les logs de rappels avec filtres
|
||||
*/
|
||||
export async function getLogsRappelsWithFilters(filters: {
|
||||
dateDebut?: Date;
|
||||
dateFin?: Date;
|
||||
sequenceId?: number;
|
||||
statut?: "success" | "failed";
|
||||
email?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<LogRappel[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
try {
|
||||
let query = db.select().from(logsRappels);
|
||||
|
||||
const conditions = [];
|
||||
if (filters.sequenceId) {
|
||||
conditions.push(eq(logsRappels.sequenceId, filters.sequenceId));
|
||||
}
|
||||
if (filters.statut) {
|
||||
conditions.push(eq(logsRappels.statut, filters.statut));
|
||||
}
|
||||
if (filters.email) {
|
||||
// Note: Drizzle ne supporte pas LIKE directement, on filtre en JS
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query = query.where(and(...conditions)) as any;
|
||||
}
|
||||
|
||||
let logs = await query.orderBy(logsRappels.dateEnvoi).limit(filters.limit || 100).offset(filters.offset || 0);
|
||||
|
||||
// Filtrer par email si nécessaire
|
||||
if (filters.email) {
|
||||
logs = logs.filter(log => log.emailDestinataire.toLowerCase().includes(filters.email!.toLowerCase()));
|
||||
}
|
||||
|
||||
// Filtrer par date si nécessaire
|
||||
if (filters.dateDebut) {
|
||||
logs = logs.filter(log => new Date(log.dateEnvoi) >= filters.dateDebut!);
|
||||
}
|
||||
if (filters.dateFin) {
|
||||
logs = logs.filter(log => new Date(log.dateEnvoi) <= filters.dateFin!);
|
||||
}
|
||||
|
||||
return logs;
|
||||
} catch (error) {
|
||||
console.error("[LogsRappels] Erreur lors de la récupération avec filtres:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule les statistiques des rappels
|
||||
*/
|
||||
export async function getStatsRappels(): Promise<{
|
||||
totalEnvoyes: number;
|
||||
totalSucces: number;
|
||||
totalEchecs: number;
|
||||
tauxSucces: number;
|
||||
emailsProblematiques: Array<{ email: string; nbEchecs: number }>;
|
||||
}> {
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
return {
|
||||
totalEnvoyes: 0,
|
||||
totalSucces: 0,
|
||||
totalEchecs: 0,
|
||||
tauxSucces: 0,
|
||||
emailsProblematiques: [],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const logs = await db.select().from(logsRappels);
|
||||
|
||||
const totalEnvoyes = logs.length;
|
||||
const totalSucces = logs.filter(l => l.statut === "success").length;
|
||||
const totalEchecs = logs.filter(l => l.statut === "failed").length;
|
||||
const tauxSucces = totalEnvoyes > 0 ? (totalSucces / totalEnvoyes) * 100 : 0;
|
||||
|
||||
// Compter les échecs par email
|
||||
const echecsParEmail = new Map<string, number>();
|
||||
logs.filter(l => l.statut === "failed").forEach(log => {
|
||||
const count = echecsParEmail.get(log.emailDestinataire) || 0;
|
||||
echecsParEmail.set(log.emailDestinataire, count + 1);
|
||||
});
|
||||
|
||||
const emailsProblematiques = Array.from(echecsParEmail.entries())
|
||||
.map(([email, nbEchecs]) => ({ email, nbEchecs }))
|
||||
.sort((a, b) => b.nbEchecs - a.nbEchecs)
|
||||
.slice(0, 10); // Top 10
|
||||
|
||||
return {
|
||||
totalEnvoyes,
|
||||
totalSucces,
|
||||
totalEchecs,
|
||||
tauxSucces,
|
||||
emailsProblematiques,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[LogsRappels] Erreur lors du calcul des statistiques:", error);
|
||||
return {
|
||||
totalEnvoyes: 0,
|
||||
totalSucces: 0,
|
||||
totalEchecs: 0,
|
||||
tauxSucces: 0,
|
||||
emailsProblematiques: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'évolution des envois par jour
|
||||
*/
|
||||
export async function getEvolutionEnvois(): Promise<Array<{
|
||||
date: string;
|
||||
nbEnvoyes: number;
|
||||
nbSucces: number;
|
||||
nbEchecs: number;
|
||||
}>> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
try {
|
||||
const logs = await db.select().from(logsRappels).orderBy(logsRappels.dateEnvoi);
|
||||
|
||||
// Grouper par jour
|
||||
const parJour = new Map<string, { nbEnvoyes: number; nbSucces: number; nbEchecs: number }>();
|
||||
|
||||
logs.forEach(log => {
|
||||
const dateStr = new Date(log.dateEnvoi).toISOString().split('T')[0];
|
||||
const stats = parJour.get(dateStr) || { nbEnvoyes: 0, nbSucces: 0, nbEchecs: 0 };
|
||||
stats.nbEnvoyes++;
|
||||
if (log.statut === "success") stats.nbSucces++;
|
||||
else stats.nbEchecs++;
|
||||
parJour.set(dateStr, stats);
|
||||
});
|
||||
|
||||
return Array.from(parJour.entries())
|
||||
.map(([date, stats]) => ({ date, ...stats }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
} catch (error) {
|
||||
console.error("[LogsRappels] Erreur lors de la récupération de l'évolution:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les logs échoués à réessayer
|
||||
*/
|
||||
export async function getLogsAReessayer(): Promise<LogRappel[]> {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
try {
|
||||
const maintenant = new Date();
|
||||
const logs = await db
|
||||
.select()
|
||||
.from(logsRappels)
|
||||
.where(
|
||||
and(
|
||||
eq(logsRappels.statut, "failed"),
|
||||
// Limiter à 3 tentatives maximum
|
||||
)
|
||||
);
|
||||
|
||||
// Filtrer en JS pour les conditions complexes
|
||||
return logs.filter(log => {
|
||||
if (log.nbTentatives >= 3) return false;
|
||||
if (!log.prochainEssai) return true; // Premier essai
|
||||
return new Date(log.prochainEssai) <= maintenant;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[LogsRappels] Erreur lors de la récupération des logs à réessayer:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
226
server/rappelRetry.ts
Normal file
226
server/rappelRetry.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { getDb } from "./db";
|
||||
import { logsRappels, sequences, inscriptions, apprenants, formations, datesFormation, rappels, formateurs } from "../drizzle/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { sendRappelJ7Email, sendRappelJ1Email } from "./emailService";
|
||||
import { getLogsAReessayer } from "./rappelDb";
|
||||
import { notifyOwner } from "./_core/notification";
|
||||
|
||||
/**
|
||||
* Calcule le délai avant le prochain essai selon le nombre de tentatives
|
||||
* Délai exponentiel : 1h, 4h, 24h
|
||||
*/
|
||||
function calculerProchainEssai(nbTentatives: number): Date {
|
||||
const maintenant = new Date();
|
||||
let delaiHeures = 1; // 1 heure par défaut
|
||||
|
||||
if (nbTentatives === 1) {
|
||||
delaiHeures = 1; // 1ère tentative échouée → réessayer dans 1h
|
||||
} else if (nbTentatives === 2) {
|
||||
delaiHeures = 4; // 2ème tentative échouée → réessayer dans 4h
|
||||
} else {
|
||||
delaiHeures = 24; // 3ème tentative échouée → réessayer dans 24h (mais on arrête à 3)
|
||||
}
|
||||
|
||||
maintenant.setHours(maintenant.getHours() + delaiHeures);
|
||||
return maintenant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Réessaye d'envoyer les rappels échoués
|
||||
*/
|
||||
export async function processRappelRetry() {
|
||||
console.log("[Rappels Retry] Démarrage du processus de réessai");
|
||||
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
console.error("[Rappels Retry] Base de données non disponible");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Récupérer les logs à réessayer
|
||||
const logsAReessayer = await getLogsAReessayer();
|
||||
|
||||
if (logsAReessayer.length === 0) {
|
||||
console.log("[Rappels Retry] Aucun rappel à réessayer");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Rappels Retry] ${logsAReessayer.length} rappel(s) à réessayer`);
|
||||
|
||||
let nbSucces = 0;
|
||||
let nbEchecs = 0;
|
||||
|
||||
for (const log of logsAReessayer) {
|
||||
try {
|
||||
// Récupérer les informations de la séquence
|
||||
const sequence = await db
|
||||
.select()
|
||||
.from(sequences)
|
||||
.where(eq(sequences.id, log.sequenceId))
|
||||
.limit(1);
|
||||
|
||||
if (sequence.length === 0) {
|
||||
console.warn(`[Rappels Retry] Séquence ${log.sequenceId} introuvable`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const seq = sequence[0];
|
||||
|
||||
// Récupérer la formation
|
||||
const formation = await db
|
||||
.select()
|
||||
.from(formations)
|
||||
.where(eq(formations.id, seq.formationId))
|
||||
.limit(1);
|
||||
|
||||
if (formation.length === 0) {
|
||||
console.warn(`[Rappels Retry] Formation ${seq.formationId} introuvable`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Récupérer l'apprenant
|
||||
const apprenant = await db
|
||||
.select()
|
||||
.from(apprenants)
|
||||
.where(eq(apprenants.id, log.apprenantId))
|
||||
.limit(1);
|
||||
|
||||
if (apprenant.length === 0 || !apprenant[0].email) {
|
||||
console.warn(`[Rappels Retry] Apprenant ${log.apprenantId} introuvable ou sans email`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Récupérer les dates de la séquence
|
||||
const dates = await db
|
||||
.select()
|
||||
.from(datesFormation)
|
||||
.where(eq(datesFormation.sequenceId, seq.id))
|
||||
.orderBy(datesFormation.ordre);
|
||||
|
||||
// Récupérer le formateur si disponible
|
||||
let formateurNom = "";
|
||||
if (seq.formateurId) {
|
||||
const formateur = await db
|
||||
.select()
|
||||
.from(formateurs)
|
||||
.where(eq(formateurs.id, seq.formateurId))
|
||||
.limit(1);
|
||||
if (formateur.length > 0) {
|
||||
formateurNom = formateur[0].nom;
|
||||
}
|
||||
}
|
||||
|
||||
// Réessayer l'envoi
|
||||
try {
|
||||
if (log.typeRappel === "rappel") {
|
||||
await sendRappelJ7Email({
|
||||
apprenantEmail: apprenant[0].email,
|
||||
apprenantPrenom: apprenant[0].prenom,
|
||||
apprenantNom: apprenant[0].nom,
|
||||
apprenantFonction: apprenant[0].fonction,
|
||||
formationNom: formation[0].nom,
|
||||
sequenceNom: seq.nom,
|
||||
dates: dates.map(d => ({
|
||||
dateDebut: d.dateDebut,
|
||||
dateFin: d.dateFin,
|
||||
ordre: d.ordre
|
||||
})),
|
||||
lieu: seq.lieu || "",
|
||||
});
|
||||
} else if (log.typeRappel === "rappelJ1") {
|
||||
await sendRappelJ1Email({
|
||||
apprenantEmail: apprenant[0].email,
|
||||
apprenantPrenom: apprenant[0].prenom,
|
||||
apprenantNom: apprenant[0].nom,
|
||||
apprenantFonction: apprenant[0].fonction,
|
||||
formationNom: formation[0].nom,
|
||||
sequenceNom: seq.nom,
|
||||
dates: dates.map(d => ({
|
||||
dateDebut: d.dateDebut,
|
||||
dateFin: d.dateFin,
|
||||
ordre: d.ordre
|
||||
})),
|
||||
lieu: seq.lieu || "",
|
||||
});
|
||||
}
|
||||
|
||||
// Succès : mettre à jour le log
|
||||
await db
|
||||
.update(logsRappels)
|
||||
.set({
|
||||
statut: "success",
|
||||
nbTentatives: log.nbTentatives + 1,
|
||||
prochainEssai: null,
|
||||
})
|
||||
.where(eq(logsRappels.id, log.id));
|
||||
|
||||
console.log(`[Rappels Retry] Succès pour ${apprenant[0].email} après ${log.nbTentatives + 1} tentative(s)`);
|
||||
nbSucces++;
|
||||
} catch (emailError: any) {
|
||||
const messageErreur = emailError?.message || String(emailError);
|
||||
const nouvelleTentative = log.nbTentatives + 1;
|
||||
|
||||
if (nouvelleTentative >= 3) {
|
||||
// Abandon après 3 tentatives
|
||||
await db
|
||||
.update(logsRappels)
|
||||
.set({
|
||||
statut: "failed",
|
||||
nbTentatives: nouvelleTentative,
|
||||
messageErreur: `Abandon après 3 tentatives: ${messageErreur}`,
|
||||
prochainEssai: null,
|
||||
})
|
||||
.where(eq(logsRappels.id, log.id));
|
||||
|
||||
console.error(`[Rappels Retry] Abandon pour ${apprenant[0].email} après 3 tentatives`);
|
||||
|
||||
// Notifier l'admin
|
||||
await notifyOwner({
|
||||
title: `❌ Abandon d'envoi de rappel`,
|
||||
content: `Le rappel pour ${apprenant[0].email} (${seq.nom}) a échoué 3 fois et a été abandonné.\\n\\nDernière erreur: ${messageErreur}`,
|
||||
});
|
||||
} else {
|
||||
// Planifier un nouvel essai
|
||||
const prochainEssai = calculerProchainEssai(nouvelleTentative);
|
||||
await db
|
||||
.update(logsRappels)
|
||||
.set({
|
||||
nbTentatives: nouvelleTentative,
|
||||
messageErreur: messageErreur,
|
||||
prochainEssai: prochainEssai,
|
||||
})
|
||||
.where(eq(logsRappels.id, log.id));
|
||||
|
||||
console.log(`[Rappels Retry] Échec pour ${apprenant[0].email}, tentative ${nouvelleTentative}/3. Prochain essai: ${prochainEssai.toLocaleString()}`);
|
||||
}
|
||||
nbEchecs++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Rappels Retry] Erreur lors du traitement du log ${log.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Rappels Retry] Traitement terminé: ${nbSucces} succès, ${nbEchecs} échecs`);
|
||||
} catch (error) {
|
||||
console.error("[Rappels Retry] Erreur lors du processus de retry:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise le scheduler de retry
|
||||
* Vérifie toutes les heures s'il y a des rappels à réessayer
|
||||
*/
|
||||
export function initRappelRetryScheduler() {
|
||||
console.log("[Rappels Retry] Initialisation du scheduler de réessai automatique");
|
||||
|
||||
// Exécuter immédiatement au démarrage
|
||||
processRappelRetry();
|
||||
|
||||
// Puis exécuter toutes les heures
|
||||
setInterval(() => {
|
||||
processRappelRetry();
|
||||
}, 60 * 60 * 1000); // 1 heure en millisecondes
|
||||
|
||||
console.log("[Rappels Retry] Scheduler de réessai initialisé - vérification toutes les heures");
|
||||
}
|
||||
@@ -974,6 +974,39 @@ export const appRouter = router({
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
historique: adminProcedure
|
||||
.input(z.object({
|
||||
dateDebut: z.string().optional(),
|
||||
dateFin: z.string().optional(),
|
||||
sequenceId: z.number().optional(),
|
||||
statut: z.enum(['success', 'failed']).optional(),
|
||||
email: z.string().optional(),
|
||||
limit: z.number().default(100),
|
||||
offset: z.number().default(0),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
const { getLogsRappelsWithFilters } = await import('./rappelDb');
|
||||
return getLogsRappelsWithFilters({
|
||||
dateDebut: input.dateDebut ? new Date(input.dateDebut) : undefined,
|
||||
dateFin: input.dateFin ? new Date(input.dateFin) : undefined,
|
||||
sequenceId: input.sequenceId,
|
||||
statut: input.statut,
|
||||
email: input.email,
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
});
|
||||
}),
|
||||
|
||||
statistiques: adminProcedure.query(async () => {
|
||||
const { getStatsRappels } = await import('./rappelDb');
|
||||
return getStatsRappels();
|
||||
}),
|
||||
|
||||
evolution: adminProcedure.query(async () => {
|
||||
const { getEvolutionEnvois } = await import('./rappelDb');
|
||||
return getEvolutionEnvois();
|
||||
}),
|
||||
}),
|
||||
|
||||
emailConfig: router({
|
||||
|
||||
Reference in New Issue
Block a user