import { COOKIE_NAME } from "@shared/const"; import { getSessionCookieOptions } from "./_core/cookies"; import { systemRouter } from "./_core/systemRouter"; import { publicProcedure, protectedProcedure, router } from "./_core/trpc"; import { parseLocalDateTime } from "./dateUtils"; import { z } from "zod"; import * as db from "./db"; import * as analyticsDb from "./analyticsDb"; import * as fail2banDb from "./fail2banDb"; import { generateAnalyticsExcel, generateAnalyticsPDF } from "./analyticsExportService"; import { TRPCError } from "@trpc/server"; import { sendInscriptionConfirmation, sendGroupEmail, sendNotificationFormateurNouvelleInscription, sendNotificationFormateurAnnulation, sendAlerteCapaciteAtteinte, sendNotificationPlaceDisponible, sendRemerciementPostFormation, sendResetPasswordEmail } from "./emailService"; import { logNotification } from "./notificationLogsDb"; import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService"; import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel"; import crypto from "crypto"; import bcrypt from "bcryptjs"; import { sql } from "drizzle-orm"; // Procédure admin uniquement const adminProcedure = protectedProcedure.use(({ ctx, next }) => { if (ctx.user.role !== 'admin') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux administrateurs' }); } return next({ ctx }); }); // Procédure pour formateurs, super-formateurs et admins const formateurProcedure = protectedProcedure.use(({ ctx, next }) => { if (!['admin', 'formateur', 'super_formateur'].includes(ctx.user.role)) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux formateurs et administrateurs' }); } return next({ ctx }); }); export const appRouter = router({ system: systemRouter, auth: router({ me: publicProcedure.query(opts => opts.ctx.user), logout: publicProcedure.mutation(({ ctx }) => { const cookieOptions = getSessionCookieOptions(ctx.req); ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 }); return { success: true, } as const; }), // Demander une réinitialisation de mot de passe requestPasswordReset: publicProcedure .input(z.object({ email: z.string().email(), })) .mutation(async ({ input }) => { const { email } = input; // Vérifier si l'utilisateur existe const utilisateur = await db.getUtilisateurByEmail(email); if (!utilisateur) { // Ne pas révéler si l'email existe ou non pour des raisons de sécurité return { success: true }; } // Générer un token unique const token = crypto.randomBytes(32).toString('hex'); // Sauvegarder le token dans la base de données await db.createPasswordResetToken(email, token); // Envoyer l'email avec le template reset_password const resetUrl = `${process.env.VITE_OAUTH_PORTAL_URL || 'http://localhost:3000'}/reset-password?token=${token}`; await sendResetPasswordEmail({ email, prenom: utilisateur.name?.split(' ')[0] || '', nom: utilisateur.name?.split(' ').slice(1).join(' ') || utilisateur.name || '', lienReinitialisation: resetUrl, }); return { success: true }; }), // Valider le token et réinitialiser le mot de passe resetPassword: publicProcedure .input(z.object({ token: z.string(), newPassword: z.string().min(8), })) .mutation(async ({ input }) => { const { token, newPassword } = input; // Récupérer le token const resetToken = await db.getPasswordResetToken(token); if (!resetToken) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Token invalide ou expiré', }); } // Vérifier si le token est expiré if (new Date() > new Date(resetToken.expiresAt)) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Token expiré', }); } // Vérifier si le token a déjà été utilisé if (resetToken.used) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Token déjà utilisé', }); } // Récupérer l'utilisateur const utilisateur = await db.getUtilisateurByEmail(resetToken.email); if (!utilisateur) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Utilisateur introuvable', }); } // Mettre à jour le mot de passe const hashedPassword = await bcrypt.hash(newPassword, 10); await db.updateUtilisateurPassword(utilisateur.id, hashedPassword); // Marquer le token comme utilisé await db.markTokenAsUsed(resetToken.id); return { success: true }; }), }), // ===== FORMATIONS ===== formations: router({ list: protectedProcedure.query(async ({ ctx }) => { // Si l'utilisateur est formateur, ne montrer que les formations pour lesquelles il a des séquences if (ctx.user.role === 'formateur' && ctx.user.formateurId) { return db.getFormationsByFormateur(ctx.user.formateurId); } else if (ctx.user.role === 'admin' || ctx.user.role === 'super_formateur') { return db.getFormations(); } else { throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès non autorisé' }); } }), getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { return db.getFormationById(input.id); }), getByLien: publicProcedure.input(z.object({ lien: z.string() })).query(async ({ input }) => { return db.getFormationByLien(input.lien); }), create: adminProcedure.input(z.object({ nom: z.string().min(1), description: z.string().optional(), lienUnique: z.string().min(1), actif: z.boolean().optional(), })).mutation(async ({ input }) => { await db.createFormation(input); return { success: true }; }), update: adminProcedure.input(z.object({ id: z.number(), nom: z.string().min(1).optional(), description: z.string().optional(), lienUnique: z.string().min(1).optional(), actif: z.boolean().optional(), })).mutation(async ({ input }) => { const { id, ...data } = input; await db.updateFormation(id, data); return { success: true }; }), delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => { await db.deleteFormation(input.id); return { success: true }; }), importExcel: adminProcedure.input(z.object({ fileBase64: z.string(), })).mutation(async ({ input }) => { try { // Décoder le fichier base64 const buffer = Buffer.from(input.fileBase64, 'base64'); // Parser le fichier Excel const { formations, sequences } = parseExcelFile(buffer); // Valider les données const validation = validateImportData(formations, sequences); if (!validation.valid) { return { success: false, formationsCreated: 0, sequencesCreated: 0, errors: validation.errors, warnings: validation.warnings, }; } // Importer dans la base de données const result = await importToDatabase(formations, sequences); return { ...result, warnings: [...validation.warnings, ...result.warnings], }; } catch (error: any) { return { success: false, formationsCreated: 0, sequencesCreated: 0, errors: [`Erreur lors du traitement du fichier: ${error.message}`], warnings: [], }; } }), previewExcel: adminProcedure.input(z.object({ fileBase64: z.string(), })).mutation(async ({ input }) => { try { const buffer = Buffer.from(input.fileBase64, 'base64'); const { formations, sequences } = parseExcelFile(buffer); const validation = validateImportData(formations, sequences); return { formations, sequences, validation, }; } catch (error: any) { throw new TRPCError({ code: 'BAD_REQUEST', message: `Erreur lors de la lecture du fichier: ${error.message}`, }); } }), }), // ===== APPRENANTS ===== apprenants: router({ list: protectedProcedure.query(async ({ ctx }) => { // Si l'utilisateur est formateur, ne montrer que les apprenants inscrits à ses séquences if (ctx.user.role === 'formateur' && ctx.user.formateurId) { return db.getApprenantsByFormateur(ctx.user.formateurId); } else if (ctx.user.role === 'admin' || ctx.user.role === 'super_formateur') { return db.getApprenants(); } else { throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès non autorisé' }); } }), getById: protectedProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { return db.getApprenantById(input.id); }), getByEmail: publicProcedure.input(z.object({ email: z.string() })).query(async ({ input }) => { return db.getApprenantByEmail(input.email); }), create: adminProcedure.input(z.object({ nom: z.string().min(1), prenom: z.string().min(1), email: z.string().email(), codeEtablissement: z.string().min(1), fonction: z.enum(["directeur", "chef_service", "autre"]), })).mutation(async ({ input }) => { await db.createApprenant(input); return { success: true }; }), // Procédure publique pour créer un apprenant lors de l'inscription createPublic: publicProcedure.input(z.object({ nom: z.string().min(1), prenom: z.string().min(1), email: z.string().email(), codeEtablissement: z.string().min(1), fonction: z.enum(["directeur", "chef_service", "autre"]), })).mutation(async ({ input }) => { await db.createApprenant(input); return { success: true }; }), update: adminProcedure.input(z.object({ id: z.number(), nom: z.string().min(1).optional(), prenom: z.string().min(1).optional(), email: z.string().email().optional(), codeEtablissement: z.string().min(1).optional(), fonction: z.enum(["directeur", "chef_service", "autre"]).optional(), })).mutation(async ({ input }) => { const { id, ...data } = input; await db.updateApprenant(id, data); return { success: true }; }), delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => { await db.deleteApprenant(input.id); return { success: true }; }), getInscriptions: protectedProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { const results = await db.getInscriptionsByApprenant(input.id); // Récupérer les informations de formation et séquence pour chaque inscription const inscriptionsAvecDetails = await Promise.all( results.map(async (result: any) => { const insc = result.inscription; if (!insc) return null; const sequence = await db.getSequenceById(insc.sequenceId); if (!sequence) return null; const formation = await db.getFormationById(sequence.formationId); const dates = await db.getDatesBySequence(insc.sequenceId); return { ...insc, sequence: { ...sequence, dates }, formation, }; }) ); return inscriptionsAvecDetails.filter(i => i !== null); }), }), // ===== FORMATEURS ===== formateurs: router({ list: adminProcedure.query(async () => { return db.getFormateurs(); }), // Tableau de bord formateur dashboardStats: protectedProcedure.query(async ({ ctx }) => { if (ctx.user.role !== 'formateur' || !ctx.user.formateurId) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux formateurs' }); } const formateurDb = await import('./formateurDb'); return formateurDb.getFormateurDashboardStats(ctx.user.formateurId); }), prochainesSequences: protectedProcedure .input(z.object({ limit: z.number().optional().default(10) })) .query(async ({ ctx, input }) => { if (ctx.user.role !== 'formateur' || !ctx.user.formateurId) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux formateurs' }); } const formateurDb = await import('./formateurDb'); return formateurDb.getFormateurProchainesSequences(ctx.user.formateurId, input.limit); }), getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { return db.getFormateurById(input.id); }), 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 }; }), update: adminProcedure.input(z.object({ id: z.number(), nom: z.string().min(1).optional(), email: z.string().email().or(z.literal('')).nullable().optional(), })).mutation(async ({ input }) => { const { id, ...data } = input; // Convertir les chaînes vides en null if (data.email === '') { data.email = null; } await db.updateFormateur(id, data); return { success: true }; }), delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => { await db.deleteFormateur(input.id); return { success: true }; }), }), // ===== SÉQUENCES ===== sequences: router({ list: protectedProcedure.query(async ({ ctx }) => { let seqs; // Si l'utilisateur est formateur, ne montrer que ses séquences if (ctx.user.role === 'formateur' && ctx.user.formateurId) { seqs = await db.getSequencesByFormateur(ctx.user.formateurId); } else if (ctx.user.role === 'admin' || ctx.user.role === 'super_formateur') { seqs = await db.getSequencesWithFormation(); } else { throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès non autorisé' }); } // Récupérer les dates et le nombre d'inscrits pour chaque séquence const sequencesAvecDates = await Promise.all( seqs.map(async (seq) => { const dates = await db.getDatesBySequence(seq.id); const nbInscrits = await db.countInscriptionsBySequence(seq.id, 'confirmee'); // Récupérer les rappels associés à chaque date const datesAvecRappels = await Promise.all( dates.map(async (date) => { const rappels = await db.getRappelsByDateFormation(date.id); return { ...date, rappels }; }) ); // Compter le nombre de rappels actifs pour cette séquence const nbRappelsActifs = datesAvecRappels.reduce((total, date) => { return total + (date.rappels?.filter((r: any) => r.actif).length || 0); }, 0); return { ...seq, dates: datesAvecRappels, nbInscrits, nbRappelsActifs }; }) ); return sequencesAvecDates; }), listByFormation: publicProcedure.input(z.object({ formationId: z.number() })).query(async ({ input }) => { const seqs = await db.getSequencesByFormation(input.formationId); // Récupérer les dates et le nombre d'inscrits pour chaque séquence const sequencesAvecDates = await Promise.all( seqs.map(async (seq) => { const dates = await db.getDatesBySequence(seq.id); const nbInscrits = await db.countInscriptionsBySequence(seq.id, 'confirmee'); return { ...seq, dates, nbInscrits }; }) ); return sequencesAvecDates; }), getById: publicProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { const seq = await db.getSequenceById(input.id); if (!seq) return null; const dates = await db.getDatesBySequence(input.id); const nbInscrits = await db.countInscriptionsBySequence(input.id, 'confirmee'); return { ...seq, dates, nbInscrits }; }), create: adminProcedure.input(z.object({ formationId: z.number(), nom: z.string().min(1), lieu: z.string().min(1), publicCible: z.enum(["directeur", "chef_service", "tous", "autre"]), formateurId: z.number().nullable().optional(), capaciteMax: z.number().default(12), dateBlocage: z.string(), statut: z.enum(["ouverte", "bloquee", "terminee"]).optional(), dates: z.array(z.object({ dateDebut: z.string(), dateFin: z.string(), ordre: z.number(), })).min(1).max(4), })).mutation(async ({ input }) => { const { dates, ...sequenceData } = input; // Valider que chaque date de fin est après la date de début for (const date of dates) { const debut = new Date(date.dateDebut); const fin = new Date(date.dateFin); if (fin <= debut) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'La date de fin doit être après la date de début pour toutes les dates de formation', }); } } // Créer la séquence const result = await db.createSequence({ ...sequenceData, dateBlocage: parseLocalDateTime(sequenceData.dateBlocage), }); // Récupérer l'ID de la séquence créée const sequenceId = Number(result[0].insertId); // Créer les dates de formation for (const date of dates) { await db.createDateFormation({ sequenceId, dateDebut: parseLocalDateTime(date.dateDebut), dateFin: parseLocalDateTime(date.dateFin), ordre: date.ordre, }); } return { success: true, sequenceId }; }), update: adminProcedure.input(z.object({ id: z.number(), formationId: z.number(), nom: z.string().min(1), lieu: z.string().min(1), publicCible: z.enum(["directeur", "chef_service", "tous", "autre"]), formateurId: z.number().nullable().optional(), capaciteMax: z.number(), dateBlocage: z.string(), statut: z.enum(["ouverte", "bloquee", "terminee"]), dates: z.array(z.object({ dateDebut: z.string(), dateFin: z.string(), ordre: z.number(), })).min(1).max(4), })).mutation(async ({ input }) => { const { id, dates, ...sequenceData } = input; console.log('[UPDATE SEQUENCE] Input reçu:', JSON.stringify(input, null, 2)); // Valider que chaque date de fin est après la date de début for (const date of dates) { const debut = new Date(date.dateDebut); const fin = new Date(date.dateFin); if (fin <= debut) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'La date de fin doit être après la date de début pour toutes les dates de formation', }); } } // Mettre à jour la séquence const updateData: any = { ...sequenceData, dateBlocage: sequenceData.dateBlocage && sequenceData.dateBlocage.trim() !== '' ? parseLocalDateTime(sequenceData.dateBlocage) : null, }; console.log('[UPDATE SEQUENCE] UpdateData:', JSON.stringify(updateData, null, 2)); await db.updateSequence(id, updateData); console.log('[UPDATE SEQUENCE] Séquence mise à jour avec succès'); // Récupérer les dates existantes const existingDates = await db.getDatesBySequence(id); console.log('[UPDATE SEQUENCE] Dates existantes:', existingDates.length); // Comparer les dates pour détecter les changements const datesChanged = existingDates.length !== dates.length || existingDates.some((existing, index) => { const newDate = dates[index]; if (!newDate) return true; const existingDebut = new Date(existing.dateDebut).toISOString(); const existingFin = new Date(existing.dateFin).toISOString(); const newDebut = parseLocalDateTime(newDate.dateDebut).toISOString(); const newFin = parseLocalDateTime(newDate.dateFin).toISOString(); return existingDebut !== newDebut || existingFin !== newFin; }); // Ne supprimer/recréer les dates que si elles ont changé if (datesChanged) { console.log('[UPDATE SEQUENCE] Dates modifiées, suppression et recréation'); // Supprimer les anciennes dates await db.deleteDatesBySequence(id); console.log('[UPDATE SEQUENCE] Anciennes dates supprimées'); // Créer les nouvelles dates for (const date of dates) { console.log('[UPDATE SEQUENCE] Création date:', date); await db.createDateFormation({ sequenceId: id, dateDebut: parseLocalDateTime(date.dateDebut), dateFin: parseLocalDateTime(date.dateFin), ordre: date.ordre, }); } console.log('[UPDATE SEQUENCE] Toutes les dates ont été créées'); } else { console.log('[UPDATE SEQUENCE] Dates inchangées, conservation des rappels'); } return { success: true }; }), delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => { // Supprimer d'abord les dates associées await db.deleteDatesBySequence(input.id); // Puis supprimer la séquence await db.deleteSequence(input.id); return { success: true }; }), getDatesByFormateurToken: publicProcedure .input(z.object({ token: z.string() })) .query(async ({ input }) => { const sequence = await db.getSequenceByFormateurToken(input.token); if (!sequence) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); } const dates = await db.getDatesBySequence(sequence.id); return { sequence, dates }; }), }), // ===== INSCRIPTIONS ===== inscriptions: router({ // Liste des inscriptions avec dates de formation (pour formateurs) listWithDates: protectedProcedure .input(z.object({ sequenceId: z.number() })) .query(async ({ input }) => { const { getInscriptionsWithDates } = await import("./inscriptionWithDates"); return getInscriptionsWithDates(input.sequenceId); }), listAll: adminProcedure.query(async () => { return db.getAllInscriptions(); }), listBySequence: protectedProcedure.input(z.object({ sequenceId: z.number() })).query(async ({ input }) => { return db.getInscriptionsBySequence(input.sequenceId); }), listByApprenant: publicProcedure.input(z.object({ apprenantId: z.number() })).query(async ({ input }) => { return db.getInscriptionsByApprenant(input.apprenantId); }), listByApprenantWithDates: publicProcedure .input(z.object({ apprenantId: z.number() })) .query(async ({ input }) => { const { getInscriptionsByApprenantWithDates } = await import("./inscriptionWithDates"); return getInscriptionsByApprenantWithDates(input.apprenantId); }), checkExisting: publicProcedure.input(z.object({ apprenantId: z.number(), sequenceId: z.number(), })).query(async ({ input }) => { return db.checkExistingInscription(input.apprenantId, input.sequenceId); }), inscrire: publicProcedure.input(z.object({ apprenantId: z.number(), sequenceId: z.number(), })).mutation(async ({ input }) => { // Vérifier si la séquence existe const sequence = await db.getSequenceById(input.sequenceId); if (!sequence) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); } // Vérifier si la séquence est bloquée const now = new Date(); if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Les inscriptions sont fermées pour cette séquence (J-15 dépassé)' }); } // Vérifier si l'apprenant a déjà une inscription const existing = await db.checkExistingInscription(input.apprenantId, input.sequenceId); if (existing) { throw new TRPCError({ code: 'CONFLICT', message: 'Vous êtes déjà inscrit à cette séquence' }); } // Vérifier la capacité const nbInscrits = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee'); const statut = nbInscrits >= sequence.capaciteMax ? 'en_attente' : 'confirmee'; await db.createInscription({ apprenantId: input.apprenantId, sequenceId: input.sequenceId, statut, }); // Envoyer l'email de confirmation avec invitations Outlook pour toutes les dates const inscriptionSequence = await db.getSequenceById(input.sequenceId); const inscriptionApprenant = await db.getApprenantById(input.apprenantId); const inscriptionFormation = inscriptionSequence ? await db.getFormationById(inscriptionSequence.formationId) : null; const dates = inscriptionSequence ? await db.getDatesBySequence(inscriptionSequence.id) : []; if (inscriptionSequence && inscriptionApprenant && inscriptionFormation && dates.length > 0) { // Utiliser la première date pour l'email principal const premiereDate = dates[0]; await sendInscriptionConfirmation({ apprenantEmail: inscriptionApprenant.email, apprenantNom: inscriptionApprenant.nom, apprenantPrenom: inscriptionApprenant.prenom, apprenantFonction: inscriptionApprenant.fonction || '', formationNom: inscriptionFormation.nom, sequenceNom: inscriptionSequence.nom, dates: dates.map(d => ({ dateDebut: new Date(d.dateDebut), dateFin: new Date(d.dateFin), ordre: d.ordre, })), lieu: inscriptionSequence.lieu, statut, }); // Notification au formateur si présent if (inscriptionSequence.formateurId) { const formateur = await db.getFormateurById(inscriptionSequence.formateurId); if (formateur && formateur.email) { const nbInscritsActuel = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee'); try { await sendNotificationFormateurNouvelleInscription({ formateurEmail: formateur.email, formateurNom: formateur.nom, apprenantNom: inscriptionApprenant.nom, apprenantPrenom: inscriptionApprenant.prenom, apprenantFonction: inscriptionApprenant.fonction || '', apprenantEtablissement: inscriptionApprenant.codeEtablissement || '', formationNom: inscriptionFormation.nom, sequenceNom: inscriptionSequence.nom, nbInscrits: nbInscritsActuel, capaciteMax: inscriptionSequence.capaciteMax, dates: dates.map(d => ({ dateDebut: new Date(d.dateDebut), dateFin: new Date(d.dateFin), 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: 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); } } } // Alerte capacité atteinte pour les admins ET le formateur const nbInscritsApres = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee'); if (nbInscritsApres >= inscriptionSequence.capaciteMax) { try { const admins = await db.getAdminUsers(); const adminEmails = admins.filter(a => a.email).map(a => a.email!); if (adminEmails.length > 0) { const nbListeAttente = await db.countInscriptionsBySequence(input.sequenceId, 'en_attente'); let formateurNom: string | undefined; if (inscriptionSequence.formateurId) { const formateur = await db.getFormateurById(inscriptionSequence.formateurId); formateurNom = formateur?.nom; } await sendAlerteCapaciteAtteinte({ adminEmails, formationNom: inscriptionFormation.nom, sequenceNom: inscriptionSequence.nom, capaciteMax: inscriptionSequence.capaciteMax, nbInscrits: nbInscritsApres, nbListeAttente, formateurNom, dates: dates.map(d => ({ dateDebut: new Date(d.dateDebut), dateFin: new Date(d.dateFin), ordre: d.ordre, })), }); console.log(`[Notification] Alerte capacité atteinte envoyée à ${adminEmails.length} admin(s)`); } // Notification au formateur pour capacité atteinte if (inscriptionSequence.formateurId) { const formateur = await db.getFormateurById(inscriptionSequence.formateurId); if (formateur && formateur.email) { const { sendNotificationFormateurCapaciteAtteinte } = await import('./emailService'); await sendNotificationFormateurCapaciteAtteinte({ formateurEmail: formateur.email, formateurNom: formateur.nom, formationNom: inscriptionFormation.nom, sequenceNom: inscriptionSequence.nom, capaciteMax: inscriptionSequence.capaciteMax, nbInscrits: nbInscritsApres, dates: dates.map(d => ({ dateDebut: new Date(d.dateDebut), dateFin: new Date(d.dateFin), ordre: d.ordre, })), }); console.log(`[Notification] Alerte capacité atteinte envoyée au formateur ${formateur.email}`); } } } catch (e) { console.error(`[Notification] Erreur envoi alerte capacité:`, e); } } } return { success: true, statut }; }), desinscrire: publicProcedure.input(z.object({ apprenantId: z.number(), sequenceId: z.number(), })).mutation(async ({ input }) => { // Vérifier si la séquence existe const sequence = await db.getSequenceById(input.sequenceId); if (!sequence) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); } // Vérifier si la séquence est bloquée const now = new Date(); if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Les désinscriptions sont fermées pour cette séquence (J-15 dépassé)' }); } // Trouver l'inscription const inscription = await db.checkExistingInscription(input.apprenantId, input.sequenceId); if (!inscription) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Inscription introuvable' }); } // Récupérer les infos avant la mise à jour pour les notifications const apprenant = await db.getApprenantById(input.apprenantId); const formation = await db.getFormationById(sequence.formationId); await db.updateInscription(inscription.id, { statut: 'annulee' }); // Notifications après désinscription if (apprenant && formation) { // Notification au formateur if (sequence.formateurId) { const formateur = await db.getFormateurById(sequence.formateurId); if (formateur && formateur.email) { const nbInscritsApres = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee'); try { await sendNotificationFormateurAnnulation({ formateurEmail: formateur.email, formateurNom: formateur.nom, apprenantNom: apprenant.nom, apprenantPrenom: apprenant.prenom, apprenantFonction: apprenant.fonction || '', formationNom: formation.nom, sequenceNom: sequence.nom, 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: 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); } } } // Notifier le premier en liste d'attente qu'une place s'est libérée const inscriptionsListeAttente = await db.getInscriptionsBySequence(input.sequenceId); const premierEnAttente = inscriptionsListeAttente .filter(i => i.inscription.statut === 'en_attente') .sort((a, b) => new Date(a.inscription.dateInscription).getTime() - new Date(b.inscription.dateInscription).getTime())[0]; if (premierEnAttente && premierEnAttente.apprenant) { try { await sendNotificationPlaceDisponible({ apprenantEmail: premierEnAttente.apprenant.email, apprenantNom: premierEnAttente.apprenant.nom, apprenantPrenom: premierEnAttente.apprenant.prenom, apprenantFonction: premierEnAttente.apprenant.fonction || '', formationNom: formation.nom, sequenceNom: sequence.nom, 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: 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); } } } return { success: true }; }), updateStatut: adminProcedure.input(z.object({ id: z.number(), statut: z.enum(['confirmee', 'en_attente', 'annulee']), })).mutation(async ({ input }) => { // Récupérer l'inscription avant modification const inscriptionAvant = await db.getInscriptionById(input.id); const ancienStatut = inscriptionAvant?.statut; await db.updateInscription(input.id, { statut: input.statut }); // Si passage à annulée, notifier le formateur if (input.statut === 'annulee' && ancienStatut !== 'annulee' && inscriptionAvant) { const sequence = await db.getSequenceById(inscriptionAvant.sequenceId); const apprenant = await db.getApprenantById(inscriptionAvant.apprenantId); if (sequence && apprenant && sequence.formateurId) { const formateur = await db.getFormateurById(sequence.formateurId); const formation = await db.getFormationById(sequence.formationId); if (formateur && formateur.email && formation) { const nbInscritsApres = await db.countInscriptionsBySequence(sequence.id, 'confirmee'); try { await sendNotificationFormateurAnnulation({ formateurEmail: formateur.email, formateurNom: formateur.nom, apprenantNom: apprenant.nom, apprenantPrenom: apprenant.prenom, apprenantFonction: apprenant.fonction || '', formationNom: formation.nom, sequenceNom: sequence.nom, nbInscrits: nbInscritsApres, capaciteMax: sequence.capaciteMax, }); console.log(`[Notification] Annulation envoyée au formateur ${formateur.email}`); // Si une place se libère (passage de capacité max à disponible) if (nbInscritsApres < sequence.capaciteMax && (nbInscritsApres + 1) >= sequence.capaciteMax) { const { sendNotificationFormateurPlaceDisponible } = await import('./emailService'); await sendNotificationFormateurPlaceDisponible({ formateurEmail: formateur.email, formateurNom: formateur.nom, formationNom: formation.nom, sequenceNom: sequence.nom, capaciteMax: sequence.capaciteMax, nbInscrits: nbInscritsApres, placesDisponibles: sequence.capaciteMax - nbInscritsApres, }); console.log(`[Notification] Place disponible envoyée au formateur ${formateur.email}`); } } catch (e) { console.error(`[Notification] Erreur envoi notification formateur:`, e); } } } } return { success: true }; }), sendGroupEmail: adminProcedure.input(z.object({ sequenceId: z.number(), type: z.enum(['teaser', 'rappel1', 'rappel2', 'rappel3', 'rappel4', 'rappel5', 'rappel6']) })).mutation(async ({ input }) => { const sequence = await db.getSequenceById(input.sequenceId); if (!sequence) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); } const formation = await db.getFormationById(sequence.formationId); if (!formation) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' }); } const dates = await db.getDatesBySequence(sequence.id); if (dates.length === 0) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune date trouvée pour cette séquence' }); } const inscriptions = await db.getInscriptionsBySequence(input.sequenceId); const recipients = inscriptions .filter(i => i.inscription.statut === 'confirmee' && i.apprenant) .map(i => ({ email: i.apprenant!.email, prenom: i.apprenant!.prenom, nom: i.apprenant!.nom, fonction: i.apprenant!.fonction, })); console.log(`[Email Group] Envoi de ${recipients.length} emails de type ${input.type}`); console.log(`[Email Group] Destinataires:`, recipients.map(r => r.email)); let formateurNom: string | undefined; if (sequence.formateurId) { const formateur = await db.getFormateurById(sequence.formateurId); formateurNom = formateur?.nom; } const result = await sendGroupEmail({ recipients, formationNom: formation.nom, sequenceNom: sequence.nom, type: input.type, dates: dates.map(d => ({ dateDebut: new Date(d.dateDebut), dateFin: new Date(d.dateFin), ordre: d.ordre, })), lieu: sequence.lieu, formateur: formateurNom, }); console.log(`[Email Group] Résultat: ${result.sent} envoyés, ${result.failed} échoués`); return { ...result, message: `${result.sent} email(s) envoyé(s) avec succès, ${result.failed} échoué(s)` }; }), emailPreview: adminProcedure .input(z.object({ sequenceId: z.number(), type: z.enum(['teaser', 'rappel1', 'rappel2', 'rappel3', 'rappel4', 'rappel5', 'rappel6']), apprenantId: z.number().optional(), })) .query(async ({ input }) => { const { generateEmailPreview } = await import('./emailPreview'); return await generateEmailPreview(input); }), exportExcel: protectedProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => { const sequence = await db.getSequenceById(input.sequenceId); if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); const formation = await db.getFormationById(sequence.formationId); if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' }); const inscriptions = await db.getInscriptionsBySequence(input.sequenceId); const dates = await db.getDatesBySequence(input.sequenceId); const data = inscriptions .filter(i => i.apprenant) .map(i => ({ nom: i.apprenant!.nom, prenom: i.apprenant!.prenom, email: i.apprenant!.email, codeEtablissement: i.apprenant!.codeEtablissement, fonction: i.apprenant!.fonction, statut: i.inscription.statut, dateInscription: i.inscription.dateInscription, })); const buffer = await generateExcelExport({ formationNom: formation.nom, sequenceNom: sequence.nom, dates: dates.map(d => ({ dateDebut: new Date(d.dateDebut), dateFin: new Date(d.dateFin), ordre: d.ordre, })), lieu: sequence.lieu, publicCible: sequence.publicCible, inscriptions: data, }); return { buffer: buffer.toString('base64'), filename: `inscriptions_${sequence.nom.replace(/\s+/g, '_')}.xlsx`, }; }), exportPDF: protectedProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => { const sequence = await db.getSequenceById(input.sequenceId); if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); const formation = await db.getFormationById(sequence.formationId); if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' }); const inscriptions = await db.getInscriptionsBySequence(input.sequenceId); const dates = await db.getDatesBySequence(input.sequenceId); const data = inscriptions .filter(i => i.apprenant) .map(i => ({ nom: i.apprenant!.nom, prenom: i.apprenant!.prenom, email: i.apprenant!.email, codeEtablissement: i.apprenant!.codeEtablissement, fonction: i.apprenant!.fonction, statut: i.inscription.statut, dateInscription: i.inscription.dateInscription, })); const buffer = await generatePDFExport({ formationNom: formation.nom, sequenceNom: sequence.nom, dates: dates.map(d => ({ dateDebut: new Date(d.dateDebut), dateFin: new Date(d.dateFin), ordre: d.ordre, })), lieu: sequence.lieu, publicCible: sequence.publicCible, inscriptions: data, }); return { buffer: buffer.toString('base64'), filename: `inscriptions_${sequence.nom.replace(/\s+/g, '_')}.pdf`, }; }), exportFeuillePresence: protectedProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => { const sequence = await db.getSequenceById(input.sequenceId); if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); const formation = await db.getFormationById(sequence.formationId); if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' }); const inscriptions = await db.getInscriptionsBySequence(input.sequenceId); const dates = await db.getDatesBySequence(input.sequenceId); // Récupérer le formateur si défini let formateurNom: string | undefined = undefined; if (sequence.formateurId) { const formateur = await db.getFormateurById(sequence.formateurId); if (formateur) { formateurNom = formateur.nom; } } // Récupérer toutes les présences avec signatures pour cette séquence const { getPresencesBySequence } = await import("./presenceDb"); const presences = await getPresencesBySequence(input.sequenceId); const data = inscriptions .filter(i => i.inscription.statut === 'confirmee' && i.apprenant) .map(i => { // Récupérer les présences de cet apprenant const apprenantPresences = presences .filter(p => p.inscriptionId === i.inscription.id) .map(p => ({ dateFormationId: p.dateFormationId, periode: p.periode as 'matin' | 'apres_midi', signatureUrl: p.signatureUrl, })); return { nom: i.apprenant!.nom, prenom: i.apprenant!.prenom, codeEtablissement: i.apprenant!.codeEtablissement, fonction: i.apprenant!.fonction, presences: apprenantPresences, }; }); const buffer = await generateFeuillePresence({ formationNom: formation.nom, sequenceNom: sequence.nom, dates: dates.map(d => ({ id: d.id, dateDebut: new Date(d.dateDebut), dateFin: new Date(d.dateFin), ordre: d.ordre, })), lieu: sequence.lieu, publicCible: sequence.publicCible, formateur: formateurNom, apprenants: data, }); return { buffer: buffer.toString('base64'), filename: `feuille_presence_${sequence.nom.replace(/\s+/g, '_')}.pdf`, }; }), // Ajout manuel d'un inscrit par l'admin (sans vérifications de blocage/capacité) create: adminProcedure.input(z.object({ apprenantId: z.number(), sequenceId: z.number(), statut: z.enum(["confirme", "liste_attente"]), })).mutation(async ({ input }) => { // Vérifier si l'apprenant existe const apprenant = await db.getApprenantById(input.apprenantId); if (!apprenant) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Apprenant introuvable' }); } // Vérifier si la séquence existe const sequence = await db.getSequenceById(input.sequenceId); if (!sequence) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); } // Vérifier si l'apprenant est déjà inscrit const existing = await db.checkExistingInscription(input.apprenantId, input.sequenceId); if (existing) { throw new TRPCError({ code: 'CONFLICT', message: 'Cet apprenant est déjà inscrit à cette séquence' }); } // Créer l'inscription avec le statut choisi await db.createInscription({ apprenantId: input.apprenantId, sequenceId: input.sequenceId, statut: input.statut === "confirme" ? "confirmee" : "en_attente", }); return { success: true }; }), }), // ===== GESTION DES UTILISATEURS ===== users: router({ list: adminProcedure.query(async () => { return db.getAllUsers(); }), listWithFormateurs: adminProcedure.query(async () => { const dbInstance = await import("./db").then(m => m.getDb()); if (!dbInstance) throw new Error("Base de données non disponible"); const { formateurs, users } = await import("../drizzle/schema"); const { eq } = await import("drizzle-orm"); // Récupérer tous les formateurs const allFormateurs = await dbInstance.select().from(formateurs); // Récupérer tous les utilisateurs const allUsers = await dbInstance.select().from(users); // Créer une map des formateurs liés à des comptes const formateurIdToUser = new Map(); allUsers.forEach(user => { if (user.formateurId) { formateurIdToUser.set(user.formateurId, user); } }); // Combiner les données return { users: allUsers, formateurs: allFormateurs.map(formateur => ({ ...formateur, hasAccount: formateurIdToUser.has(formateur.id), user: formateurIdToUser.get(formateur.id) || null, })), }; }), getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { return db.getUserById(input.id); }), create: adminProcedure.input(z.object({ openId: z.string().min(1), name: z.string().optional(), email: z.string().email().optional(), username: z.string().min(3).optional(), password: z.string().min(6).optional(), role: z.enum(["user", "admin", "formateur", "super_formateur"]).default("user"), isActive: z.boolean().default(true), formateurId: z.number().optional(), })).mutation(async ({ input }) => { await db.createUser(input); return { success: true }; }), update: adminProcedure.input(z.object({ id: z.number(), name: z.string().optional(), email: z.string().email().optional(), username: z.string().min(3).optional(), password: z.string().min(6).optional(), role: z.enum(["user", "admin", "formateur", "super_formateur"]).optional(), isActive: z.boolean().optional(), formateurId: z.number().optional(), })).mutation(async ({ input }) => { const { id, ...data } = input; await db.updateUser(id, data); return { success: true }; }), toggleStatus: adminProcedure.input(z.object({ id: z.number(), isActive: z.boolean(), })).mutation(async ({ input }) => { await db.toggleUserStatus(input.id, input.isActive); return { success: true }; }), delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => { await db.deleteUser(input.id); return { success: true }; }), requestPasswordReset: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => { const user = await db.getUserById(input.id); if (!user) { throw new Error("Utilisateur introuvable"); } if (!user.email) { throw new Error("Cet utilisateur n'a pas d'adresse email"); } // Générer un token unique const crypto = await import('crypto'); const token = crypto.randomBytes(32).toString('hex'); // Sauvegarder le token en base (la fonction gère l'expiration automatiquement) await db.createPasswordResetToken(user.email, token); // Générer le lien de réinitialisation // TODO: Remplacer par l'URL réelle de votre application en production const resetLink = `https://votre-domaine.com/reset-password?token=${token}`; // Envoyer l'email const emailService = await import('./emailService'); const emailSent = await emailService.sendPasswordResetEmail({ apprenantEmail: user.email, apprenantNom: user.name || 'Utilisateur', apprenantPrenom: '', resetLink, }); if (!emailSent) { throw new Error("Échec de l'envoi de l'email"); } return { success: true, message: "Email de réinitialisation envoyé" }; }), }), // ===== GESTION DES TEMPLATES D'EMAILS ===== emailTemplates: router({ list: adminProcedure.query(async () => { return db.getAllEmailTemplates(); }), getByType: adminProcedure .input(z.object({ type: z.string() })) .query(async ({ input }) => { return db.getEmailTemplateByType(input.type); }), upsert: adminProcedure .input(z.object({ type: z.enum(["inscription", "teaser", "rappel1", "rappel2", "rappel3", "rappel4", "rappel5", "rappel6", "reset_password", "attestation"]), titre: z.string(), bodyContent: z.string(), couleurPrincipale: z.string(), couleurSecondaire: z.string(), piedDePage: z.string().nullable(), })) .mutation(async ({ input }) => { return db.upsertEmailTemplate(input); }), delete: adminProcedure .input(z.object({ type: z.string() })) .mutation(async ({ input }) => { await db.deleteEmailTemplate(input.type); return { success: true }; }), initializeDefaults: adminProcedure.mutation(async () => { await db.initializeDefaultEmailTemplates(); return { success: true }; }), }), // ===== GESTION DES RAPPELS ===== rappels: router({ list: adminProcedure.query(async () => { return db.getRappels(); }), create: adminProcedure .input(z.object({ nom: z.string(), templateType: z.enum(["rappel1", "rappel2", "rappel3", "rappel4", "rappel5", "rappel6"]), timing: z.enum(["pre_formation", "post_formation"]).default("pre_formation"), joursAvant: z.number(), heureEnvoi: z.string().default("09:00"), actif: z.boolean(), dateFormationIds: z.array(z.number()).optional(), fichier: z.object({ nomFichier: z.string(), urlFichier: z.string(), s3Key: z.string(), typeFichier: z.string(), tailleFichier: z.number(), }).nullable().optional(), fichier2: z.object({ nomFichier: z.string(), urlFichier: z.string(), s3Key: z.string(), typeFichier: z.string(), tailleFichier: z.number(), }).nullable().optional(), })) .mutation(async ({ input }) => { const { dateFormationIds, ...rappelData } = input; const result: any = await db.createRappel(rappelData); // Créer les associations rappel-date si des dates sont spécifiées if (dateFormationIds && dateFormationIds.length > 0) { const rappelId = result.insertId || result[0]?.insertId; if (rappelId) { await db.createRappelDates(Number(rappelId), dateFormationIds); } } return result; }), update: adminProcedure .input(z.object({ id: z.number(), nom: z.string().optional(), templateType: z.enum(["rappel1", "rappel2", "rappel3", "rappel4", "rappel5", "rappel6"]).optional(), timing: z.enum(["pre_formation", "post_formation"]).optional(), joursAvant: z.number().optional(), heureEnvoi: z.string().optional(), actif: z.boolean().optional(), dateFormationIds: z.array(z.number()).optional(), fichier: z.object({ nomFichier: z.string(), urlFichier: z.string(), s3Key: z.string(), typeFichier: z.string(), tailleFichier: z.number(), }).nullable().optional(), fichier2: z.object({ nomFichier: z.string(), urlFichier: z.string(), s3Key: z.string(), typeFichier: z.string(), tailleFichier: z.number(), }).nullable().optional(), })) .mutation(async ({ input }) => { const { id, dateFormationIds, ...data } = input; await db.updateRappel(id, data); // Mettre à jour les associations rappel-date si spécifiées if (dateFormationIds !== undefined) { await db.deleteRappelDates(id); if (dateFormationIds.length > 0) { await db.createRappelDates(id, dateFormationIds); } } return { success: true }; }), delete: adminProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { await db.deleteRappel(input.id); return { success: true }; }), toggleActif: adminProcedure .input(z.object({ id: z.number(), actif: z.boolean() })) .mutation(async ({ input }) => { await db.updateRappel(input.id, { actif: input.actif }); return { success: true }; }), envoyerAutomatiques: publicProcedure .mutation(async () => { const { processRappelsAutomatiques } = await import('./rappelScheduler'); const result = await processRappelsAutomatiques(); return result; }), testerRappel: adminProcedure .input(z.object({ sequenceId: z.number(), emailTest: z.string().email().optional(), })) .mutation(async ({ input }) => { const sequence = await db.getSequenceById(input.sequenceId); if (!sequence) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' }); } const formation = await db.getFormationById(sequence.formationId); if (!formation) { throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' }); } // Récupérer le formateur si présent let formateurNom: string | undefined; if (sequence.formateurId) { const formateur = await db.getFormateurById(sequence.formateurId); formateurNom = formateur?.nom; } // Récupérer les dates de la séquence const dates = await db.getDatesBySequence(input.sequenceId); // Récupérer les rappels configurés pour la première date de la séquence if (dates.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cette séquence n\'a pas de dates configurées' }); } const rappelsConfigures = await db.getRappelsByDateFormation(dates[0].id); const rappelsActifs = rappelsConfigures.filter((r: any) => r.actif); if (rappelsActifs.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Aucun rappel actif configuré pour cette séquence' }); } // Utiliser le premier rappel actif trouvé const rappelAUtiliser = rappelsActifs[0]; // Récupérer les inscrits const inscrits = await db.getInscriptionsBySequence(input.sequenceId); const inscritsConfirmes = inscrits.filter(i => i.inscription.statut === 'confirmee'); if (inscritsConfirmes.length === 0) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Aucun apprenant inscrit confirmé pour cette séquence' }); } // Envoyer le rappel de test const { sendRappelEmail } = await import('./emailService'); let nbEnvoyes = 0; let nbEchecs = 0; const erreurs: string[] = []; // Si un email de test est fourni, envoyer un email par inscrit confirmé à cet email // Sinon, envoyer à l'email réel de chaque inscrit const destinataires = input.emailTest ? inscritsConfirmes.map(i => ({ apprenant: i.apprenant, emailOverride: input.emailTest })) : inscritsConfirmes.map(i => ({ apprenant: i.apprenant, emailOverride: null })); for (const { apprenant, emailOverride } of destinataires) { if (!apprenant || (!apprenant.email && !emailOverride)) { nbEchecs++; erreurs.push(`Apprenant ${apprenant?.nom || 'inconnu'}: email manquant`); continue; } const emailDestinataire = emailOverride || apprenant.email; try { await sendRappelEmail({ apprenantEmail: emailDestinataire, apprenantPrenom: apprenant.prenom, apprenantNom: apprenant.nom, apprenantFonction: apprenant.fonction || '', formationNom: formation.nom, sequenceNom: sequence.nom, dates: dates.map((d: any) => ({ dateDebut: d.dateDebut, dateFin: d.dateFin, ordre: d.ordre })), lieu: sequence.lieu || '', formateur: formateurNom, templateType: rappelAUtiliser.templateType, attachmentUrl: rappelAUtiliser.urlFichier || undefined, attachmentFilename: rappelAUtiliser.nomFichier || undefined, attachmentMimeType: rappelAUtiliser.typeFichier || undefined, attachmentUrl2: rappelAUtiliser.urlFichier2 || undefined, attachmentFilename2: rappelAUtiliser.nomFichier2 || undefined, attachmentMimeType2: rappelAUtiliser.typeFichier2 || undefined, }); // Enregistrer dans l'historique avec typeEnvoi = 'test' await db.createLogRappel({ rappelId: rappelAUtiliser.id, sequenceId: input.sequenceId, apprenantId: apprenant.id, email: emailDestinataire, type: rappelAUtiliser.templateType, typeEnvoi: 'test', statut: 'succes', dateEnvoi: new Date(), }); nbEnvoyes++; } catch (error: any) { nbEchecs++; erreurs.push(`${apprenant.email}: ${error.message}`); // Enregistrer l'échec dans l'historique try { await db.createLogRappel({ rappelId: rappelAUtiliser.id, sequenceId: input.sequenceId, apprenantId: apprenant.id, email: emailDestinataire, type: rappelAUtiliser.templateType, typeEnvoi: 'test', statut: 'echec', messageErreur: error.message, dateEnvoi: new Date(), }); } catch (logError) { console.error('[LogRappel] Erreur lors de l\'enregistrement du log:', logError); } } } if (nbEnvoyes === 0) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Aucun email envoyé. Erreurs: ${erreurs.join(', ')}`, }); } return { success: true, message: `Rappel de test envoyé à ${nbEnvoyes} apprenant(s)${nbEchecs > 0 ? ` (${nbEchecs} échec(s))` : ''}`, nbEnvoyes, nbEchecs, erreurs: nbEchecs > 0 ? erreurs : undefined, }; }), historique: adminProcedure .input(z.object({ dateDebut: z.string().optional(), dateFin: z.string().optional(), sequenceId: z.number().optional(), statut: z.enum(['succes', 'echec']).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(); }), // Nettoyer les anciens logs erronés (avant la correction du schéma) nettoyerLogsErrones: adminProcedure .mutation(async () => { const database = await db.getDb(); if (!database) throw new Error("Database not available"); // Supprimer tous les logs qui n'ont pas d'email (champ obligatoire manquant) const result = await database.execute(sql` DELETE FROM logsRappels WHERE email IS NULL OR email = '' `); const nbSupprimes = Array.isArray(result) ? 0 : (result as any).affectedRows || 0; return { success: true, nbSupprimes, message: `${nbSupprimes} log(s) erroné(s) supprimé(s)`, }; }), // Vider complètement la table des logs de rappels viderHistoriqueRappels: adminProcedure .mutation(async () => { const database = await db.getDb(); if (!database) throw new Error("Database not available"); // Supprimer tous les logs de rappels const result = await database.execute(sql` DELETE FROM logsrappels `); const nbSupprimes = Array.isArray(result) ? 0 : (result as any).affectedRows || 0; return { success: true, nbSupprimes, message: `${nbSupprimes} log(s) supprimé(s) de l'historique`, }; }), }), emailConfig: router({ get: adminProcedure.query(async () => { return db.getActiveEmailConfig(); }), upsert: adminProcedure .input(z.object({ provider: z.enum(["resend", "smtp", "simulation"]), apiKey: z.string().nullable(), fromEmail: z.string().email(), fromName: z.string(), mode: z.enum(["simulation", "production"]), domainVerified: z.boolean(), // Champs SMTP smtpHost: z.string().nullable().optional(), smtpPort: z.number().nullable().optional(), smtpSecure: z.enum(["none", "tls", "ssl"]).nullable().optional(), smtpUser: z.string().nullable().optional(), smtpPassword: z.string().nullable().optional(), })) .mutation(async ({ input }) => { await db.upsertEmailConfig(input); return { success: true }; }), testEmail: adminProcedure .input(z.object({ toEmail: z.string().email(), })) .mutation(async ({ input }) => { // Importer le service d'envoi const { sendEmail } = await import("./_core/emailSender"); try { const success = await sendEmail({ to: input.toEmail, subject: "Test d'envoi d'email - Formation Manager Itinova", html: `

