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 { TRPCError } from "@trpc/server"; import { sendInscriptionConfirmation, sendGroupEmail } from "./emailService"; import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService"; // 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 }; }), }), // ===== 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 }; }), 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); return { ...seq, dates }; }), create: adminProcedure.input(z.object({ formationId: z.number(), nom: z.string().min(1), lieu: z.string().min(1), publicCible: z.enum(["directeur", "chef_service", "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", "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, }); } 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' }); } await db.updateInscription(inscription.id, { statut: 'annulee' }); 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']), })).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, })); 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, }); return result; }), 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(), 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(), 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(), 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.getAllRappels(); }), create: adminProcedure .input(z.object({ nom: z.string(), joursAvant: z.number(), sujet: z.string(), contenu: z.string(), actif: z.boolean(), })) .mutation(async ({ input }) => { return db.createRappel(input); }), update: adminProcedure .input(z.object({ id: z.number(), nom: z.string(), joursAvant: z.number(), sujet: z.string(), contenu: z.string(), actif: z.boolean(), })) .mutation(async ({ input }) => { return db.updateRappel(input); }), 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({ id: input.id, actif: input.actif }); return { success: true }; }), }), 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(), })) .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 }; } }), }), }); export type AppRouter = typeof appRouter;