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 } from "./emailService"; import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService"; import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel"; // 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; }), }), // ===== FORMATIONS ===== formations: router({ list: adminProcedure.query(async () => { return db.getFormations(); }), 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: adminProcedure.query(async () => { return db.getApprenants(); }), 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(); }), 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), })).mutation(async ({ input }) => { await db.createFormateur(input); return { success: true }; }), update: adminProcedure.input(z.object({ id: z.number(), nom: z.string().min(1).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: adminProcedure.query(async () => { const seqs = await db.getSequencesWithFormation(); // 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; }), 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: parseLocalDateTime(sequenceData.dateBlocage), }; 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({ 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); }), 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, })), }); console.log(`[Notification] Email envoyé au formateur ${formateur.email} pour nouvelle inscription`); } catch (e) { console.error(`[Notification] Erreur envoi email formateur:`, e); } } } // Alerte capacité atteinte pour les admins 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)`); } } 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, }); console.log(`[Notification] Email d'annulation envoyé au formateur ${formateur.email}`); } catch (e) { 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, }); console.log(`[Notification] Email place disponible envoyé à ${premierEnAttente.apprenant.email}`); } catch (e) { 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 }) => { await db.updateInscription(input.id, { statut: input.statut }); 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(); }), 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"]).default("user"), isActive: z.boolean().default(true), })).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"]).optional(), isActive: z.boolean().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.string(), name: z.string(), logoUrl: z.string().nullable(), primaryColor: z.string(), headerBgColor: z.string(), headerTextColor: z.string(), headerTitle: z.string(), bodyContent: z.string().nullable(), footerText: z.string().nullable(), active: z.boolean(), })) .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.string(), joursAvant: z.number(), heureEnvoi: z.string().default("09:00"), actif: z.boolean(), })) .mutation(async ({ input }) => { return db.createRappel(input); }), update: adminProcedure .input(z.object({ id: z.number(), nom: z.string().optional(), templateType: z.string().optional(), joursAvant: z.number().optional(), heureEnvoi: z.string().optional(), actif: z.boolean().optional(), })) .mutation(async ({ input }) => { const { id, ...data } = input; await db.updateRappel(id, data); 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(), typeRappel: z.enum(['rappel', 'rappelJ1']), })) .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 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 au premier inscrit uniquement const premierInscrit = inscritsConfirmes[0]; const apprenant = premierInscrit.apprenant; if (!apprenant || !apprenant.email) { throw new TRPCError({ code: 'BAD_REQUEST', message: 'Apprenant introuvable ou sans email' }); } const { sendRappelJ7Email, sendRappelJ1Email } = await import('./emailService'); try { if (input.typeRappel === 'rappel') { await sendRappelJ7Email({ 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, }); } else { await sendRappelJ1Email({ 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, }); } return { success: true, message: `Rappel de test envoyé à ${apprenant.email}`, destinataire: apprenant.email, }; } catch (error: any) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: `Erreur lors de l'envoi: ${error.message}`, }); } }), historique: adminProcedure .input(z.object({ dateDebut: z.string().optional(), dateFin: z.string().optional(), sequenceId: z.number().optional(), statut: z.enum(['success', 'failed']).optional(), email: z.string().optional(), limit: z.number().default(100), offset: z.number().default(0), })) .query(async ({ input }) => { const { getLogsRappelsWithFilters } = await import('./rappelDb'); return getLogsRappelsWithFilters({ dateDebut: input.dateDebut ? new Date(input.dateDebut) : undefined, dateFin: input.dateFin ? new Date(input.dateFin) : undefined, sequenceId: input.sequenceId, statut: input.statut, email: input.email, limit: input.limit, offset: input.offset, }); }), statistiques: adminProcedure.query(async () => { const { getStatsRappels } = await import('./rappelDb'); return getStatsRappels(); }), evolution: adminProcedure.query(async () => { const { getEvolutionEnvois } = await import('./rappelDb'); return getEvolutionEnvois(); }), }), emailConfig: router({ get: adminProcedure.query(async () => { return db.getActiveEmailConfig(); }), upsert: adminProcedure .input(z.object({ provider: z.string(), 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 }; } }), }), // ===== 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({ nom: 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(), nom: 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, complete: true, }); // 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?.nom || '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 }; }), }), }); export type AppRouter = typeof appRouter;