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 { 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 }); }); 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.prenom || '', nom: utilisateur.nom || '', 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') { 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') { return db.getApprenants(); } else { throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès non autorisé' }); } }), getById: adminProcedure.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: adminProcedure.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().nullable().optional(), })).mutation(async ({ input }) => { const { id, ...data } = input; 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') { 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 }; }) ); return { ...seq, dates: datesAvecRappels, nbInscrits }; }) ); 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; // 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)); // 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'); // 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'); 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 }; }), }), // ===== 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: adminProcedure.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 ? 'liste_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, 'liste_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 === 'liste_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', 'liste_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', 'rappel', 'rappel_j1']) })).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', 'rappel', 'rappel_j1']), apprenantId: z.number().optional(), })) .query(async ({ input }) => { const { generateEmailPreview } = await import('./emailPreview'); return await generateEmailPreview(input); }), exportExcel: adminProcedure.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: adminProcedure.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: adminProcedure.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; } } const data = inscriptions .filter(i => i.inscription.statut === 'confirmee' && i.apprenant) .map(i => ({ nom: i.apprenant!.nom, prenom: i.apprenant!.prenom, codeEtablissement: i.apprenant!.codeEtablissement, fonction: i.apprenant!.fonction, })); const buffer = await generateFeuillePresence({ 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, formateur: formateurNom, apprenants: data, }); return { buffer: buffer.toString('base64'), filename: `feuille_presence_${sequence.nom.replace(/\s+/g, '_')}.pdf`, }; }), }), // ===== 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"]).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"]).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'); // Définir l'expiration à 24h const expiresAt = new Date(); expiresAt.setHours(expiresAt.getHours() + 24); // Sauvegarder le token en base await db.createPasswordResetToken(user.id, token, expiresAt); // 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", "rappel", "rappelJ1", "rappel3", "rappel4", "rappel5", "rappel6", "reset_password"]), 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(["rappel", "rappelJ1", "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(), })) .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(["rappel", "rappelJ1", "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(), })) .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(), })) .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 à l'administrateur connecté // Envoyer le rappel de test à tous les inscrits confirmés const { sendRappelEmail } = await import('./emailService'); let nbEnvoyes = 0; let nbEchecs = 0; const erreurs: string[] = []; for (const inscrit of inscritsConfirmes) { const apprenant = inscrit.apprenant; if (!apprenant || !apprenant.email) { nbEchecs++; erreurs.push(`Apprenant ${apprenant?.nom || 'inconnu'}: email manquant`); continue; } try { await sendRappelEmail({ apprenantEmail: apprenant.email, 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, }); // Enregistrer dans l'historique avec typeEnvoi = 'test' await db.createLogRappel({ rappelId: rappelAUtiliser.id, sequenceId: input.sequenceId, apprenantId: apprenant.id, email: apprenant.email, 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: apprenant.email, 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(); }), }), // ===== 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, }; }), // Valider une présence (scan QR code ou manuel) valider: publicProcedure .input(z.object({ token: z.string().optional(), inscriptionId: z.number(), dateFormationId: z.number(), modeValidation: z.enum(["qrcode", "manuel"]), validateurId: z.number().optional(), commentaire: z.string().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"); // Si mode QR code, vérifier le token if (input.modeValidation === "qrcode" && input.token) { const db = await getDb(); if (!db) throw new Error("Database not available"); // Récupérer l'inscription pour vérifier la séquence 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"); } } const result = await validerPresence({ inscriptionId: input.inscriptionId, dateFormationId: input.dateFormationId, modeValidation: input.modeValidation, validateurId: input.validateurId, commentaire: input.commentaire, }); // 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 getPresencesBySequence(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: adminProcedure .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: adminProcedure .input(z.object({ inscriptionId: z.number(), documentUrl: z.string(), documentS3Key: z.string(), })) .mutation(async ({ input, ctx }) => { const { uploadAttestationDocument } = await import("./gestionAttestationsDb"); return uploadAttestationDocument( input.inscriptionId, input.documentUrl, input.documentS3Key, ctx.user.id ); }), // Supprimer un document d'attestation deleteDocument: adminProcedure .input(z.object({ attestationId: z.number() })) .mutation(async ({ input }) => { const { deleteAttestationDocument } = await import("./gestionAttestationsDb"); return deleteAttestationDocument(input.attestationId); }), // Envoyer une attestation par email sendAttestation: adminProcedure .input(z.object({ attestationId: z.number(), apprenantEmail: z.string(), apprenantNom: z.string(), apprenantPrenom: z.string(), formationNom: z.string(), pdfUrl: z.string(), })) .mutation(async ({ input }) => { const { sendAttestationEmail } = await import("./emailService"); const { markAttestationAsSent } = await import("./gestionAttestationsDb"); // 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); return { success: true }; }), }), }); export type AppRouter = typeof appRouter;