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 }; }), }), // ===== SÉQUENCES ===== sequences: router({ list: adminProcedure.query(async () => { const seqs = await db.getSequences(); // Récupérer les dates pour chaque séquence const sequencesAvecDates = await Promise.all( seqs.map(async (seq) => { const dates = await db.getDatesBySequence(seq.id); return { ...seq, dates }; }) ); 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"]), 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"]), 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); 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, 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); }), 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 }; }), }), }); export type AppRouter = typeof appRouter;