Test réussi !

Cet email de test a été envoyé avec succès depuis votre configuration SMTP.

Votre configuration d'envoi d'emails fonctionne correctement.

`, }); return { success, message: success ? "Email envoyé avec succès" : "Échec de l'envoi" }; } catch (error: any) { return { success: false, message: error.message }; } }), }), // ===== PARAMÈTRES DE L'APPLICATION ===== parametres: router({ get: adminProcedure.query(async () => { const { getParametres } = await import("./parametresDb"); return getParametres(); }), update: adminProcedure .input(z.object({ urlPublique: z.string().url().optional(), delaiExpirationQR: z.number().int().positive().optional(), dureeValiditeToken: z.number().int().positive().optional(), notificationsActives: z.boolean().optional(), })) .mutation(async ({ input, ctx }) => { const { updateParametres } = await import("./parametresDb"); await updateParametres(input, ctx.user.id, ctx.user.name || "Administrateur"); return { success: true }; }), updateUrlPublique: adminProcedure .input(z.object({ urlPublique: z.string().url(), })) .mutation(async ({ input, ctx }) => { const { updateUrlPublique } = await import("./parametresDb"); await updateUrlPublique(input.urlPublique, ctx.user.id, ctx.user.name || "Administrateur"); return { success: true }; }), testerUrl: adminProcedure .input(z.object({ url: z.string(), })) .mutation(async ({ input }) => { const { testerUrl } = await import("./parametresDb"); return testerUrl(input.url); }), getHistorique: adminProcedure .input(z.object({ limit: z.number().int().positive().default(50), })) .query(async ({ input }) => { const { getHistoriqueParametres } = await import("./parametresDb"); return getHistoriqueParametres(input.limit); }), }), // ===== ANALYTICS ===== analytics: router({ globalStats: adminProcedure.query(async () => { return analyticsDb.getGlobalStats(); }), inscriptionsByMonth: adminProcedure .input(z.object({ startDate: z.string().optional(), endDate: z.string().optional(), })) .query(async ({ input }) => { const startDate = input.startDate ? new Date(input.startDate) : undefined; const endDate = input.endDate ? new Date(input.endDate) : undefined; return analyticsDb.getInscriptionsByMonth(startDate, endDate); }), tauxRemplissageByMonth: adminProcedure.query(async () => { return analyticsDb.getTauxRemplissageByMonth(); }), participationByEtablissement: adminProcedure.query(async () => { return analyticsDb.getParticipationByEtablissement(); }), participationByFonction: adminProcedure.query(async () => { return analyticsDb.getParticipationByFonction(); }), exportExcel: adminProcedure .input(z.object({ startDate: z.string().optional(), endDate: z.string().optional(), })) .mutation(async ({ input }) => { const startDate = input.startDate ? new Date(input.startDate) : undefined; const endDate = input.endDate ? new Date(input.endDate) : undefined; const buffer = await generateAnalyticsExcel(startDate, endDate); return { data: buffer.toString('base64'), filename: `rapport-analytique-${new Date().toISOString().split('T')[0]}.xlsx`, }; }), exportPDF: adminProcedure .input(z.object({ startDate: z.string().optional(), endDate: z.string().optional(), })) .mutation(async ({ input }) => { const startDate = input.startDate ? new Date(input.startDate) : undefined; const endDate = input.endDate ? new Date(input.endDate) : undefined; const buffer = await generateAnalyticsPDF(startDate, endDate); return { data: buffer.toString('base64'), filename: `rapport-analytique-${new Date().toISOString().split('T')[0]}.pdf`, }; }), }), // ===== QUESTIONNAIRES ===== questionnaires: router({ list: adminProcedure.query(async () => { const questionnaireDb = await import("./questionnaireDb"); return questionnaireDb.getAllQuestionnaires(); }), getById: adminProcedure .input(z.object({ id: z.number() })) .query(async ({ input }) => { const questionnaireDb = await import("./questionnaireDb"); return questionnaireDb.getQuestionnaireById(input.id); }), create: adminProcedure .input(z.object({ titre: z.string(), type: z.enum(["satisfaction", "evaluation_pre", "evaluation_post"]), description: z.string().optional(), formationId: z.number().nullable().optional(), actif: z.boolean().default(true), envoiAutomatique: z.boolean().default(false), delaiEnvoiJours: z.number().default(1), })) .mutation(async ({ input }) => { const questionnaireDb = await import("./questionnaireDb"); const id = await questionnaireDb.createQuestionnaire(input); return { id, success: true }; }), update: adminProcedure .input(z.object({ id: z.number(), titre: z.string().optional(), type: z.enum(["satisfaction", "evaluation_pre", "evaluation_post"]).optional(), description: z.string().optional(), formationId: z.number().nullable().optional(), actif: z.boolean().optional(), envoiAutomatique: z.boolean().optional(), delaiEnvoiJours: z.number().optional(), })) .mutation(async ({ input }) => { const { id, ...data } = input; const questionnaireDb = await import("./questionnaireDb"); await questionnaireDb.updateQuestionnaire(id, data); return { success: true }; }), delete: adminProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { const questionnaireDb = await import("./questionnaireDb"); await questionnaireDb.deleteQuestionnaire(input.id); return { success: true }; }), getStats: adminProcedure .input(z.object({ id: z.number() })) .query(async ({ input }) => { const questionnaireDb = await import("./questionnaireDb"); return questionnaireDb.getQuestionnaireStats(input.id); }), getReponsesDetaillees: adminProcedure .input(z.object({ id: z.number() })) .query(async ({ input }) => { const questionnaireDb = await import("./questionnaireDb"); return questionnaireDb.getReponsesDetailleesQuestionnaire(input.id); }), exportExcel: adminProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { const { exportQuestionnaireToExcel } = await import("./questionnaireExport"); const buffer = await exportQuestionnaireToExcel(input.id); return { data: buffer.toString('base64'), filename: `questionnaire-${input.id}-${new Date().toISOString().split('T')[0]}.xlsx`, }; }), exportPDF: adminProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { const { exportQuestionnaireToPDF } = await import("./questionnaireExport"); const buffer = await exportQuestionnaireToPDF(input.id); return { data: buffer.toString('base64'), filename: `questionnaire-${input.id}-${new Date().toISOString().split('T')[0]}.pdf`, }; }), }), // ===== QUESTIONS ===== questions: router({ list: adminProcedure .input(z.object({ questionnaireId: z.number() })) .query(async ({ input }) => { const questionnaireDb = await import("./questionnaireDb"); return questionnaireDb.getQuestionsByQuestionnaireId(input.questionnaireId); }), create: adminProcedure .input(z.object({ questionnaireId: z.number(), ordre: z.number(), texte: z.string(), typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]), options: z.string().optional(), echelleMin: z.number().optional(), echelleMax: z.number().optional(), echelleLabelMin: z.string().optional(), echelleLabelMax: z.string().optional(), obligatoire: z.boolean().default(false), })) .mutation(async ({ input }) => { const questionnaireDb = await import("./questionnaireDb"); const id = await questionnaireDb.createQuestion(input); return { id, success: true }; }), update: adminProcedure .input(z.object({ id: z.number(), ordre: z.number().optional(), texte: z.string().optional(), typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]).optional(), options: z.string().optional(), echelleMin: z.number().optional(), echelleMax: z.number().optional(), echelleLabelMin: z.string().optional(), echelleLabelMax: z.string().optional(), obligatoire: z.boolean().optional(), })) .mutation(async ({ input }) => { const { id, ...data } = input; const questionnaireDb = await import("./questionnaireDb"); await questionnaireDb.updateQuestion(id, data); return { success: true }; }), delete: adminProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { const questionnaireDb = await import("./questionnaireDb"); await questionnaireDb.deleteQuestion(input.id); return { success: true }; }), }), // ===== RÉPONSES QUESTIONNAIRES (PUBLIC) ===== reponses: router({ getByToken: publicProcedure .input(z.object({ token: z.string() })) .query(async ({ input }) => { const questionnaireDb = await import("./questionnaireDb"); const envoi = await questionnaireDb.getEnvoiByToken(input.token); if (!envoi) return null; const questionnaire = await questionnaireDb.getQuestionnaireById(envoi.questionnaireId); const questions = await questionnaireDb.getQuestionsByQuestionnaireId(envoi.questionnaireId); return { envoi, questionnaire, questions, }; }), submit: publicProcedure .input(z.object({ token: z.string(), reponses: z.array(z.object({ questionId: z.number(), reponseNumerique: z.number().optional(), reponseTexte: z.string().optional(), })), })) .mutation(async ({ input }) => { const questionnaireDb = await import("./questionnaireDb"); // Récupérer l'envoi const envoi = await questionnaireDb.getEnvoiByToken(input.token); if (!envoi) throw new Error("Token invalide"); if (envoi.dateReponse) throw new Error("Questionnaire déjà répondu"); // Créer la réponse au questionnaire const reponseQuestionnaireId = await questionnaireDb.createReponseQuestionnaire({ questionnaireId: envoi.questionnaireId, apprenantId: envoi.apprenantId, sequenceId: envoi.sequenceId, }); // Enregistrer chaque réponse for (const reponse of input.reponses) { await questionnaireDb.createReponseQuestion({ reponseQuestionnaireId, questionId: reponse.questionId, reponseNumerique: reponse.reponseNumerique, reponseTexte: reponse.reponseTexte, }); } // Mettre à jour l'envoi await questionnaireDb.updateEnvoiQuestionnaire(envoi.id, { dateReponse: new Date(), }); // Envoyer une notification à l'administrateur try { const { notifyOwner } = await import('./_core/notification'); const questionnaire = await questionnaireDb.getQuestionnaireById(envoi.questionnaireId); await notifyOwner({ title: 'Nouvelle réponse au questionnaire', content: `Un apprenant a répondu au questionnaire "${questionnaire?.titre || 'Sans titre'}"`, }); } catch (error) { console.error('[Notification] Erreur lors de l\'envoi de la notification:', error); // Ne pas bloquer la réponse si la notification échoue } return { success: true }; }), envoyerAutomatiques: adminProcedure .mutation(async () => { const { processEnvoisAutomatiques } = await import('./questionnaireScheduler'); const result = await processEnvoisAutomatiques(); return result; }), }), // ===== SUIVI QUESTIONNAIRES ===== questionnaireSuivi: router({ statsGlobales: adminProcedure.query(async () => { const suiviDb = await import("./questionnaireSuiviDb"); return suiviDb.getStatsGlobales(); }), statsByFormation: adminProcedure.query(async () => { const suiviDb = await import("./questionnaireSuiviDb"); return suiviDb.getStatsByFormation(); }), evolutionTemporelle: adminProcedure .input(z.object({ startDate: z.date().optional(), endDate: z.date().optional(), })) .query(async ({ input }) => { const suiviDb = await import("./questionnaireSuiviDb"); return suiviDb.getEvolutionTemporelle(input.startDate, input.endDate); }), statsByFormateur: adminProcedure.query(async () => { const suiviDb = await import("./questionnaireSuiviDb"); return suiviDb.getStatsByFormateur(); }), }), // ===== ESPACE FORMATEUR ===== formateur: router({ calendrier: protectedProcedure .input(z.object({ formateurId: z.number(), dateDebut: z.date().optional(), dateFin: z.date().optional(), })) .query(async ({ input }) => { const formateurDb = await import("./formateurDb"); return formateurDb.getCalendrierFormateur(input.formateurId, input.dateDebut, input.dateFin); }), apprenants: protectedProcedure .input(z.object({ sequenceId: z.number() })) .query(async ({ input }) => { const formateurDb = await import("./formateurDb"); return formateurDb.getApprenantsSequence(input.sequenceId); }), supports: protectedProcedure .input(z.object({ sequenceId: z.number() })) .query(async ({ input }) => { const formateurDb = await import("./formateurDb"); return formateurDb.getSupportsSequence(input.sequenceId); }), ajouterSupport: protectedProcedure .input(z.object({ sequenceId: z.number(), formateurId: z.number(), nomFichier: z.string(), typeFichier: z.string(), tailleFichier: z.number(), urlFichier: z.string(), s3Key: z.string(), description: z.string().optional(), })) .mutation(async ({ input }) => { const formateurDb = await import("./formateurDb"); return formateurDb.ajouterSupport(input); }), supprimerSupport: protectedProcedure .input(z.object({ supportId: z.number(), formateurId: z.number(), })) .mutation(async ({ input }) => { const formateurDb = await import("./formateurDb"); const support = await formateurDb.supprimerSupport(input.supportId, input.formateurId); // Note: La suppression du fichier S3 devrait être gérée par un processus de nettoyage séparé // car il n'y a pas de fonction storageDelete dans l'API actuelle return { success: true, support }; }), validerPresence: protectedProcedure .input(z.object({ inscriptionId: z.number(), present: z.boolean(), })) .mutation(async ({ input }) => { const formateurDb = await import("./formateurDb"); await formateurDb.validerPresence(input.inscriptionId, input.present); return { success: true }; }), historique: protectedProcedure .input(z.object({ formateurId: z.number(), limit: z.number().optional(), })) .query(async ({ input }) => { const formateurDb = await import("./formateurDb"); return formateurDb.getHistoriqueFormateur(input.formateurId, input.limit); }), detailSequence: protectedProcedure .input(z.object({ sequenceId: z.number(), formateurId: z.number(), })) .query(async ({ input }) => { const formateurDb = await import("./formateurDb"); return formateurDb.getDetailSequence(input.sequenceId, input.formateurId); }), }), // ===== ÉTABLISSEMENTS ===== etablissements: router({ list: adminProcedure.query(async () => { const { getEtablissementsStats } = await import("./etablissementsDb"); return getEtablissementsStats(); }), getDetail: adminProcedure .input(z.object({ codeEtablissement: z.string() })) .query(async ({ input }) => { const { getEtablissementDetail } = await import("./etablissementsDb"); return getEtablissementDetail(input.codeEtablissement); }), }), // ===== NOTIFICATIONS ===== notifications: router({ envoyerRemerciements: adminProcedure .input(z.object({ sequenceId: z.number() })) .mutation(async ({ input }) => { const { envoyerRemerciementsSequence } = await import("./remerciementScheduler"); return envoyerRemerciementsSequence(input.sequenceId); }), envoyerRemerciementsAutomatiques: adminProcedure .mutation(async () => { const { processRemerciementsAutomatiques } = await import("./remerciementScheduler"); 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, }); }), }), // Attestations de formation attestations: router({ // Générer une attestation pour une inscription generer: adminProcedure .input(z.object({ inscriptionId: z.number(), })) .mutation(async ({ input }) => { const { genererAttestationPDF, enregistrerAttestation, getAttestation } = await import("./attestationService"); const db = await import("./db").then(m => m.getDb()); if (!db) throw new Error("Base de données non disponible"); // Vérifier si l'attestation existe déjà const { inscriptions } = await import("../drizzle/schema"); const { eq } = await import("drizzle-orm"); const [inscription] = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1); if (!inscription) { throw new Error("Inscription introuvable"); } const attestationExistante = await getAttestation(inscription.apprenantId, inscription.sequenceId); if (attestationExistante) { return { success: true, attestation: attestationExistante, message: "Attestation déjà générée", }; } // Générer le PDF const { s3Key, pdfUrl } = await genererAttestationPDF(input.inscriptionId); // Enregistrer dans la base const attestationId = await enregistrerAttestation( input.inscriptionId, inscription.apprenantId, inscription.sequenceId, s3Key, pdfUrl ); const attestation = await getAttestation(inscription.apprenantId, inscription.sequenceId); return { success: true, attestation, message: "Attestation générée avec succès", }; }), // Récupérer l'attestation d'un apprenant pour une séquence get: publicProcedure .input(z.object({ apprenantId: z.number(), sequenceId: z.number(), })) .query(async ({ input }) => { const { getAttestation } = await import("./attestationService"); return getAttestation(input.apprenantId, input.sequenceId); }), // Récupérer toutes les attestations d'un apprenant listApprenant: publicProcedure .input(z.object({ apprenantId: z.number(), })) .query(async ({ input }) => { const { getAttestationsApprenant } = await import("./attestationService"); return getAttestationsApprenant(input.apprenantId); }), // Récupérer la configuration des attestations getConfig: adminProcedure .query(async () => { const { getOrCreateConfigAttestation } = await import("./attestationService"); return getOrCreateConfigAttestation(); }), // Mettre à jour la configuration des attestations updateConfig: adminProcedure .input(z.object({ nomSignataire: z.string().optional(), fonctionSignataire: z.string().optional(), texteAttestation: z.string().optional(), logoUrl: z.string().optional(), logoS3Key: z.string().optional(), signatureUrl: z.string().optional(), signatureS3Key: z.string().optional(), })) .mutation(async ({ input }) => { const { updateConfigAttestation } = await import("./attestationService"); await updateConfigAttestation(input); return { success: true }; }), // Récupérer l'historique des envois d'attestations historique: adminProcedure .query(async () => { const { getHistoriqueAttestations } = await import("./attestationsDb"); return getHistoriqueAttestations(); }), // Récupérer l'historique pour une séquence spécifique historiqueBySequence: adminProcedure .input(z.object({ sequenceId: z.number(), })) .query(async ({ input }) => { const { getHistoriqueAttestationsBySequence } = await import("./attestationsDb"); return getHistoriqueAttestationsBySequence(input.sequenceId); }), // Générer une prévisualisation du modèle d'attestation previsualiser: adminProcedure .mutation(async () => { const { genererPreviewAttestation } = await import("./attestationService"); return genererPreviewAttestation(); }), // Uploader la signature manuscrite d'un apprenant uploadSignature: publicProcedure .input(z.object({ attestationId: z.number(), signatureDataUrl: z.string(), })) .mutation(async ({ input }) => { const { storagePut } = await import("./storage"); const { getDb } = await import("./db"); const { attestations } = await import("../drizzle/schema"); const { eq } = await import("drizzle-orm"); const db = await getDb(); if (!db) throw new Error("Base de données non disponible"); // Convertir le data URL en buffer const base64Data = input.signatureDataUrl.replace(/^data:image\/png;base64,/, ""); const buffer = Buffer.from(base64Data, "base64"); // Générer un nom de fichier unique const fileName = `signatures/attestation-${input.attestationId}-${Date.now()}.png`; // Uploader vers S3 const { url, key } = await storagePut(fileName, buffer, "image/png"); // Mettre à jour l'attestation dans la base de données await db .update(attestations) .set({ signatureUrl: url, signatureS3Key: key, dateSigned: new Date(), }) .where(eq(attestations.id, input.attestationId)); return { success: true, signatureUrl: url, }; }), }), // ===== PRESENCES (ÉMARGEMENT NUMÉRIQUE) ===== presences: router({ // Générer un QR code pour une séquence generateQRCode: protectedProcedure .input(z.object({ sequenceId: z.number(), })) .mutation(async ({ input, ctx }) => { const { generateQRToken, generateQRCodeDataURL } = await import("./qrCodeGenerator"); const { getDb } = await import("./db"); const { sequences } = await import("../drizzle/schema"); const { eq } = await import("drizzle-orm"); const db = await getDb(); if (!db) throw new Error("Database not available"); // Vérifier que la séquence existe const sequence = await db.select().from(sequences).where(eq(sequences.id, input.sequenceId)).limit(1); if (sequence.length === 0) { throw new Error("Séquence introuvable"); } // Générer un nouveau token si nécessaire let token = sequence[0].qrCodeToken; if (!token) { token = generateQRToken(); await db.update(sequences).set({ qrCodeToken: token }).where(eq(sequences.id, input.sequenceId)); } // Générer le QR code const qrCodeDataURL = await generateQRCodeDataURL(token); return { token, qrCodeDataURL, }; }), // Générer un QR code pour le formateur generateQRCodeFormateur: protectedProcedure .input(z.object({ sequenceId: z.number(), })) .mutation(async ({ input, ctx }) => { const { generateQRToken } = await import("./qrCodeGenerator"); const { getDb } = await import("./db"); const { sequences } = await import("../drizzle/schema"); const { eq } = await import("drizzle-orm"); const QRCode = (await import("qrcode")).default; const { getParametres } = await import("./parametresDb"); const db = await getDb(); if (!db) throw new Error("Database not available"); // Vérifier que la séquence existe const sequence = await db.select().from(sequences).where(eq(sequences.id, input.sequenceId)).limit(1); if (sequence.length === 0) { throw new Error("Séquence introuvable"); } // Générer un nouveau token pour le formateur let token = sequence[0].qrCodeFormateurToken; if (!token) { token = generateQRToken(); await db.update(sequences).set({ qrCodeFormateurToken: token }).where(eq(sequences.id, input.sequenceId)); } // Générer le QR code avec URL spécifique formateur const parametres = await getParametres(); const url = `${parametres.urlPublique}/emargement-formateur/${token}`; const qrCodeDataURL = await QRCode.toDataURL(url, { errorCorrectionLevel: "H", type: "image/png", width: 400, margin: 2, color: { dark: "#000000", light: "#FFFFFF", }, }); return { token, qrCodeDataURL, }; }), // Valider une présence (scan QR code ou manuel) valider: publicProcedure .input(z.object({ token: z.string().optional(), inscriptionId: z.number().optional(), dateFormationId: z.number().optional(), periode: z.enum(["matin", "apres_midi"]), modeValidation: z.enum(["qrcode", "manuel"]), validateurId: z.number().optional(), commentaire: z.string().optional(), signatureDataUrl: z.string().optional(), email: z.string().optional(), typeUtilisateur: z.enum(["apprenant", "formateur"]).optional(), })) .mutation(async ({ input }) => { const { validerPresence } = await import("./presenceDb"); const { getDb } = await import("./db"); const { sequences, inscriptions } = await import("../drizzle/schema"); const { eq } = await import("drizzle-orm"); const db = await getDb(); if (!db) throw new Error("Database not available"); // Cas formateur : logique spécifique if (input.typeUtilisateur === "formateur") { const { formateurs, datesFormation } = await import("../drizzle/schema"); const { and, sql } = await import("drizzle-orm"); const { storagePut } = await import("./storage"); const crypto = await import("crypto"); // Vérifier le token formateur if (input.modeValidation === "qrcode" && input.token) { const sequence = await db.select().from(sequences).where(eq(sequences.qrCodeFormateurToken, input.token)).limit(1); if (sequence.length === 0) { throw new Error("QR code formateur invalide"); } } // Récupérer le formateur par email if (!input.email) throw new Error("Email formateur requis"); const formateurData = await db.select().from(formateurs).where(eq(formateurs.email, input.email)).limit(1); if (formateurData.length === 0) { throw new Error("Formateur introuvable avec cet email"); } // Trouver la séquence associée au token const sequenceData = await db.select().from(sequences).where(eq(sequences.qrCodeFormateurToken, input.token || "")).limit(1); if (sequenceData.length === 0) { throw new Error("Séquence introuvable"); } const finalSequenceId = sequenceData[0].id; // Trouver automatiquement la date du jour const today = new Date(); today.setHours(0, 0, 0, 0); const datesToday = await db .select() .from(datesFormation) .where( and( eq(datesFormation.sequenceId, finalSequenceId), sql`DATE(${datesFormation.dateDebut}) <= CURDATE()`, sql`DATE(${datesFormation.dateFin}) >= CURDATE()` ) ) .limit(1); if (datesToday.length === 0) { throw new Error("Aucune date de formation ne correspond à aujourd'hui"); } const dateFormId = datesToday[0].id; // Stocker la signature sur S3 let signatureUrl: string | undefined; let signatureS3Key: string | undefined; if (input.signatureDataUrl) { const formationId = sequenceData[0].formationId; const sequenceId = sequenceData[0].id; const randomSuffix = crypto.randomBytes(8).toString("hex"); const fileName = `signature-formateur-${formateurData[0].id}-${dateFormId}-${randomSuffix}.png`; const signatureS3Key = `signatures/${formationId}-${sequenceId}/${fileName}`; const base64Data = input.signatureDataUrl.split(",")[1]; const buffer = Buffer.from(base64Data, "base64"); const { url } = await storagePut(signatureS3Key, buffer, "image/png"); signatureUrl = url; } // Valider la présence formateur const { validerPresenceFormateur } = await import("./presenceDb"); const result = await validerPresenceFormateur({ formateurId: formateurData[0].id, sequenceId: finalSequenceId, dateFormationId: dateFormId, periode: input.periode, modeValidation: input.modeValidation, commentaire: input.commentaire, signatureUrl, signatureS3Key, }); return { success: true, presence: result }; } // Cas apprenant : logique existante // Si mode QR code, vérifier le token if (input.modeValidation === "qrcode" && input.token) { // Récupérer l'inscription pour vérifier la séquence if (!input.inscriptionId) throw new Error("Inscription requise pour un apprenant"); const inscription = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1); if (inscription.length === 0) { throw new Error("Inscription introuvable"); } // Vérifier que le token correspond à la séquence const sequence = await db.select().from(sequences).where(eq(sequences.id, inscription[0].sequenceId)).limit(1); if (sequence.length === 0 || sequence[0].qrCodeToken !== input.token) { throw new Error("QR code invalide"); } } // Vérifier que inscriptionId et dateFormationId sont fournis pour un apprenant if (!input.inscriptionId) throw new Error("Inscription requise pour un apprenant"); if (!input.dateFormationId) throw new Error("Date de formation requise pour un apprenant"); // Stocker la signature localement si fournie let signatureUrl: string | undefined; let signatureS3Key: string | undefined; if (input.signatureDataUrl) { const fs = await import("fs"); const path = await import("path"); const crypto = await import("crypto"); // Récupérer l'inscription pour obtenir la séquence const inscriptionData = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1); if (inscriptionData.length === 0) throw new Error("Inscription introuvable"); const sequenceData = await db.select().from(sequences).where(eq(sequences.id, inscriptionData[0].sequenceId)).limit(1); if (sequenceData.length === 0) throw new Error("Séquence introuvable"); const formationId = sequenceData[0].formationId; const sequenceId = sequenceData[0].id; // Créer la structure de dossiers const uploadsDir = path.resolve(process.cwd(), "uploads", "signatures", `${formationId}-${sequenceId}`); if (!fs.existsSync(uploadsDir)) { fs.mkdirSync(uploadsDir, { recursive: true }); } // Générer un nom de fichier unique const randomSuffix = crypto.randomBytes(8).toString("hex"); const fileName = `signature-${input.inscriptionId}-${input.dateFormationId}-${randomSuffix}.png`; const filePath = path.join(uploadsDir, fileName); // Convertir le data URL en buffer et sauvegarder const base64Data = input.signatureDataUrl.split(",")[1]; const buffer = Buffer.from(base64Data, "base64"); fs.writeFileSync(filePath, buffer); // Générer l'URL publique signatureUrl = `/uploads/signatures/${formationId}-${sequenceId}/${fileName}`; signatureS3Key = `signatures/${formationId}-${sequenceId}/${fileName}`; } const result = await validerPresence({ inscriptionId: input.inscriptionId, dateFormationId: input.dateFormationId, periode: input.periode, modeValidation: input.modeValidation, validateurId: input.validateurId, commentaire: input.commentaire, signatureUrl, signatureS3Key, }); // Vérifier si toutes les présences sont validées const { checkAllPresencesValidated } = await import("./presenceDb"); const allValidated = await checkAllPresencesValidated(input.inscriptionId); // Si toutes les présences sont validées, générer l'attestation automatiquement if (allValidated) { try { const { genererAttestationPDF, enregistrerAttestation } = await import("./attestationService"); // Générer le PDF const { s3Key, pdfUrl } = await genererAttestationPDF(input.inscriptionId); // Récupérer les données de l'inscription const db = await getDb(); if (db) { const inscriptionData = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1); if (inscriptionData.length > 0) { // Enregistrer l'attestation en base await enregistrerAttestation( input.inscriptionId, inscriptionData[0].apprenantId, inscriptionData[0].sequenceId, s3Key, pdfUrl ); } } return { ...result, attestationGenerated: true, attestationUrl: pdfUrl, }; } catch (error) { console.error("Erreur lors de la génération de l'attestation:", error); // Ne pas bloquer la validation de présence si l'attestation échoue } } return result; }), // Lister les présences pour une séquence listBySequence: protectedProcedure .input(z.object({ sequenceId: z.number(), })) .query(async ({ input }) => { const { getPresencesBySequence } = await import("./presenceDb"); return await getPresencesBySequence(input.sequenceId); }), // Lister les signatures formateurs pour une séquence listSignaturesFormateurs: protectedProcedure .input(z.object({ sequenceId: z.number(), })) .query(async ({ input }) => { const { getSignaturesFormateursBySequence } = await import("./presenceDb"); return await getSignaturesFormateursBySequence(input.sequenceId); }), // Lister les présences pour une inscription listByInscription: publicProcedure .input(z.object({ inscriptionId: z.number(), })) .query(async ({ input }) => { const { getPresencesByInscription } = await import("./presenceDb"); return getPresencesByInscription(input.inscriptionId); }), // Supprimer une présence delete: protectedProcedure .input(z.object({ presenceId: z.number(), })) .mutation(async ({ input }) => { const { supprimerPresence } = await import("./presenceDb"); return supprimerPresence(input.presenceId); }), // Vérifier si toutes les présences sont validées pour une inscription checkAllValidated: publicProcedure .input(z.object({ inscriptionId: z.number(), })) .query(async ({ input }) => { const { checkAllPresencesValidated } = await import("./presenceDb"); return checkAllPresencesValidated(input.inscriptionId); }), }), // ===== GESTION DES ATTESTATIONS ===== gestionAttestations: router({ // Récupérer la configuration d'une formation getFormationConfig: adminProcedure .input(z.object({ formationId: z.number() })) .query(async ({ input }) => { const { getFormationConfig } = await import("./gestionAttestationsDb"); return getFormationConfig(input.formationId); }), // Mettre à jour la configuration d'une formation updateFormationConfig: adminProcedure .input(z.object({ formationId: z.number(), modeAttestation: z.enum(["auto", "manuel"]), modeEnvoi: z.enum(["auto", "manuel"]), })) .mutation(async ({ input }) => { const { updateFormationConfig } = await import("./gestionAttestationsDb"); return updateFormationConfig( input.formationId, input.modeAttestation, input.modeEnvoi ); }), // Récupérer les apprenants avec leur statut d'attestation getApprenantsWithStatus: protectedProcedure .input(z.object({ formationId: z.number() })) .query(async ({ input }) => { try { const { getApprenantsWithAttestationStatus } = await import("./gestionAttestationsDb"); const results = await getApprenantsWithAttestationStatus(input.formationId); console.log("[gestionAttestations] Found", results.length, "apprenants for formation", input.formationId); return results; } catch (error) { console.error("[gestionAttestations] Error fetching apprenants:", error); throw error; } }), // Uploader un document d'attestation uploadDocument: formateurProcedure .input(z.object({ inscriptionId: z.number(), documentUrl: z.string(), documentS3Key: z.string(), })) .mutation(async ({ input, ctx }) => { const { uploadAttestationDocument, canAccessSequence } = await import("./gestionAttestationsDb"); const { getDb } = await import("./db"); const { inscriptions } = await import("../drizzle/schema"); const { eq } = await import("drizzle-orm"); // Récupérer la séquence de l'inscription const db = await getDb(); if (!db) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Database not available' }); const inscription = await db.select({ sequenceId: inscriptions.sequenceId }).from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1); if (inscription.length === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Inscription not found' }); // Vérifier l'accès const hasAccess = await canAccessSequence(ctx.user.id, ctx.user.role, inscription[0].sequenceId); if (!hasAccess) throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès refusé à cette séquence' }); return uploadAttestationDocument( input.inscriptionId, input.documentUrl, input.documentS3Key, ctx.user.id ); }), // Supprimer un document d'attestation deleteDocument: formateurProcedure .input(z.object({ attestationId: z.number() })) .mutation(async ({ input, ctx }) => { const { deleteAttestationDocument, canAccessSequence } = await import("./gestionAttestationsDb"); const { getDb } = await import("./db"); const { attestations } = await import("../drizzle/schema"); const { eq } = await import("drizzle-orm"); // Récupérer la séquence de l'attestation const db = await getDb(); if (!db) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Database not available' }); const attestation = await db.select({ sequenceId: attestations.sequenceId }).from(attestations).where(eq(attestations.id, input.attestationId)).limit(1); if (attestation.length === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Attestation not found' }); // Vérifier l'accès const hasAccess = await canAccessSequence(ctx.user.id, ctx.user.role, attestation[0].sequenceId); if (!hasAccess) throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès refusé à cette séquence' }); return deleteAttestationDocument(input.attestationId); }), // Envoyer une attestation par email sendAttestation: formateurProcedure .input(z.object({ attestationId: z.number(), sequenceId: z.number(), apprenantId: z.number(), apprenantEmail: z.string(), apprenantNom: z.string(), apprenantPrenom: z.string(), formationNom: z.string(), pdfUrl: z.string(), })) .mutation(async ({ input, ctx }) => { const { sendAttestationEmail } = await import("./emailService"); const { markAttestationAsSent, canAccessSequence } = await import("./gestionAttestationsDb"); const { enregistrerEnvoiAttestation } = await import("./attestationsDb"); // Vérifier l'accès à la séquence const hasAccess = await canAccessSequence(ctx.user.id, ctx.user.role, input.sequenceId); if (!hasAccess) throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès refusé à cette séquence' }); try { // Envoyer l'email await sendAttestationEmail({ to: input.apprenantEmail, apprenantNom: input.apprenantNom, apprenantPrenom: input.apprenantPrenom, formationNom: input.formationNom, pdfUrl: input.pdfUrl, }); // Marquer comme envoyé await markAttestationAsSent(input.attestationId); // Enregistrer dans l'historique await enregistrerEnvoiAttestation({ sequenceId: input.sequenceId, apprenantId: input.apprenantId, statut: "envoye", urlAttestation: input.pdfUrl, }); return { success: true }; } catch (error: any) { // Enregistrer l'échec dans l'historique await enregistrerEnvoiAttestation({ sequenceId: input.sequenceId, apprenantId: input.apprenantId, statut: "erreur", messageErreur: error.message, urlAttestation: input.pdfUrl, }); throw error; } }), // Envoyer toutes les attestations d'une formation en masse sendAllAttestations: formateurProcedure .input(z.object({ formationId: z.number() })) .mutation(async ({ input }) => { const { getApprenantsWithAttestationStatus } = await import("./gestionAttestationsDb"); const { sendAttestationEmail } = await import("./emailService"); const { markAttestationAsSent } = await import("./gestionAttestationsDb"); const { enregistrerEnvoiAttestation } = await import("./attestationsDb"); const { getDb } = await import("./db"); const { formations } = await import("../drizzle/schema"); const { eq } = await import("drizzle-orm"); try { // Récupérer tous les apprenants avec attestations const apprenants = await getApprenantsWithAttestationStatus(input.formationId); // Récupérer le nom de la formation const db = await getDb(); if (!db) throw new Error("Database not available"); const formation = await db.select().from(formations).where(eq(formations.id, input.formationId)).limit(1); const formationNom = formation[0]?.nom || ""; let succes = 0; let echecs = 0; const erreurs: string[] = []; // Filtrer les apprenants qui ont une attestation prête const apprenantsAvecAttestation = apprenants.filter(item => { const hasDocument = item.attestation && item.attestation.documentUrl; return hasDocument; }); console.log(`[sendAllAttestations] Envoi de ${apprenantsAvecAttestation.length} attestation(s) pour la formation ${formationNom}`); // Envoyer chaque attestation for (const item of apprenantsAvecAttestation) { if (!item.attestation || !item.attestation.documentUrl) continue; const pdfUrl = item.attestation.documentUrl; try { // Envoyer l'email await sendAttestationEmail({ to: item.apprenant.email, apprenantNom: item.apprenant.nom, apprenantPrenom: item.apprenant.prenom, formationNom: formationNom, pdfUrl: pdfUrl, }); // Marquer comme envoyé await markAttestationAsSent(item.attestation.id); // Enregistrer dans l'historique await enregistrerEnvoiAttestation({ sequenceId: item.sequence.id, apprenantId: item.apprenant.id, statut: "envoye", urlAttestation: pdfUrl, }); succes++; console.log(`[sendAllAttestations] Envoi réussi pour ${item.apprenant.email}`); } catch (error: any) { echecs++; const messageErreur = error?.message || String(error); erreurs.push(`${item.apprenant.email}: ${messageErreur}`); console.error(`[sendAllAttestations] Erreur pour ${item.apprenant.email}:`, error); // Enregistrer l'échec dans l'historique await enregistrerEnvoiAttestation({ sequenceId: item.sequence.id, apprenantId: item.apprenant.id, statut: "erreur", messageErreur: messageErreur, urlAttestation: pdfUrl, }); } } console.log(`[sendAllAttestations] Terminé: ${succes} succès, ${echecs} échecs`); return { succes, echecs, erreurs, total: apprenantsAvecAttestation.length, }; } catch (error: any) { console.error("[sendAllAttestations] Erreur globale:", error); throw error; } }), }), // Monitoring Fail2Ban fail2ban: router({ // Récupérer le statut et les statistiques Fail2Ban getStatus: adminProcedure.query(async () => { return await fail2banDb.getFail2BanStatus(); }), // Récupérer l'historique des bannissements getHistory: adminProcedure .input(z.object({ limit: z.number().optional().default(50), })) .query(async ({ input }) => { return await fail2banDb.getBanHistory(input.limit); }), // Débannir une IP unbanIP: adminProcedure .input(z.object({ ip: z.string(), })) .mutation(async ({ input }) => { const success = await fail2banDb.unbanIP(input.ip); if (!success) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Impossible de débannir l'IP ${input.ip}`, }); } return { success: true }; }), // Compter les bannissements récents countRecentBans: adminProcedure .input(z.object({ hours: z.number().optional().default(1), })) .query(async ({ input }) => { return await fail2banDb.countRecentBans(input.hours); }), }), }); export type AppRouter = typeof appRouter;