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:
Manus Sandbox
2025-12-08 03:38:14 -05:00
parent bb0ec9e3b0
commit 355cb43dc7
13 changed files with 2754 additions and 1 deletions

View File

@@ -0,0 +1,9 @@
{
"query": "ALTER TABLE `logsRappels` \nADD COLUMN `nbTentatives` int NOT NULL DEFAULT 1,\nADD COLUMN `prochainEssai` timestamp NULL;",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.root --database 7PAT67UmWoxv8vwp8Bbcv6 --execute ALTER TABLE `logsRappels` \nADD COLUMN `nbTentatives` int NOT NULL DEFAULT 1,\nADD COLUMN `prochainEssai` timestamp NULL;",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 795
}

View File

@@ -25,6 +25,8 @@ import AdminQuestionnaires from "./pages/AdminQuestionnaires";
import AdminQuestionnaireEdit from "./pages/AdminQuestionnaireEdit";
import AdminQuestionnaireAnalyse from "./pages/AdminQuestionnaireAnalyse";
import AdminQuestionnaireSuivi from "./pages/AdminQuestionnaireSuivi";
import AdminRappelsHistorique from "./pages/admin/AdminRappelsHistorique";
import AdminRappelsStats from "./pages/admin/AdminRappelsStats";
import QuestionnaireReponse from "./pages/QuestionnaireReponse";
import Inscription from "./pages/Inscription";
import Login from "./pages/Login";
@@ -51,6 +53,8 @@ function Router() {
<Route path={"/admin/email-config"} component={AdminEmailConfig} />
<Route path={"/admin/calendrier"} component={AdminCalendrier} />
<Route path={"/admin/rappels"} component={AdminRappels} />
<Route path={"/admin/rappels/historique"} component={AdminRappelsHistorique} />
<Route path={"/admin/rappels/statistiques"} component={AdminRappelsStats} />
<Route path={"/admin/analytics"} component={AdminAnalytics} />
<Route path={"/admin/etablissements"} component={AdminEtablissements} />
<Route path={"/admin/import-excel"} component={AdminImportExcel} />

View File

@@ -48,7 +48,10 @@ const menuSections = [
items: [
{ icon: UserCog, label: "Utilisateurs", path: "/admin/users" },
{ icon: FileSpreadsheet, label: "Import Excel", path: "/admin/import-excel" },
{ icon: Bell, label: "Rappels", path: "/admin/rappels" },
{ icon: Bell, label: "Rappels", path: "/admin/rappels", subItems: [
{ label: "Historique", path: "/admin/rappels/historique" },
{ label: "Statistiques", path: "/admin/rappels/statistiques" },
] },
{ icon: ClipboardList, label: "Questionnaires", path: "/admin/questionnaires" },
{ icon: Mail, label: "Templates d'emails", path: "/admin/email-templates" },
{ icon: Settings, label: "Configuration SMTP", path: "/admin/email-config" },

View File

@@ -0,0 +1,262 @@
import { useState } from "react";
import DashboardLayout from "@/components/DashboardLayout";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc";
import { History, Search, Filter, ChevronLeft, ChevronRight } from "lucide-react";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
export default function AdminRappelsHistorique() {
const [filtres, setFiltres] = useState({
dateDebut: "",
dateFin: "",
sequenceId: undefined as number | undefined,
statut: undefined as "success" | "failed" | undefined,
email: "",
limit: 50,
offset: 0,
});
const { data: logs, isLoading } = trpc.rappels.historique.useQuery(filtres);
const { data: sequences } = trpc.sequences.list.useQuery();
const handleFilterChange = (key: string, value: any) => {
setFiltres(prev => ({
...prev,
[key]: value,
offset: 0, // Reset pagination
}));
};
const handlePageChange = (direction: "prev" | "next") => {
setFiltres(prev => ({
...prev,
offset: direction === "next" ? prev.offset + prev.limit : Math.max(0, prev.offset - prev.limit),
}));
};
const resetFilters = () => {
setFiltres({
dateDebut: "",
dateFin: "",
sequenceId: undefined,
statut: undefined,
email: "",
limit: 50,
offset: 0,
});
};
return (
<DashboardLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Historique des Rappels</h1>
<p className="text-muted-foreground mt-1">
Consultez tous les rappels envoyés avec filtres avancés
</p>
</div>
<History className="h-8 w-8 text-muted-foreground" />
</div>
{/* Filtres */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Filter className="h-5 w-5" />
Filtres
</CardTitle>
<CardDescription>Affinez votre recherche dans l'historique</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor="dateDebut">Date début</Label>
<Input
id="dateDebut"
type="date"
value={filtres.dateDebut}
onChange={(e) => handleFilterChange("dateDebut", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="dateFin">Date fin</Label>
<Input
id="dateFin"
type="date"
value={filtres.dateFin}
onChange={(e) => handleFilterChange("dateFin", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="sequence">Séquence</Label>
<Select
value={filtres.sequenceId?.toString() || "all"}
onValueChange={(value) => handleFilterChange("sequenceId", value === "all" ? undefined : parseInt(value))}
>
<SelectTrigger id="sequence">
<SelectValue placeholder="Toutes les séquences" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Toutes les séquences</SelectItem>
{sequences?.map((seq) => (
<SelectItem key={seq.id} value={seq.id.toString()}>
{seq.nom}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="statut">Statut</Label>
<Select
value={filtres.statut || "all"}
onValueChange={(value) => handleFilterChange("statut", value === "all" ? undefined : value)}
>
<SelectTrigger id="statut">
<SelectValue placeholder="Tous les statuts" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les statuts</SelectItem>
<SelectItem value="success">Succès</SelectItem>
<SelectItem value="failed">Échec</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="email">Rechercher par email</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="text"
placeholder="email@exemple.com"
className="pl-10"
value={filtres.email}
onChange={(e) => handleFilterChange("email", e.target.value)}
/>
</div>
</div>
<div className="flex items-end gap-2 md:col-span-2">
<Button variant="outline" onClick={resetFilters} className="w-full">
Réinitialiser
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Tableau des logs */}
<Card>
<CardHeader>
<CardTitle>Logs des rappels</CardTitle>
<CardDescription>
{logs?.length || 0} résultat(s) trouvé(s)
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">
Chargement...
</div>
) : !logs || logs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
Aucun log trouvé avec ces filtres
</div>
) : (
<>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date envoi</TableHead>
<TableHead>Email</TableHead>
<TableHead>Type</TableHead>
<TableHead>Statut</TableHead>
<TableHead>Tentatives</TableHead>
<TableHead>Erreur</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{logs.map((log) => (
<TableRow key={log.id}>
<TableCell className="whitespace-nowrap">
{format(new Date(log.dateEnvoi), "dd/MM/yyyy HH:mm", { locale: fr })}
</TableCell>
<TableCell className="font-medium">{log.emailDestinataire}</TableCell>
<TableCell>
<Badge variant="outline">
{log.typeRappel === "rappel" ? "J-7" : "J-1"}
</Badge>
</TableCell>
<TableCell>
<Badge variant={log.statut === "success" ? "default" : "destructive"}>
{log.statut === "success" ? "Succès" : "Échec"}
</Badge>
</TableCell>
<TableCell>
<span className={log.nbTentatives > 1 ? "text-orange-600 font-medium" : ""}>
{log.nbTentatives}
</span>
</TableCell>
<TableCell className="max-w-xs truncate text-sm text-muted-foreground">
{log.messageErreur || "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">
Affichage de {filtres.offset + 1} à {Math.min(filtres.offset + filtres.limit, filtres.offset + (logs?.length || 0))}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange("prev")}
disabled={filtres.offset === 0}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Précédent
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange("next")}
disabled={!logs || logs.length < filtres.limit}
>
Suivant
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,252 @@
import DashboardLayout from "@/components/DashboardLayout";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { trpc } from "@/lib/trpc";
import { BarChart, TrendingUp, AlertCircle, Mail, CheckCircle2, XCircle } from "lucide-react";
import {
LineChart,
Line,
BarChart as RechartsBarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
export default function AdminRappelsStats() {
const { data: stats, isLoading: statsLoading } = trpc.rappels.statistiques.useQuery();
const { data: evolution, isLoading: evolutionLoading } = trpc.rappels.evolution.useQuery();
const formatPercentage = (value: number) => {
return `${value.toFixed(1)}%`;
};
return (
<DashboardLayout>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Statistiques des Rappels</h1>
<p className="text-muted-foreground mt-1">
Analysez les performances du système de rappels
</p>
</div>
<TrendingUp className="h-8 w-8 text-muted-foreground" />
</div>
{/* Cartes de statistiques */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total envoyés</CardTitle>
<Mail className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{statsLoading ? "..." : stats?.totalEnvoyes || 0}
</div>
<p className="text-xs text-muted-foreground mt-1">
Tous les rappels confondus
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Succès</CardTitle>
<CheckCircle2 className="h-4 w-4 text-green-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{statsLoading ? "..." : stats?.totalSucces || 0}
</div>
<p className="text-xs text-muted-foreground mt-1">
Rappels envoyés avec succès
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Échecs</CardTitle>
<XCircle className="h-4 w-4 text-red-600" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
{statsLoading ? "..." : stats?.totalEchecs || 0}
</div>
<p className="text-xs text-muted-foreground mt-1">
Rappels en échec
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Taux de succès</CardTitle>
<BarChart className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{statsLoading ? "..." : formatPercentage(stats?.tauxSucces || 0)}
</div>
<p className="text-xs text-muted-foreground mt-1">
Ratio succès/total
</p>
</CardContent>
</Card>
</div>
{/* Graphique d'évolution */}
<Card>
<CardHeader>
<CardTitle>Évolution des envois</CardTitle>
<CardDescription>Nombre de rappels envoyés par jour</CardDescription>
</CardHeader>
<CardContent>
{evolutionLoading ? (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
Chargement...
</div>
) : !evolution || evolution.length === 0 ? (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
Aucune donnée disponible
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={evolution}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={(value) => {
const date = new Date(value);
return `${date.getDate()}/${date.getMonth() + 1}`;
}}
/>
<YAxis />
<Tooltip
labelFormatter={(value) => {
const date = new Date(value as string);
return date.toLocaleDateString("fr-FR");
}}
/>
<Legend />
<Line
type="monotone"
dataKey="nbSucces"
stroke="#22c55e"
name="Succès"
strokeWidth={2}
/>
<Line
type="monotone"
dataKey="nbEchecs"
stroke="#ef4444"
name="Échecs"
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Graphique du taux de succès */}
<Card>
<CardHeader>
<CardTitle>Taux de succès par jour</CardTitle>
<CardDescription>Pourcentage de rappels réussis</CardDescription>
</CardHeader>
<CardContent>
{evolutionLoading ? (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
Chargement...
</div>
) : !evolution || evolution.length === 0 ? (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
Aucune donnée disponible
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<RechartsBarChart
data={evolution.map(e => ({
...e,
tauxSucces: e.nbEnvoyes > 0 ? (e.nbSucces / e.nbEnvoyes) * 100 : 0,
}))}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={(value) => {
const date = new Date(value);
return `${date.getDate()}/${date.getMonth() + 1}`;
}}
/>
<YAxis domain={[0, 100]} tickFormatter={(value) => `${value}%`} />
<Tooltip
labelFormatter={(value) => {
const date = new Date(value as string);
return date.toLocaleDateString("fr-FR");
}}
formatter={(value: any) => [`${value.toFixed(1)}%`, "Taux de succès"]}
/>
<Bar dataKey="tauxSucces" fill="#3b82f6" name="Taux de succès" />
</RechartsBarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
{/* Emails problématiques */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-orange-600" />
Emails problématiques
</CardTitle>
<CardDescription>
Top 10 des emails avec le plus d'échecs
</CardDescription>
</CardHeader>
<CardContent>
{statsLoading ? (
<div className="text-center py-8 text-muted-foreground">
Chargement...
</div>
) : !stats?.emailsProblematiques || stats.emailsProblematiques.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
Aucun email problématique détecté
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead className="text-right">Nombre d'échecs</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.emailsProblematiques.map((item, index) => (
<TableRow key={index}>
<TableCell className="font-medium">{item.email}</TableCell>
<TableCell className="text-right">
<span className="inline-flex items-center justify-center rounded-full bg-red-100 px-3 py-1 text-sm font-medium text-red-800">
{item.nbEchecs}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
</DashboardLayout>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -148,6 +148,13 @@
"when": 1765180630803,
"tag": "0020_large_rocket_raccoon",
"breakpoints": true
},
{
"idx": 21,
"version": "5",
"when": 1765183092631,
"tag": "0021_equal_the_fallen",
"breakpoints": true
}
]
}

View File

@@ -471,6 +471,10 @@ export const logsRappels = mysqlTable("logsRappels", {
messageErreur: text("messageErreur"),
/** Date de la première date de la séquence (pour référence) */
dateSequence: timestamp("dateSequence").notNull(),
/** Nombre de tentatives d'envoi */
nbTentatives: int("nbTentatives").default(1).notNull(),
/** Date du prochain essai en cas d'échec */
prochainEssai: timestamp("prochainEssai"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
});

View File

@@ -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();
});
}

View File

@@ -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
View 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");
}

View File

@@ -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({

29
todo.md
View File

@@ -96,3 +96,32 @@
- [x] Créer la fonction de notification admin
- [x] Intégrer les notifications dans le catch des erreurs d'envoi
- [x] Ajouter un résumé des échecs dans les logs
## Historique et statistiques des rappels
### Backend
- [x] Créer les fonctions de récupération des logs avec filtres
- [x] Créer les fonctions de statistiques (taux succès, emails problématiques)
- [x] Ajouter les procédures tRPC pour l'historique
- [x] Ajouter les procédures tRPC pour les statistiques
### Page d'historique
- [x] Créer AdminRappelsHistorique.tsx avec tableau des logs
- [x] Ajouter les filtres (date, séquence, statut, email)
- [x] Ajouter la recherche par email
- [x] Ajouter la pagination
- [x] Ajouter la route dans App.tsx
### Tableau de bord statistiques
- [x] Créer AdminRappelsStats.tsx avec cartes de statistiques
- [x] Ajouter le graphique d'évolution des envois
- [x] Ajouter le graphique du taux de succès
- [x] Ajouter la liste des emails problématiques
- [x] Ajouter la route dans App.tsx
### Système de réessai automatique
- [x] Ajouter le champ nbTentatives dans logsRappels
- [x] Ajouter le champ prochainEssai dans logsRappels
- [x] Créer la fonction de retry avec délai exponentiel
- [x] Intégrer le retry dans rappelScheduler
- [x] Limiter à 3 tentatives maximum