Checkpoint: Ajout de 3 améliorations pour le système de notifications :
1. Interface de gestion des emails formateurs (Configuration > Formateurs) - permet de gérer les adresses email des formateurs pour activer/désactiver les notifications 2. Tableau de bord des notifications (Configuration > Notifications) - historique complet des emails envoyés avec filtres et statistiques 3. Lien vers questionnaire de satisfaction automatiquement inclus dans l'email de remerciement post-formation
This commit is contained in:
210
server/notificationLogsDb.ts
Normal file
210
server/notificationLogsDb.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { eq, desc, and, gte, lte, sql, like } from "drizzle-orm";
|
||||
import { getDb } from "./db";
|
||||
import { logsNotifications, sequences, apprenants, formateurs, formations } from "../drizzle/schema";
|
||||
|
||||
export type NotificationType =
|
||||
| "remerciement"
|
||||
| "notification_formateur_inscription"
|
||||
| "notification_formateur_annulation"
|
||||
| "alerte_capacite"
|
||||
| "notification_liste_attente";
|
||||
|
||||
export interface LogNotificationInput {
|
||||
type: NotificationType;
|
||||
sequenceId?: number;
|
||||
apprenantId?: number;
|
||||
formateurId?: number;
|
||||
emailDestinataire: string;
|
||||
sujet: string;
|
||||
statut: "success" | "failed";
|
||||
messageErreur?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre un log de notification
|
||||
*/
|
||||
export async function logNotification(input: LogNotificationInput) {
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
console.warn("[NotificationLogs] Database not available");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await db.insert(logsNotifications).values({
|
||||
type: input.type,
|
||||
sequenceId: input.sequenceId || null,
|
||||
apprenantId: input.apprenantId || null,
|
||||
formateurId: input.formateurId || null,
|
||||
emailDestinataire: input.emailDestinataire,
|
||||
sujet: input.sujet,
|
||||
statut: input.statut,
|
||||
messageErreur: input.messageErreur || null,
|
||||
metadata: input.metadata ? JSON.stringify(input.metadata) : null,
|
||||
});
|
||||
console.log(`[NotificationLogs] Log créé: ${input.type} -> ${input.emailDestinataire} (${input.statut})`);
|
||||
} catch (error) {
|
||||
console.error("[NotificationLogs] Erreur lors de la création du log:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'historique des notifications avec filtres
|
||||
*/
|
||||
export async function getNotificationLogs(filters?: {
|
||||
type?: NotificationType;
|
||||
dateDebut?: Date;
|
||||
dateFin?: Date;
|
||||
statut?: "success" | "failed";
|
||||
email?: string;
|
||||
sequenceId?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) return { logs: [], total: 0 };
|
||||
|
||||
const conditions = [];
|
||||
|
||||
if (filters?.type) {
|
||||
conditions.push(eq(logsNotifications.type, filters.type));
|
||||
}
|
||||
if (filters?.dateDebut) {
|
||||
conditions.push(gte(logsNotifications.dateEnvoi, filters.dateDebut));
|
||||
}
|
||||
if (filters?.dateFin) {
|
||||
conditions.push(lte(logsNotifications.dateEnvoi, filters.dateFin));
|
||||
}
|
||||
if (filters?.statut) {
|
||||
conditions.push(eq(logsNotifications.statut, filters.statut));
|
||||
}
|
||||
if (filters?.email) {
|
||||
conditions.push(like(logsNotifications.emailDestinataire, `%${filters.email}%`));
|
||||
}
|
||||
if (filters?.sequenceId) {
|
||||
conditions.push(eq(logsNotifications.sequenceId, filters.sequenceId));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
// Récupérer les logs avec les informations associées
|
||||
const logs = await db
|
||||
.select({
|
||||
id: logsNotifications.id,
|
||||
type: logsNotifications.type,
|
||||
sequenceId: logsNotifications.sequenceId,
|
||||
apprenantId: logsNotifications.apprenantId,
|
||||
formateurId: logsNotifications.formateurId,
|
||||
emailDestinataire: logsNotifications.emailDestinataire,
|
||||
sujet: logsNotifications.sujet,
|
||||
dateEnvoi: logsNotifications.dateEnvoi,
|
||||
statut: logsNotifications.statut,
|
||||
messageErreur: logsNotifications.messageErreur,
|
||||
metadata: logsNotifications.metadata,
|
||||
sequenceNom: sequences.nom,
|
||||
formationNom: formations.nom,
|
||||
apprenantNom: apprenants.nom,
|
||||
apprenantPrenom: apprenants.prenom,
|
||||
formateurNom: formateurs.nom,
|
||||
})
|
||||
.from(logsNotifications)
|
||||
.leftJoin(sequences, eq(logsNotifications.sequenceId, sequences.id))
|
||||
.leftJoin(formations, eq(sequences.formationId, formations.id))
|
||||
.leftJoin(apprenants, eq(logsNotifications.apprenantId, apprenants.id))
|
||||
.leftJoin(formateurs, eq(logsNotifications.formateurId, formateurs.id))
|
||||
.where(whereClause)
|
||||
.orderBy(desc(logsNotifications.dateEnvoi))
|
||||
.limit(filters?.limit || 50)
|
||||
.offset(filters?.offset || 0);
|
||||
|
||||
// Compter le total
|
||||
const [countResult] = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(logsNotifications)
|
||||
.where(whereClause);
|
||||
|
||||
return {
|
||||
logs,
|
||||
total: countResult?.count || 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les statistiques des notifications
|
||||
*/
|
||||
export async function getNotificationStats(filters?: {
|
||||
dateDebut?: Date;
|
||||
dateFin?: Date;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
const conditions = [];
|
||||
if (filters?.dateDebut) {
|
||||
conditions.push(gte(logsNotifications.dateEnvoi, filters.dateDebut));
|
||||
}
|
||||
if (filters?.dateFin) {
|
||||
conditions.push(lte(logsNotifications.dateEnvoi, filters.dateFin));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
// Statistiques globales
|
||||
const [globalStats] = await db
|
||||
.select({
|
||||
total: sql<number>`COUNT(*)`,
|
||||
success: sql<number>`SUM(CASE WHEN statut = 'success' THEN 1 ELSE 0 END)`,
|
||||
failed: sql<number>`SUM(CASE WHEN statut = 'failed' THEN 1 ELSE 0 END)`,
|
||||
})
|
||||
.from(logsNotifications)
|
||||
.where(whereClause);
|
||||
|
||||
// Statistiques par type
|
||||
const statsByType = await db
|
||||
.select({
|
||||
type: logsNotifications.type,
|
||||
total: sql<number>`COUNT(*)`,
|
||||
success: sql<number>`SUM(CASE WHEN statut = 'success' THEN 1 ELSE 0 END)`,
|
||||
failed: sql<number>`SUM(CASE WHEN statut = 'failed' THEN 1 ELSE 0 END)`,
|
||||
})
|
||||
.from(logsNotifications)
|
||||
.where(whereClause)
|
||||
.groupBy(logsNotifications.type);
|
||||
|
||||
// Évolution par jour (7 derniers jours)
|
||||
const evolutionParJour = await db
|
||||
.select({
|
||||
date: sql<string>`DATE(dateEnvoi)`,
|
||||
total: sql<number>`COUNT(*)`,
|
||||
success: sql<number>`SUM(CASE WHEN statut = 'success' THEN 1 ELSE 0 END)`,
|
||||
failed: sql<number>`SUM(CASE WHEN statut = 'failed' THEN 1 ELSE 0 END)`,
|
||||
})
|
||||
.from(logsNotifications)
|
||||
.where(whereClause)
|
||||
.groupBy(sql`DATE(dateEnvoi)`)
|
||||
.orderBy(sql`DATE(dateEnvoi)`)
|
||||
.limit(30);
|
||||
|
||||
return {
|
||||
global: {
|
||||
total: globalStats?.total || 0,
|
||||
success: globalStats?.success || 0,
|
||||
failed: globalStats?.failed || 0,
|
||||
tauxSucces: globalStats?.total ? Math.round((globalStats.success / globalStats.total) * 100) : 0,
|
||||
},
|
||||
parType: statsByType,
|
||||
evolutionParJour,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Labels pour les types de notifications
|
||||
*/
|
||||
export const notificationTypeLabels: Record<NotificationType, string> = {
|
||||
remerciement: "Remerciement post-formation",
|
||||
notification_formateur_inscription: "Notification formateur (inscription)",
|
||||
notification_formateur_annulation: "Notification formateur (annulation)",
|
||||
alerte_capacite: "Alerte capacité atteinte",
|
||||
notification_liste_attente: "Notification liste d'attente",
|
||||
};
|
||||
@@ -1,7 +1,82 @@
|
||||
import { getDb } from "./db";
|
||||
import { sequences, inscriptions, apprenants, formations, datesFormation, formateurs } from "../drizzle/schema";
|
||||
import { sequences, inscriptions, apprenants, formations, datesFormation, formateurs, questionnaires, envoisQuestionnaires } from "../drizzle/schema";
|
||||
import { eq, and, lte, sql } from "drizzle-orm";
|
||||
import { sendRemerciementPostFormation } from "./emailService";
|
||||
import { logNotification } from "./notificationLogsDb";
|
||||
import crypto from "crypto";
|
||||
|
||||
/**
|
||||
* Génère un token unique pour accéder au questionnaire
|
||||
*/
|
||||
function generateToken(): string {
|
||||
return crypto.randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère ou crée un lien questionnaire pour un apprenant
|
||||
*/
|
||||
async function getOrCreateQuestionnaireLink(
|
||||
apprenantId: number,
|
||||
sequenceId: number
|
||||
): Promise<string | null> {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
try {
|
||||
// Chercher un questionnaire de satisfaction actif
|
||||
const [questionnaire] = await db
|
||||
.select()
|
||||
.from(questionnaires)
|
||||
.where(
|
||||
and(
|
||||
eq(questionnaires.type, "satisfaction"),
|
||||
eq(questionnaires.actif, true)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!questionnaire) {
|
||||
console.log("[Remerciements] Aucun questionnaire de satisfaction actif trouvé");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Vérifier si un envoi existe déjà
|
||||
const [envoiExistant] = await db
|
||||
.select()
|
||||
.from(envoisQuestionnaires)
|
||||
.where(
|
||||
and(
|
||||
eq(envoisQuestionnaires.questionnaireId, questionnaire.id),
|
||||
eq(envoisQuestionnaires.apprenantId, apprenantId),
|
||||
eq(envoisQuestionnaires.sequenceId, sequenceId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (envoiExistant) {
|
||||
// Retourner le lien existant
|
||||
const baseUrl = process.env.VITE_FRONTEND_URL || "http://localhost:3000";
|
||||
return `${baseUrl}/questionnaire/${envoiExistant.token}`;
|
||||
}
|
||||
|
||||
// Créer un nouvel envoi
|
||||
const token = generateToken();
|
||||
await db.insert(envoisQuestionnaires).values({
|
||||
questionnaireId: questionnaire.id,
|
||||
apprenantId,
|
||||
sequenceId,
|
||||
token,
|
||||
dateEnvoi: new Date(),
|
||||
dateReponse: null,
|
||||
});
|
||||
|
||||
const baseUrl = process.env.VITE_FRONTEND_URL || "http://localhost:3000";
|
||||
return `${baseUrl}/questionnaire/${token}`;
|
||||
} catch (error) {
|
||||
console.error("[Remerciements] Erreur lors de la création du lien questionnaire:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Table pour suivre les remerciements déjà envoyés
|
||||
@@ -111,6 +186,9 @@ async function processRemerciementSequence(
|
||||
}
|
||||
|
||||
try {
|
||||
// Générer le lien questionnaire
|
||||
const lienQuestionnaire = await getOrCreateQuestionnaireLink(apprenant.id, sequence.id);
|
||||
|
||||
await sendRemerciementPostFormation({
|
||||
apprenantEmail: apprenant.email,
|
||||
apprenantNom: apprenant.nom,
|
||||
@@ -119,12 +197,35 @@ async function processRemerciementSequence(
|
||||
formationNom: formation.nom,
|
||||
sequenceNom: sequence.nom,
|
||||
formateurNom: formateur?.nom,
|
||||
lienQuestionnaire: lienQuestionnaire || undefined,
|
||||
});
|
||||
|
||||
// Logger la notification
|
||||
await logNotification({
|
||||
type: "remerciement",
|
||||
sequenceId: sequence.id,
|
||||
apprenantId: apprenant.id,
|
||||
emailDestinataire: apprenant.email,
|
||||
sujet: `Merci pour votre participation - ${formation.nom}`,
|
||||
statut: "success",
|
||||
metadata: { lienQuestionnaire },
|
||||
});
|
||||
|
||||
marquerRemerciementEnvoye(inscription.id, sequence.id);
|
||||
envoyesCount++;
|
||||
console.log(`[Remerciements] Email envoyé à ${apprenant.email}`);
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
// Logger l'échec
|
||||
await logNotification({
|
||||
type: "remerciement",
|
||||
sequenceId: sequence.id,
|
||||
apprenantId: apprenant.id,
|
||||
emailDestinataire: apprenant.email,
|
||||
sujet: `Merci pour votre participation - ${formation.nom}`,
|
||||
statut: "failed",
|
||||
messageErreur: error.message,
|
||||
});
|
||||
|
||||
errorsCount++;
|
||||
console.error(`[Remerciements] Erreur envoi à ${apprenant.email}:`, error);
|
||||
}
|
||||
@@ -189,6 +290,9 @@ export async function envoyerRemerciementsSequence(sequenceId: number): Promise<
|
||||
|
||||
for (const { inscription, apprenant } of inscriptionsConfirmees) {
|
||||
try {
|
||||
// Générer le lien questionnaire
|
||||
const lienQuestionnaire = await getOrCreateQuestionnaireLink(apprenant.id, sequenceId);
|
||||
|
||||
await sendRemerciementPostFormation({
|
||||
apprenantEmail: apprenant.email,
|
||||
apprenantNom: apprenant.nom,
|
||||
@@ -197,9 +301,33 @@ export async function envoyerRemerciementsSequence(sequenceId: number): Promise<
|
||||
formationNom: formation.nom,
|
||||
sequenceNom: sequence.nom,
|
||||
formateurNom: formateur?.nom,
|
||||
lienQuestionnaire: lienQuestionnaire || undefined,
|
||||
});
|
||||
|
||||
// Logger la notification
|
||||
await logNotification({
|
||||
type: "remerciement",
|
||||
sequenceId,
|
||||
apprenantId: apprenant.id,
|
||||
emailDestinataire: apprenant.email,
|
||||
sujet: `Merci pour votre participation - ${formation.nom}`,
|
||||
statut: "success",
|
||||
metadata: { lienQuestionnaire },
|
||||
});
|
||||
|
||||
sent++;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
// Logger l'échec
|
||||
await logNotification({
|
||||
type: "remerciement",
|
||||
sequenceId,
|
||||
apprenantId: apprenant.id,
|
||||
emailDestinataire: apprenant.email,
|
||||
sujet: `Merci pour votre participation - ${formation.nom}`,
|
||||
statut: "failed",
|
||||
messageErreur: error.message,
|
||||
});
|
||||
|
||||
console.error(`[Remerciements] Erreur envoi à ${apprenant.email}:`, error);
|
||||
failed++;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import * as analyticsDb from "./analyticsDb";
|
||||
import { generateAnalyticsExcel, generateAnalyticsPDF } from "./analyticsExportService";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { sendInscriptionConfirmation, sendGroupEmail, sendNotificationFormateurNouvelleInscription, sendNotificationFormateurAnnulation, sendAlerteCapaciteAtteinte, sendNotificationPlaceDisponible, sendRemerciementPostFormation } from "./emailService";
|
||||
import { logNotification } from "./notificationLogsDb";
|
||||
import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService";
|
||||
import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel";
|
||||
|
||||
@@ -226,6 +227,7 @@ export const appRouter = router({
|
||||
|
||||
create: adminProcedure.input(z.object({
|
||||
nom: z.string().min(1),
|
||||
email: z.string().email().optional(),
|
||||
})).mutation(async ({ input }) => {
|
||||
await db.createFormateur(input);
|
||||
return { success: true };
|
||||
@@ -234,6 +236,7 @@ export const appRouter = router({
|
||||
update: adminProcedure.input(z.object({
|
||||
id: z.number(),
|
||||
nom: z.string().min(1).optional(),
|
||||
email: z.string().email().nullable().optional(),
|
||||
})).mutation(async ({ input }) => {
|
||||
const { id, ...data } = input;
|
||||
await db.updateFormateur(id, data);
|
||||
@@ -487,8 +490,29 @@ export const appRouter = router({
|
||||
ordre: d.ordre,
|
||||
})),
|
||||
});
|
||||
// Logger la notification
|
||||
await logNotification({
|
||||
type: "notification_formateur_inscription",
|
||||
sequenceId: input.sequenceId,
|
||||
apprenantId: inscriptionApprenant.id,
|
||||
formateurId: formateur.id,
|
||||
emailDestinataire: formateur.email,
|
||||
sujet: `Nouvelle inscription - ${inscriptionFormation.nom}`,
|
||||
statut: "success",
|
||||
});
|
||||
console.log(`[Notification] Email envoyé au formateur ${formateur.email} pour nouvelle inscription`);
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
// Logger l'échec
|
||||
await logNotification({
|
||||
type: "notification_formateur_inscription",
|
||||
sequenceId: input.sequenceId,
|
||||
apprenantId: inscriptionApprenant.id,
|
||||
formateurId: formateur.id,
|
||||
emailDestinataire: formateur.email,
|
||||
sujet: `Nouvelle inscription - ${inscriptionFormation.nom}`,
|
||||
statut: "failed",
|
||||
messageErreur: e.message,
|
||||
});
|
||||
console.error(`[Notification] Erreur envoi email formateur:`, e);
|
||||
}
|
||||
}
|
||||
@@ -582,8 +606,29 @@ export const appRouter = router({
|
||||
nbInscrits: nbInscritsApres,
|
||||
capaciteMax: sequence.capaciteMax,
|
||||
});
|
||||
// Logger la notification
|
||||
await logNotification({
|
||||
type: "notification_formateur_annulation",
|
||||
sequenceId: input.sequenceId,
|
||||
apprenantId: apprenant.id,
|
||||
formateurId: formateur.id,
|
||||
emailDestinataire: formateur.email,
|
||||
sujet: `Annulation d'inscription - ${formation.nom}`,
|
||||
statut: "success",
|
||||
});
|
||||
console.log(`[Notification] Email d'annulation envoyé au formateur ${formateur.email}`);
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
// Logger l'échec
|
||||
await logNotification({
|
||||
type: "notification_formateur_annulation",
|
||||
sequenceId: input.sequenceId,
|
||||
apprenantId: apprenant.id,
|
||||
formateurId: formateur.id,
|
||||
emailDestinataire: formateur.email,
|
||||
sujet: `Annulation d'inscription - ${formation.nom}`,
|
||||
statut: "failed",
|
||||
messageErreur: e.message,
|
||||
});
|
||||
console.error(`[Notification] Erreur envoi email formateur:`, e);
|
||||
}
|
||||
}
|
||||
@@ -607,8 +652,27 @@ export const appRouter = router({
|
||||
positionListeAttente: 1,
|
||||
delaiReponse: 48,
|
||||
});
|
||||
// Logger la notification
|
||||
await logNotification({
|
||||
type: "notification_liste_attente",
|
||||
sequenceId: input.sequenceId,
|
||||
apprenantId: premierEnAttente.apprenant.id,
|
||||
emailDestinataire: premierEnAttente.apprenant.email,
|
||||
sujet: `Place disponible - ${formation.nom}`,
|
||||
statut: "success",
|
||||
});
|
||||
console.log(`[Notification] Email place disponible envoyé à ${premierEnAttente.apprenant.email}`);
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
// Logger l'échec
|
||||
await logNotification({
|
||||
type: "notification_liste_attente",
|
||||
sequenceId: input.sequenceId,
|
||||
apprenantId: premierEnAttente.apprenant.id,
|
||||
emailDestinataire: premierEnAttente.apprenant.email,
|
||||
sujet: `Place disponible - ${formation.nom}`,
|
||||
statut: "failed",
|
||||
messageErreur: e.message,
|
||||
});
|
||||
console.error(`[Notification] Erreur envoi email liste d'attente:`, e);
|
||||
}
|
||||
}
|
||||
@@ -1631,6 +1695,46 @@ export const appRouter = router({
|
||||
await processRemerciementsAutomatiques();
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// Historique des notifications
|
||||
historique: adminProcedure
|
||||
.input(z.object({
|
||||
type: z.enum(["remerciement", "notification_formateur_inscription", "notification_formateur_annulation", "alerte_capacite", "notification_liste_attente"]).optional(),
|
||||
dateDebut: z.string().optional(),
|
||||
dateFin: z.string().optional(),
|
||||
statut: z.enum(["success", "failed"]).optional(),
|
||||
email: z.string().optional(),
|
||||
sequenceId: z.number().optional(),
|
||||
limit: z.number().default(50),
|
||||
offset: z.number().default(0),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
const { getNotificationLogs } = await import("./notificationLogsDb");
|
||||
return getNotificationLogs({
|
||||
type: input.type,
|
||||
dateDebut: input.dateDebut ? new Date(input.dateDebut) : undefined,
|
||||
dateFin: input.dateFin ? new Date(input.dateFin) : undefined,
|
||||
statut: input.statut,
|
||||
email: input.email,
|
||||
sequenceId: input.sequenceId,
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
});
|
||||
}),
|
||||
|
||||
// Statistiques des notifications
|
||||
statistiques: adminProcedure
|
||||
.input(z.object({
|
||||
dateDebut: z.string().optional(),
|
||||
dateFin: z.string().optional(),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
const { getNotificationStats } = await import("./notificationLogsDb");
|
||||
return getNotificationStats({
|
||||
dateDebut: input.dateDebut ? new Date(input.dateDebut) : undefined,
|
||||
dateFin: input.dateFin ? new Date(input.dateFin) : undefined,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user