Files
formation-manager-itinova/server/routers.ts
Manus 8f808980dc Checkpoint: Intégration des signatures tactiles dans les feuilles de présence PDF :
 Modifications implémentées :
- Interface ApprenantPresence étendue pour inclure les présences avec signatures
- Fonction generateFeuillePresence modifiée pour charger et afficher les images de signatures
- Hook didDrawCell ajouté pour insérer les images dans les cellules du tableau PDF
- Procédure exportFeuillePresence modifiée pour récupérer toutes les présences avec signatures
- Signatures chargées depuis le système de fichiers local (uploads/signatures/)
- Gestion automatique des cas où la signature n'existe pas (cellule vide)
- Hauteur des cellules augmentée (minCellHeight: 15) pour accommoder les signatures

📋 Fonctionnement :
- Lors de l'export PDF, le système récupère toutes les présences de la séquence
- Pour chaque apprenant, les signatures sont associées à leurs dates respectives
- Les images PNG sont chargées depuis le disque et converties en base64
- Les signatures sont insérées dans les colonnes Matin/Après-midi correspondantes
- Si aucune signature n'existe, la cellule reste vide pour signature manuscrite

🔧 Technique :
- Utilisation de jsPDF avec jspdf-autotable
- Chargement des images avec fs.readFileSync
- Conversion en data URL base64 pour insertion dans le PDF
- Gestion des erreurs avec console.warn si fichier manquant
2026-01-28 05:21:57 -05:00

2912 lines
108 KiB
TypeScript

import { COOKIE_NAME } from "@shared/const";
import { getSessionCookieOptions } from "./_core/cookies";
import { systemRouter } from "./_core/systemRouter";
import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
import { parseLocalDateTime } from "./dateUtils";
import { z } from "zod";
import * as db from "./db";
import * as analyticsDb from "./analyticsDb";
import * as fail2banDb from "./fail2banDb";
import { generateAnalyticsExcel, generateAnalyticsPDF } from "./analyticsExportService";
import { TRPCError } from "@trpc/server";
import { sendInscriptionConfirmation, sendGroupEmail, sendNotificationFormateurNouvelleInscription, sendNotificationFormateurAnnulation, sendAlerteCapaciteAtteinte, sendNotificationPlaceDisponible, sendRemerciementPostFormation, sendResetPasswordEmail } from "./emailService";
import { logNotification } from "./notificationLogsDb";
import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService";
import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel";
import crypto from "crypto";
import bcrypt from "bcryptjs";
import { sql } from "drizzle-orm";
// Procédure admin uniquement
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux administrateurs' });
}
return next({ ctx });
});
export const appRouter = router({
system: systemRouter,
auth: router({
me: publicProcedure.query(opts => opts.ctx.user),
logout: publicProcedure.mutation(({ ctx }) => {
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
return {
success: true,
} as const;
}),
// Demander une réinitialisation de mot de passe
requestPasswordReset: publicProcedure
.input(z.object({
email: z.string().email(),
}))
.mutation(async ({ input }) => {
const { email } = input;
// Vérifier si l'utilisateur existe
const utilisateur = await db.getUtilisateurByEmail(email);
if (!utilisateur) {
// Ne pas révéler si l'email existe ou non pour des raisons de sécurité
return { success: true };
}
// Générer un token unique
const token = crypto.randomBytes(32).toString('hex');
// Sauvegarder le token dans la base de données
await db.createPasswordResetToken(email, token);
// Envoyer l'email avec le template reset_password
const resetUrl = `${process.env.VITE_OAUTH_PORTAL_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
await sendResetPasswordEmail({
email,
prenom: utilisateur.name?.split(' ')[0] || '',
nom: utilisateur.name?.split(' ').slice(1).join(' ') || utilisateur.name || '',
lienReinitialisation: resetUrl,
});
return { success: true };
}),
// Valider le token et réinitialiser le mot de passe
resetPassword: publicProcedure
.input(z.object({
token: z.string(),
newPassword: z.string().min(8),
}))
.mutation(async ({ input }) => {
const { token, newPassword } = input;
// Récupérer le token
const resetToken = await db.getPasswordResetToken(token);
if (!resetToken) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Token invalide ou expiré',
});
}
// Vérifier si le token est expiré
if (new Date() > new Date(resetToken.expiresAt)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Token expiré',
});
}
// Vérifier si le token a déjà été utilisé
if (resetToken.used) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Token déjà utilisé',
});
}
// Récupérer l'utilisateur
const utilisateur = await db.getUtilisateurByEmail(resetToken.email);
if (!utilisateur) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Utilisateur introuvable',
});
}
// Mettre à jour le mot de passe
const hashedPassword = await bcrypt.hash(newPassword, 10);
await db.updateUtilisateurPassword(utilisateur.id, hashedPassword);
// Marquer le token comme utilisé
await db.markTokenAsUsed(resetToken.id);
return { success: true };
}),
}),
// ===== FORMATIONS =====
formations: router({
list: protectedProcedure.query(async ({ ctx }) => {
// Si l'utilisateur est formateur, ne montrer que les formations pour lesquelles il a des séquences
if (ctx.user.role === 'formateur' && ctx.user.formateurId) {
return db.getFormationsByFormateur(ctx.user.formateurId);
} else if (ctx.user.role === 'admin') {
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: protectedProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
return db.getApprenantById(input.id);
}),
getByEmail: publicProcedure.input(z.object({ email: z.string() })).query(async ({ input }) => {
return db.getApprenantByEmail(input.email);
}),
create: adminProcedure.input(z.object({
nom: z.string().min(1),
prenom: z.string().min(1),
email: z.string().email(),
codeEtablissement: z.string().min(1),
fonction: z.enum(["directeur", "chef_service", "autre"]),
})).mutation(async ({ input }) => {
await db.createApprenant(input);
return { success: true };
}),
// Procédure publique pour créer un apprenant lors de l'inscription
createPublic: publicProcedure.input(z.object({
nom: z.string().min(1),
prenom: z.string().min(1),
email: z.string().email(),
codeEtablissement: z.string().min(1),
fonction: z.enum(["directeur", "chef_service", "autre"]),
})).mutation(async ({ input }) => {
await db.createApprenant(input);
return { success: true };
}),
update: adminProcedure.input(z.object({
id: z.number(),
nom: z.string().min(1).optional(),
prenom: z.string().min(1).optional(),
email: z.string().email().optional(),
codeEtablissement: z.string().min(1).optional(),
fonction: z.enum(["directeur", "chef_service", "autre"]).optional(),
})).mutation(async ({ input }) => {
const { id, ...data } = input;
await db.updateApprenant(id, data);
return { success: true };
}),
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
await db.deleteApprenant(input.id);
return { success: true };
}),
getInscriptions: protectedProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
const results = await db.getInscriptionsByApprenant(input.id);
// Récupérer les informations de formation et séquence pour chaque inscription
const inscriptionsAvecDetails = await Promise.all(
results.map(async (result: any) => {
const insc = result.inscription;
if (!insc) return null;
const sequence = await db.getSequenceById(insc.sequenceId);
if (!sequence) return null;
const formation = await db.getFormationById(sequence.formationId);
const dates = await db.getDatesBySequence(insc.sequenceId);
return {
...insc,
sequence: { ...sequence, dates },
formation,
};
})
);
return inscriptionsAvecDetails.filter(i => i !== null);
}),
}),
// ===== FORMATEURS =====
formateurs: router({
list: adminProcedure.query(async () => {
return db.getFormateurs();
}),
// Tableau de bord formateur
dashboardStats: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role !== 'formateur' || !ctx.user.formateurId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux formateurs' });
}
const formateurDb = await import('./formateurDb');
return formateurDb.getFormateurDashboardStats(ctx.user.formateurId);
}),
prochainesSequences: protectedProcedure
.input(z.object({ limit: z.number().optional().default(10) }))
.query(async ({ ctx, input }) => {
if (ctx.user.role !== 'formateur' || !ctx.user.formateurId) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux formateurs' });
}
const formateurDb = await import('./formateurDb');
return formateurDb.getFormateurProchainesSequences(ctx.user.formateurId, input.limit);
}),
getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
return db.getFormateurById(input.id);
}),
create: adminProcedure.input(z.object({
nom: z.string().min(1),
email: z.string().email().optional(),
})).mutation(async ({ input }) => {
await db.createFormateur(input);
return { success: true };
}),
update: adminProcedure.input(z.object({
id: z.number(),
nom: z.string().min(1).optional(),
email: z.string().email().or(z.literal('')).nullable().optional(),
})).mutation(async ({ input }) => {
const { id, ...data } = input;
// Convertir les chaînes vides en null
if (data.email === '') {
data.email = null;
}
await db.updateFormateur(id, data);
return { success: true };
}),
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
await db.deleteFormateur(input.id);
return { success: true };
}),
}),
// ===== SÉQUENCES =====
sequences: router({
list: protectedProcedure.query(async ({ ctx }) => {
let seqs;
// Si l'utilisateur est formateur, ne montrer que ses séquences
if (ctx.user.role === 'formateur' && ctx.user.formateurId) {
seqs = await db.getSequencesByFormateur(ctx.user.formateurId);
} else if (ctx.user.role === 'admin') {
seqs = await db.getSequencesWithFormation();
} else {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès non autorisé' });
}
// Récupérer les dates et le nombre d'inscrits pour chaque séquence
const sequencesAvecDates = await Promise.all(
seqs.map(async (seq) => {
const dates = await db.getDatesBySequence(seq.id);
const nbInscrits = await db.countInscriptionsBySequence(seq.id, 'confirmee');
// Récupérer les rappels associés à chaque date
const datesAvecRappels = await Promise.all(
dates.map(async (date) => {
const rappels = await db.getRappelsByDateFormation(date.id);
return { ...date, rappels };
})
);
// Compter le nombre de rappels actifs pour cette séquence
const nbRappelsActifs = datesAvecRappels.reduce((total, date) => {
return total + (date.rappels?.filter((r: any) => r.actif).length || 0);
}, 0);
return { ...seq, dates: datesAvecRappels, nbInscrits, nbRappelsActifs };
})
);
return sequencesAvecDates;
}),
listByFormation: publicProcedure.input(z.object({ formationId: z.number() })).query(async ({ input }) => {
const seqs = await db.getSequencesByFormation(input.formationId);
// Récupérer les dates et le nombre d'inscrits pour chaque séquence
const sequencesAvecDates = await Promise.all(
seqs.map(async (seq) => {
const dates = await db.getDatesBySequence(seq.id);
const nbInscrits = await db.countInscriptionsBySequence(seq.id, 'confirmee');
return { ...seq, dates, nbInscrits };
})
);
return sequencesAvecDates;
}),
getById: publicProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
const seq = await db.getSequenceById(input.id);
if (!seq) return null;
const dates = await db.getDatesBySequence(input.id);
const nbInscrits = await db.countInscriptionsBySequence(input.id, 'confirmee');
return { ...seq, dates, nbInscrits };
}),
create: adminProcedure.input(z.object({
formationId: z.number(),
nom: z.string().min(1),
lieu: z.string().min(1),
publicCible: z.enum(["directeur", "chef_service", "tous", "autre"]),
formateurId: z.number().nullable().optional(),
capaciteMax: z.number().default(12),
dateBlocage: z.string(),
statut: z.enum(["ouverte", "bloquee", "terminee"]).optional(),
dates: z.array(z.object({
dateDebut: z.string(),
dateFin: z.string(),
ordre: z.number(),
})).min(1).max(4),
})).mutation(async ({ input }) => {
const { dates, ...sequenceData } = input;
// Valider que chaque date de fin est après la date de début
for (const date of dates) {
const debut = new Date(date.dateDebut);
const fin = new Date(date.dateFin);
if (fin <= debut) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'La date de fin doit être après la date de début pour toutes les dates de formation',
});
}
}
// Créer la séquence
const result = await db.createSequence({
...sequenceData,
dateBlocage: parseLocalDateTime(sequenceData.dateBlocage),
});
// Récupérer l'ID de la séquence créée
const sequenceId = Number(result[0].insertId);
// Créer les dates de formation
for (const date of dates) {
await db.createDateFormation({
sequenceId,
dateDebut: parseLocalDateTime(date.dateDebut),
dateFin: parseLocalDateTime(date.dateFin),
ordre: date.ordre,
});
}
return { success: true, sequenceId };
}),
update: adminProcedure.input(z.object({
id: z.number(),
formationId: z.number(),
nom: z.string().min(1),
lieu: z.string().min(1),
publicCible: z.enum(["directeur", "chef_service", "tous", "autre"]),
formateurId: z.number().nullable().optional(),
capaciteMax: z.number(),
dateBlocage: z.string(),
statut: z.enum(["ouverte", "bloquee", "terminee"]),
dates: z.array(z.object({
dateDebut: z.string(),
dateFin: z.string(),
ordre: z.number(),
})).min(1).max(4),
})).mutation(async ({ input }) => {
const { id, dates, ...sequenceData } = input;
console.log('[UPDATE SEQUENCE] Input reçu:', JSON.stringify(input, null, 2));
// Valider que chaque date de fin est après la date de début
for (const date of dates) {
const debut = new Date(date.dateDebut);
const fin = new Date(date.dateFin);
if (fin <= debut) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'La date de fin doit être après la date de début pour toutes les dates de formation',
});
}
}
// Mettre à jour la séquence
const updateData: any = {
...sequenceData,
dateBlocage: sequenceData.dateBlocage && sequenceData.dateBlocage.trim() !== ''
? parseLocalDateTime(sequenceData.dateBlocage)
: null,
};
console.log('[UPDATE SEQUENCE] UpdateData:', JSON.stringify(updateData, null, 2));
await db.updateSequence(id, updateData);
console.log('[UPDATE SEQUENCE] Séquence mise à jour avec succès');
// Récupérer les dates existantes
const existingDates = await db.getDatesBySequence(id);
console.log('[UPDATE SEQUENCE] Dates existantes:', existingDates.length);
// Comparer les dates pour détecter les changements
const datesChanged = existingDates.length !== dates.length ||
existingDates.some((existing, index) => {
const newDate = dates[index];
if (!newDate) return true;
const existingDebut = new Date(existing.dateDebut).toISOString();
const existingFin = new Date(existing.dateFin).toISOString();
const newDebut = parseLocalDateTime(newDate.dateDebut).toISOString();
const newFin = parseLocalDateTime(newDate.dateFin).toISOString();
return existingDebut !== newDebut || existingFin !== newFin;
});
// Ne supprimer/recréer les dates que si elles ont changé
if (datesChanged) {
console.log('[UPDATE SEQUENCE] Dates modifiées, suppression et recréation');
// Supprimer les anciennes dates
await db.deleteDatesBySequence(id);
console.log('[UPDATE SEQUENCE] Anciennes dates supprimées');
// Créer les nouvelles dates
for (const date of dates) {
console.log('[UPDATE SEQUENCE] Création date:', date);
await db.createDateFormation({
sequenceId: id,
dateDebut: parseLocalDateTime(date.dateDebut),
dateFin: parseLocalDateTime(date.dateFin),
ordre: date.ordre,
});
}
console.log('[UPDATE SEQUENCE] Toutes les dates ont été créées');
} else {
console.log('[UPDATE SEQUENCE] Dates inchangées, conservation des rappels');
}
return { success: true };
}),
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
// Supprimer d'abord les dates associées
await db.deleteDatesBySequence(input.id);
// Puis supprimer la séquence
await db.deleteSequence(input.id);
return { success: true };
}),
}),
// ===== INSCRIPTIONS =====
inscriptions: router({
// Liste des inscriptions avec dates de formation (pour formateurs)
listWithDates: protectedProcedure
.input(z.object({ sequenceId: z.number() }))
.query(async ({ input }) => {
const { getInscriptionsWithDates } = await import("./inscriptionWithDates");
return getInscriptionsWithDates(input.sequenceId);
}),
listAll: adminProcedure.query(async () => {
return db.getAllInscriptions();
}),
listBySequence: protectedProcedure.input(z.object({ sequenceId: z.number() })).query(async ({ input }) => {
return db.getInscriptionsBySequence(input.sequenceId);
}),
listByApprenant: publicProcedure.input(z.object({ apprenantId: z.number() })).query(async ({ input }) => {
return db.getInscriptionsByApprenant(input.apprenantId);
}),
listByApprenantWithDates: publicProcedure
.input(z.object({ apprenantId: z.number() }))
.query(async ({ input }) => {
const { getInscriptionsByApprenantWithDates } = await import("./inscriptionWithDates");
return getInscriptionsByApprenantWithDates(input.apprenantId);
}),
checkExisting: publicProcedure.input(z.object({
apprenantId: z.number(),
sequenceId: z.number(),
})).query(async ({ input }) => {
return db.checkExistingInscription(input.apprenantId, input.sequenceId);
}),
inscrire: publicProcedure.input(z.object({
apprenantId: z.number(),
sequenceId: z.number(),
})).mutation(async ({ input }) => {
// Vérifier si la séquence existe
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
}
// Vérifier si la séquence est bloquée
const now = new Date();
if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Les inscriptions sont fermées pour cette séquence (J-15 dépassé)'
});
}
// Vérifier si l'apprenant a déjà une inscription
const existing = await db.checkExistingInscription(input.apprenantId, input.sequenceId);
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Vous êtes déjà inscrit à cette séquence'
});
}
// Vérifier la capacité
const nbInscrits = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
const statut = nbInscrits >= sequence.capaciteMax ? 'en_attente' : 'confirmee';
await db.createInscription({
apprenantId: input.apprenantId,
sequenceId: input.sequenceId,
statut,
});
// Envoyer l'email de confirmation avec invitations Outlook pour toutes les dates
const inscriptionSequence = await db.getSequenceById(input.sequenceId);
const inscriptionApprenant = await db.getApprenantById(input.apprenantId);
const inscriptionFormation = inscriptionSequence ? await db.getFormationById(inscriptionSequence.formationId) : null;
const dates = inscriptionSequence ? await db.getDatesBySequence(inscriptionSequence.id) : [];
if (inscriptionSequence && inscriptionApprenant && inscriptionFormation && dates.length > 0) {
// Utiliser la première date pour l'email principal
const premiereDate = dates[0];
await sendInscriptionConfirmation({
apprenantEmail: inscriptionApprenant.email,
apprenantNom: inscriptionApprenant.nom,
apprenantPrenom: inscriptionApprenant.prenom,
apprenantFonction: inscriptionApprenant.fonction || '',
formationNom: inscriptionFormation.nom,
sequenceNom: inscriptionSequence.nom,
dates: dates.map(d => ({
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
lieu: inscriptionSequence.lieu,
statut,
});
// Notification au formateur si présent
if (inscriptionSequence.formateurId) {
const formateur = await db.getFormateurById(inscriptionSequence.formateurId);
if (formateur && formateur.email) {
const nbInscritsActuel = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
try {
await sendNotificationFormateurNouvelleInscription({
formateurEmail: formateur.email,
formateurNom: formateur.nom,
apprenantNom: inscriptionApprenant.nom,
apprenantPrenom: inscriptionApprenant.prenom,
apprenantFonction: inscriptionApprenant.fonction || '',
apprenantEtablissement: inscriptionApprenant.codeEtablissement || '',
formationNom: inscriptionFormation.nom,
sequenceNom: inscriptionSequence.nom,
nbInscrits: nbInscritsActuel,
capaciteMax: inscriptionSequence.capaciteMax,
dates: dates.map(d => ({
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
});
// Logger la notification
await logNotification({
type: "notification_formateur_inscription",
sequenceId: input.sequenceId,
apprenantId: inscriptionApprenant.id,
formateurId: formateur.id,
emailDestinataire: formateur.email,
sujet: `Nouvelle inscription - ${inscriptionFormation.nom}`,
statut: "success",
});
console.log(`[Notification] Email envoyé au formateur ${formateur.email} pour nouvelle inscription`);
} catch (e: any) {
// Logger l'échec
await logNotification({
type: "notification_formateur_inscription",
sequenceId: input.sequenceId,
apprenantId: inscriptionApprenant.id,
formateurId: formateur.id,
emailDestinataire: formateur.email,
sujet: `Nouvelle inscription - ${inscriptionFormation.nom}`,
statut: "failed",
messageErreur: e.message,
});
console.error(`[Notification] Erreur envoi email formateur:`, e);
}
}
}
// Alerte capacité atteinte pour les admins ET le formateur
const nbInscritsApres = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
if (nbInscritsApres >= inscriptionSequence.capaciteMax) {
try {
const admins = await db.getAdminUsers();
const adminEmails = admins.filter(a => a.email).map(a => a.email!);
if (adminEmails.length > 0) {
const nbListeAttente = await db.countInscriptionsBySequence(input.sequenceId, 'en_attente');
let formateurNom: string | undefined;
if (inscriptionSequence.formateurId) {
const formateur = await db.getFormateurById(inscriptionSequence.formateurId);
formateurNom = formateur?.nom;
}
await sendAlerteCapaciteAtteinte({
adminEmails,
formationNom: inscriptionFormation.nom,
sequenceNom: inscriptionSequence.nom,
capaciteMax: inscriptionSequence.capaciteMax,
nbInscrits: nbInscritsApres,
nbListeAttente,
formateurNom,
dates: dates.map(d => ({
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
});
console.log(`[Notification] Alerte capacité atteinte envoyée à ${adminEmails.length} admin(s)`);
}
// Notification au formateur pour capacité atteinte
if (inscriptionSequence.formateurId) {
const formateur = await db.getFormateurById(inscriptionSequence.formateurId);
if (formateur && formateur.email) {
const { sendNotificationFormateurCapaciteAtteinte } = await import('./emailService');
await sendNotificationFormateurCapaciteAtteinte({
formateurEmail: formateur.email,
formateurNom: formateur.nom,
formationNom: inscriptionFormation.nom,
sequenceNom: inscriptionSequence.nom,
capaciteMax: inscriptionSequence.capaciteMax,
nbInscrits: nbInscritsApres,
dates: dates.map(d => ({
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
});
console.log(`[Notification] Alerte capacité atteinte envoyée au formateur ${formateur.email}`);
}
}
} catch (e) {
console.error(`[Notification] Erreur envoi alerte capacité:`, e);
}
}
}
return { success: true, statut };
}),
desinscrire: publicProcedure.input(z.object({
apprenantId: z.number(),
sequenceId: z.number(),
})).mutation(async ({ input }) => {
// Vérifier si la séquence existe
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
}
// Vérifier si la séquence est bloquée
const now = new Date();
if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Les désinscriptions sont fermées pour cette séquence (J-15 dépassé)'
});
}
// Trouver l'inscription
const inscription = await db.checkExistingInscription(input.apprenantId, input.sequenceId);
if (!inscription) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Inscription introuvable' });
}
// Récupérer les infos avant la mise à jour pour les notifications
const apprenant = await db.getApprenantById(input.apprenantId);
const formation = await db.getFormationById(sequence.formationId);
await db.updateInscription(inscription.id, { statut: 'annulee' });
// Notifications après désinscription
if (apprenant && formation) {
// Notification au formateur
if (sequence.formateurId) {
const formateur = await db.getFormateurById(sequence.formateurId);
if (formateur && formateur.email) {
const nbInscritsApres = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
try {
await sendNotificationFormateurAnnulation({
formateurEmail: formateur.email,
formateurNom: formateur.nom,
apprenantNom: apprenant.nom,
apprenantPrenom: apprenant.prenom,
apprenantFonction: apprenant.fonction || '',
formationNom: formation.nom,
sequenceNom: sequence.nom,
nbInscrits: nbInscritsApres,
capaciteMax: sequence.capaciteMax,
});
// Logger la notification
await logNotification({
type: "notification_formateur_annulation",
sequenceId: input.sequenceId,
apprenantId: apprenant.id,
formateurId: formateur.id,
emailDestinataire: formateur.email,
sujet: `Annulation d'inscription - ${formation.nom}`,
statut: "success",
});
console.log(`[Notification] Email d'annulation envoyé au formateur ${formateur.email}`);
} catch (e: any) {
// Logger l'échec
await logNotification({
type: "notification_formateur_annulation",
sequenceId: input.sequenceId,
apprenantId: apprenant.id,
formateurId: formateur.id,
emailDestinataire: formateur.email,
sujet: `Annulation d'inscription - ${formation.nom}`,
statut: "failed",
messageErreur: e.message,
});
console.error(`[Notification] Erreur envoi email formateur:`, e);
}
}
}
// Notifier le premier en liste d'attente qu'une place s'est libérée
const inscriptionsListeAttente = await db.getInscriptionsBySequence(input.sequenceId);
const premierEnAttente = inscriptionsListeAttente
.filter(i => i.inscription.statut === 'en_attente')
.sort((a, b) => new Date(a.inscription.dateInscription).getTime() - new Date(b.inscription.dateInscription).getTime())[0];
if (premierEnAttente && premierEnAttente.apprenant) {
try {
await sendNotificationPlaceDisponible({
apprenantEmail: premierEnAttente.apprenant.email,
apprenantNom: premierEnAttente.apprenant.nom,
apprenantPrenom: premierEnAttente.apprenant.prenom,
apprenantFonction: premierEnAttente.apprenant.fonction || '',
formationNom: formation.nom,
sequenceNom: sequence.nom,
positionListeAttente: 1,
delaiReponse: 48,
});
// Logger la notification
await logNotification({
type: "notification_liste_attente",
sequenceId: input.sequenceId,
apprenantId: premierEnAttente.apprenant.id,
emailDestinataire: premierEnAttente.apprenant.email,
sujet: `Place disponible - ${formation.nom}`,
statut: "success",
});
console.log(`[Notification] Email place disponible envoyé à ${premierEnAttente.apprenant.email}`);
} catch (e: any) {
// Logger l'échec
await logNotification({
type: "notification_liste_attente",
sequenceId: input.sequenceId,
apprenantId: premierEnAttente.apprenant.id,
emailDestinataire: premierEnAttente.apprenant.email,
sujet: `Place disponible - ${formation.nom}`,
statut: "failed",
messageErreur: e.message,
});
console.error(`[Notification] Erreur envoi email liste d'attente:`, e);
}
}
}
return { success: true };
}),
updateStatut: adminProcedure.input(z.object({
id: z.number(),
statut: z.enum(['confirmee', 'en_attente', 'annulee']),
})).mutation(async ({ input }) => {
// Récupérer l'inscription avant modification
const inscriptionAvant = await db.getInscriptionById(input.id);
const ancienStatut = inscriptionAvant?.statut;
await db.updateInscription(input.id, { statut: input.statut });
// Si passage à annulée, notifier le formateur
if (input.statut === 'annulee' && ancienStatut !== 'annulee' && inscriptionAvant) {
const sequence = await db.getSequenceById(inscriptionAvant.sequenceId);
const apprenant = await db.getApprenantById(inscriptionAvant.apprenantId);
if (sequence && apprenant && sequence.formateurId) {
const formateur = await db.getFormateurById(sequence.formateurId);
const formation = await db.getFormationById(sequence.formationId);
if (formateur && formateur.email && formation) {
const nbInscritsApres = await db.countInscriptionsBySequence(sequence.id, 'confirmee');
try {
await sendNotificationFormateurAnnulation({
formateurEmail: formateur.email,
formateurNom: formateur.nom,
apprenantNom: apprenant.nom,
apprenantPrenom: apprenant.prenom,
apprenantFonction: apprenant.fonction || '',
formationNom: formation.nom,
sequenceNom: sequence.nom,
nbInscrits: nbInscritsApres,
capaciteMax: sequence.capaciteMax,
});
console.log(`[Notification] Annulation envoyée au formateur ${formateur.email}`);
// Si une place se libère (passage de capacité max à disponible)
if (nbInscritsApres < sequence.capaciteMax && (nbInscritsApres + 1) >= sequence.capaciteMax) {
const { sendNotificationFormateurPlaceDisponible } = await import('./emailService');
await sendNotificationFormateurPlaceDisponible({
formateurEmail: formateur.email,
formateurNom: formateur.nom,
formationNom: formation.nom,
sequenceNom: sequence.nom,
capaciteMax: sequence.capaciteMax,
nbInscrits: nbInscritsApres,
placesDisponibles: sequence.capaciteMax - nbInscritsApres,
});
console.log(`[Notification] Place disponible envoyée au formateur ${formateur.email}`);
}
} catch (e) {
console.error(`[Notification] Erreur envoi notification formateur:`, e);
}
}
}
}
return { success: true };
}),
sendGroupEmail: adminProcedure.input(z.object({ sequenceId: z.number(), type: z.enum(['teaser', 'rappel1', 'rappel2', 'rappel3', 'rappel4', 'rappel5', 'rappel6']) })).mutation(async ({ input }) => {
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
}
const formation = await db.getFormationById(sequence.formationId);
if (!formation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
}
const dates = await db.getDatesBySequence(sequence.id);
if (dates.length === 0) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune date trouvée pour cette séquence' });
}
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
const recipients = inscriptions
.filter(i => i.inscription.statut === 'confirmee' && i.apprenant)
.map(i => ({
email: i.apprenant!.email,
prenom: i.apprenant!.prenom,
nom: i.apprenant!.nom,
fonction: i.apprenant!.fonction,
}));
console.log(`[Email Group] Envoi de ${recipients.length} emails de type ${input.type}`);
console.log(`[Email Group] Destinataires:`, recipients.map(r => r.email));
let formateurNom: string | undefined;
if (sequence.formateurId) {
const formateur = await db.getFormateurById(sequence.formateurId);
formateurNom = formateur?.nom;
}
const result = await sendGroupEmail({
recipients,
formationNom: formation.nom,
sequenceNom: sequence.nom,
type: input.type,
dates: dates.map(d => ({
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
lieu: sequence.lieu,
formateur: formateurNom,
});
console.log(`[Email Group] Résultat: ${result.sent} envoyés, ${result.failed} échoués`);
return {
...result,
message: `${result.sent} email(s) envoyé(s) avec succès, ${result.failed} échoué(s)`
};
}),
emailPreview: adminProcedure
.input(z.object({
sequenceId: z.number(),
type: z.enum(['teaser', 'rappel1', 'rappel2', 'rappel3', 'rappel4', 'rappel5', 'rappel6']),
apprenantId: z.number().optional(),
}))
.query(async ({ input }) => {
const { generateEmailPreview } = await import('./emailPreview');
return await generateEmailPreview(input);
}),
exportExcel: protectedProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
const formation = await db.getFormationById(sequence.formationId);
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
const dates = await db.getDatesBySequence(input.sequenceId);
const data = inscriptions
.filter(i => i.apprenant)
.map(i => ({
nom: i.apprenant!.nom,
prenom: i.apprenant!.prenom,
email: i.apprenant!.email,
codeEtablissement: i.apprenant!.codeEtablissement,
fonction: i.apprenant!.fonction,
statut: i.inscription.statut,
dateInscription: i.inscription.dateInscription,
}));
const buffer = await generateExcelExport({
formationNom: formation.nom,
sequenceNom: sequence.nom,
dates: dates.map(d => ({
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
lieu: sequence.lieu,
publicCible: sequence.publicCible,
inscriptions: data,
});
return {
buffer: buffer.toString('base64'),
filename: `inscriptions_${sequence.nom.replace(/\s+/g, '_')}.xlsx`,
};
}),
exportPDF: protectedProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
const formation = await db.getFormationById(sequence.formationId);
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
const dates = await db.getDatesBySequence(input.sequenceId);
const data = inscriptions
.filter(i => i.apprenant)
.map(i => ({
nom: i.apprenant!.nom,
prenom: i.apprenant!.prenom,
email: i.apprenant!.email,
codeEtablissement: i.apprenant!.codeEtablissement,
fonction: i.apprenant!.fonction,
statut: i.inscription.statut,
dateInscription: i.inscription.dateInscription,
}));
const buffer = await generatePDFExport({
formationNom: formation.nom,
sequenceNom: sequence.nom,
dates: dates.map(d => ({
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
lieu: sequence.lieu,
publicCible: sequence.publicCible,
inscriptions: data,
});
return {
buffer: buffer.toString('base64'),
filename: `inscriptions_${sequence.nom.replace(/\s+/g, '_')}.pdf`,
};
}),
exportFeuillePresence: protectedProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
const formation = await db.getFormationById(sequence.formationId);
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
const dates = await db.getDatesBySequence(input.sequenceId);
// Récupérer le formateur si défini
let formateurNom: string | undefined = undefined;
if (sequence.formateurId) {
const formateur = await db.getFormateurById(sequence.formateurId);
if (formateur) {
formateurNom = formateur.nom;
}
}
// Récupérer toutes les présences avec signatures pour cette séquence
const { getPresencesBySequence } = await import("./presenceDb");
const presences = await getPresencesBySequence(input.sequenceId);
const data = inscriptions
.filter(i => i.inscription.statut === 'confirmee' && i.apprenant)
.map(i => {
// Récupérer les présences de cet apprenant
const apprenantPresences = presences
.filter(p => p.inscriptionId === i.inscription.id)
.map(p => ({
dateFormationId: p.dateFormationId,
signatureUrl: p.signatureUrl,
}));
return {
nom: i.apprenant!.nom,
prenom: i.apprenant!.prenom,
codeEtablissement: i.apprenant!.codeEtablissement,
fonction: i.apprenant!.fonction,
presences: apprenantPresences,
};
});
const buffer = await generateFeuillePresence({
formationNom: formation.nom,
sequenceNom: sequence.nom,
dates: dates.map(d => ({
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
lieu: sequence.lieu,
publicCible: sequence.publicCible,
formateur: formateurNom,
apprenants: data,
});
return {
buffer: buffer.toString('base64'),
filename: `feuille_presence_${sequence.nom.replace(/\s+/g, '_')}.pdf`,
};
}),
// Ajout manuel d'un inscrit par l'admin (sans vérifications de blocage/capacité)
create: adminProcedure.input(z.object({
apprenantId: z.number(),
sequenceId: z.number(),
statut: z.enum(["confirme", "liste_attente"]),
})).mutation(async ({ input }) => {
// Vérifier si l'apprenant existe
const apprenant = await db.getApprenantById(input.apprenantId);
if (!apprenant) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Apprenant introuvable' });
}
// Vérifier si la séquence existe
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
}
// Vérifier si l'apprenant est déjà inscrit
const existing = await db.checkExistingInscription(input.apprenantId, input.sequenceId);
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Cet apprenant est déjà inscrit à cette séquence'
});
}
// Créer l'inscription avec le statut choisi
await db.createInscription({
apprenantId: input.apprenantId,
sequenceId: input.sequenceId,
statut: input.statut === "confirme" ? "confirmee" : "en_attente",
});
return { success: true };
}),
}),
// ===== GESTION DES UTILISATEURS =====
users: router({
list: adminProcedure.query(async () => {
return db.getAllUsers();
}),
listWithFormateurs: adminProcedure.query(async () => {
const dbInstance = await import("./db").then(m => m.getDb());
if (!dbInstance) throw new Error("Base de données non disponible");
const { formateurs, users } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
// Récupérer tous les formateurs
const allFormateurs = await dbInstance.select().from(formateurs);
// Récupérer tous les utilisateurs
const allUsers = await dbInstance.select().from(users);
// Créer une map des formateurs liés à des comptes
const formateurIdToUser = new Map();
allUsers.forEach(user => {
if (user.formateurId) {
formateurIdToUser.set(user.formateurId, user);
}
});
// Combiner les données
return {
users: allUsers,
formateurs: allFormateurs.map(formateur => ({
...formateur,
hasAccount: formateurIdToUser.has(formateur.id),
user: formateurIdToUser.get(formateur.id) || null,
})),
};
}),
getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
return db.getUserById(input.id);
}),
create: adminProcedure.input(z.object({
openId: z.string().min(1),
name: z.string().optional(),
email: z.string().email().optional(),
username: z.string().min(3).optional(),
password: z.string().min(6).optional(),
role: z.enum(["user", "admin", "formateur"]).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');
// Sauvegarder le token en base (la fonction gère l'expiration automatiquement)
await db.createPasswordResetToken(user.email, token);
// Générer le lien de réinitialisation
// TODO: Remplacer par l'URL réelle de votre application en production
const resetLink = `https://votre-domaine.com/reset-password?token=${token}`;
// Envoyer l'email
const emailService = await import('./emailService');
const emailSent = await emailService.sendPasswordResetEmail({
apprenantEmail: user.email,
apprenantNom: user.name || 'Utilisateur',
apprenantPrenom: '',
resetLink,
});
if (!emailSent) {
throw new Error("Échec de l'envoi de l'email");
}
return { success: true, message: "Email de réinitialisation envoyé" };
}),
}),
// ===== GESTION DES TEMPLATES D'EMAILS =====
emailTemplates: router({
list: adminProcedure.query(async () => {
return db.getAllEmailTemplates();
}),
getByType: adminProcedure
.input(z.object({ type: z.string() }))
.query(async ({ input }) => {
return db.getEmailTemplateByType(input.type);
}),
upsert: adminProcedure
.input(z.object({
type: z.enum(["inscription", "teaser", "rappel1", "rappel2", "rappel3", "rappel4", "rappel5", "rappel6", "reset_password"]),
titre: z.string(),
bodyContent: z.string(),
couleurPrincipale: z.string(),
couleurSecondaire: z.string(),
piedDePage: z.string().nullable(),
}))
.mutation(async ({ input }) => {
return db.upsertEmailTemplate(input);
}),
delete: adminProcedure
.input(z.object({ type: z.string() }))
.mutation(async ({ input }) => {
await db.deleteEmailTemplate(input.type);
return { success: true };
}),
initializeDefaults: adminProcedure.mutation(async () => {
await db.initializeDefaultEmailTemplates();
return { success: true };
}),
}),
// ===== GESTION DES RAPPELS =====
rappels: router({
list: adminProcedure.query(async () => {
return db.getRappels();
}),
create: adminProcedure
.input(z.object({
nom: z.string(),
templateType: z.enum(["rappel1", "rappel2", "rappel3", "rappel4", "rappel5", "rappel6"]),
timing: z.enum(["pre_formation", "post_formation"]).default("pre_formation"),
joursAvant: z.number(),
heureEnvoi: z.string().default("09:00"),
actif: z.boolean(),
dateFormationIds: z.array(z.number()).optional(),
fichier: z.object({
nomFichier: z.string(),
urlFichier: z.string(),
s3Key: z.string(),
typeFichier: z.string(),
tailleFichier: z.number(),
}).nullable().optional(),
}))
.mutation(async ({ input }) => {
const { dateFormationIds, ...rappelData } = input;
const result: any = await db.createRappel(rappelData);
// Créer les associations rappel-date si des dates sont spécifiées
if (dateFormationIds && dateFormationIds.length > 0) {
const rappelId = result.insertId || result[0]?.insertId;
if (rappelId) {
await db.createRappelDates(Number(rappelId), dateFormationIds);
}
}
return result;
}),
update: adminProcedure
.input(z.object({
id: z.number(),
nom: z.string().optional(),
templateType: z.enum(["rappel1", "rappel2", "rappel3", "rappel4", "rappel5", "rappel6"]).optional(),
timing: z.enum(["pre_formation", "post_formation"]).optional(),
joursAvant: z.number().optional(),
heureEnvoi: z.string().optional(),
actif: z.boolean().optional(),
dateFormationIds: z.array(z.number()).optional(),
fichier: z.object({
nomFichier: z.string(),
urlFichier: z.string(),
s3Key: z.string(),
typeFichier: z.string(),
tailleFichier: z.number(),
}).nullable().optional(),
}))
.mutation(async ({ input }) => {
const { id, dateFormationIds, ...data } = input;
await db.updateRappel(id, data);
// Mettre à jour les associations rappel-date si spécifiées
if (dateFormationIds !== undefined) {
await db.deleteRappelDates(id);
if (dateFormationIds.length > 0) {
await db.createRappelDates(id, dateFormationIds);
}
}
return { success: true };
}),
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
await db.deleteRappel(input.id);
return { success: true };
}),
toggleActif: adminProcedure
.input(z.object({ id: z.number(), actif: z.boolean() }))
.mutation(async ({ input }) => {
await db.updateRappel(input.id, { actif: input.actif });
return { success: true };
}),
envoyerAutomatiques: publicProcedure
.mutation(async () => {
const { processRappelsAutomatiques } = await import('./rappelScheduler');
const result = await processRappelsAutomatiques();
return result;
}),
testerRappel: adminProcedure
.input(z.object({
sequenceId: z.number(),
emailTest: z.string().email().optional(),
}))
.mutation(async ({ input }) => {
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
}
const formation = await db.getFormationById(sequence.formationId);
if (!formation) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
}
// Récupérer le formateur si présent
let formateurNom: string | undefined;
if (sequence.formateurId) {
const formateur = await db.getFormateurById(sequence.formateurId);
formateurNom = formateur?.nom;
}
// Récupérer les dates de la séquence
const dates = await db.getDatesBySequence(input.sequenceId);
// Récupérer les rappels configurés pour la première date de la séquence
if (dates.length === 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cette séquence n\'a pas de dates configurées' });
}
const rappelsConfigures = await db.getRappelsByDateFormation(dates[0].id);
const rappelsActifs = rappelsConfigures.filter((r: any) => r.actif);
if (rappelsActifs.length === 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Aucun rappel actif configuré pour cette séquence' });
}
// Utiliser le premier rappel actif trouvé
const rappelAUtiliser = rappelsActifs[0];
// Récupérer les inscrits
const inscrits = await db.getInscriptionsBySequence(input.sequenceId);
const inscritsConfirmes = inscrits.filter(i => i.inscription.statut === 'confirmee');
if (inscritsConfirmes.length === 0) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Aucun apprenant inscrit confirmé pour cette séquence' });
}
// Envoyer le rappel de test
const { sendRappelEmail } = await import('./emailService');
let nbEnvoyes = 0;
let nbEchecs = 0;
const erreurs: string[] = [];
// Si un email de test est fourni, envoyer un email par inscrit confirmé à cet email
// Sinon, envoyer à l'email réel de chaque inscrit
const destinataires = input.emailTest
? inscritsConfirmes.map(i => ({ apprenant: i.apprenant, emailOverride: input.emailTest }))
: inscritsConfirmes.map(i => ({ apprenant: i.apprenant, emailOverride: null }));
for (const { apprenant, emailOverride } of destinataires) {
if (!apprenant || (!apprenant.email && !emailOverride)) {
nbEchecs++;
erreurs.push(`Apprenant ${apprenant?.nom || 'inconnu'}: email manquant`);
continue;
}
const emailDestinataire = emailOverride || apprenant.email;
try {
await sendRappelEmail({
apprenantEmail: emailDestinataire,
apprenantPrenom: apprenant.prenom,
apprenantNom: apprenant.nom,
apprenantFonction: apprenant.fonction || '',
formationNom: formation.nom,
sequenceNom: sequence.nom,
dates: dates.map((d: any) => ({
dateDebut: d.dateDebut,
dateFin: d.dateFin,
ordre: d.ordre
})),
lieu: sequence.lieu || '',
formateur: formateurNom,
templateType: rappelAUtiliser.templateType,
attachmentUrl: rappelAUtiliser.urlFichier || undefined,
attachmentFilename: rappelAUtiliser.nomFichier || undefined,
attachmentMimeType: rappelAUtiliser.typeFichier || undefined,
});
// Enregistrer dans l'historique avec typeEnvoi = 'test'
await db.createLogRappel({
rappelId: rappelAUtiliser.id,
sequenceId: input.sequenceId,
apprenantId: apprenant.id,
email: emailDestinataire,
type: rappelAUtiliser.templateType,
typeEnvoi: 'test',
statut: 'succes',
dateEnvoi: new Date(),
});
nbEnvoyes++;
} catch (error: any) {
nbEchecs++;
erreurs.push(`${apprenant.email}: ${error.message}`);
// Enregistrer l'échec dans l'historique
try {
await db.createLogRappel({
rappelId: rappelAUtiliser.id,
sequenceId: input.sequenceId,
apprenantId: apprenant.id,
email: emailDestinataire,
type: rappelAUtiliser.templateType,
typeEnvoi: 'test',
statut: 'echec',
messageErreur: error.message,
dateEnvoi: new Date(),
});
} catch (logError) {
console.error('[LogRappel] Erreur lors de l\'enregistrement du log:', logError);
}
}
}
if (nbEnvoyes === 0) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `Aucun email envoyé. Erreurs: ${erreurs.join(', ')}`,
});
}
return {
success: true,
message: `Rappel de test envoyé à ${nbEnvoyes} apprenant(s)${nbEchecs > 0 ? ` (${nbEchecs} échec(s))` : ''}`,
nbEnvoyes,
nbEchecs,
erreurs: nbEchecs > 0 ? erreurs : undefined,
};
}),
historique: adminProcedure
.input(z.object({
dateDebut: z.string().optional(),
dateFin: z.string().optional(),
sequenceId: z.number().optional(),
statut: z.enum(['succes', 'echec']).optional(),
email: z.string().optional(),
limit: z.number().default(100),
offset: z.number().default(0),
}))
.query(async ({ input }) => {
const { getLogsRappelsWithFilters } = await import('./rappelDb');
return getLogsRappelsWithFilters({
dateDebut: input.dateDebut ? new Date(input.dateDebut) : undefined,
dateFin: input.dateFin ? new Date(input.dateFin) : undefined,
sequenceId: input.sequenceId,
statut: input.statut,
email: input.email,
limit: input.limit,
offset: input.offset,
});
}),
statistiques: adminProcedure.query(async () => {
const { getStatsRappels } = await import('./rappelDb');
return getStatsRappels();
}),
evolution: adminProcedure.query(async () => {
const { getEvolutionEnvois } = await import('./rappelDb');
return getEvolutionEnvois();
}),
// Nettoyer les anciens logs erronés (avant la correction du schéma)
nettoyerLogsErrones: adminProcedure
.mutation(async () => {
const database = await db.getDb();
if (!database) throw new Error("Database not available");
// Supprimer tous les logs qui n'ont pas d'email (champ obligatoire manquant)
const result = await database.execute(sql`
DELETE FROM logsRappels
WHERE email IS NULL OR email = ''
`);
const nbSupprimes = Array.isArray(result) ? 0 : (result as any).affectedRows || 0;
return {
success: true,
nbSupprimes,
message: `${nbSupprimes} log(s) erroné(s) supprimé(s)`,
};
}),
// Vider complètement la table des logs de rappels
viderHistoriqueRappels: adminProcedure
.mutation(async () => {
const database = await db.getDb();
if (!database) throw new Error("Database not available");
// Supprimer tous les logs de rappels
const result = await database.execute(sql`
DELETE FROM logsrappels
`);
const nbSupprimes = Array.isArray(result) ? 0 : (result as any).affectedRows || 0;
return {
success: true,
nbSupprimes,
message: `${nbSupprimes} log(s) supprimé(s) de l'historique`,
};
}),
}),
emailConfig: router({
get: adminProcedure.query(async () => {
return db.getActiveEmailConfig();
}),
upsert: adminProcedure
.input(z.object({
provider: z.enum(["resend", "smtp", "simulation"]),
apiKey: z.string().nullable(),
fromEmail: z.string().email(),
fromName: z.string(),
mode: z.enum(["simulation", "production"]),
domainVerified: z.boolean(),
// Champs SMTP
smtpHost: z.string().nullable().optional(),
smtpPort: z.number().nullable().optional(),
smtpSecure: z.enum(["none", "tls", "ssl"]).nullable().optional(),
smtpUser: z.string().nullable().optional(),
smtpPassword: z.string().nullable().optional(),
}))
.mutation(async ({ input }) => {
await db.upsertEmailConfig(input);
return { success: true };
}),
testEmail: adminProcedure
.input(z.object({
toEmail: z.string().email(),
}))
.mutation(async ({ input }) => {
// Importer le service d'envoi
const { sendEmail } = await import("./_core/emailSender");
try {
const success = await sendEmail({
to: input.toEmail,
subject: "Test d'envoi d'email - Formation Manager Itinova",
html: `
<h1>Test réussi !</h1>
<p>Cet email de test a été envoyé avec succès depuis votre configuration SMTP.</p>
<p>Votre configuration d'envoi d'emails fonctionne correctement.</p>
`,
});
return { success, message: success ? "Email envoyé avec succès" : "Échec de l'envoi" };
} catch (error: any) {
return { success: false, message: error.message };
}
}),
}),
// ===== PARAMÈTRES DE L'APPLICATION =====
parametres: router({
get: adminProcedure.query(async () => {
const { getParametres } = await import("./parametresDb");
return getParametres();
}),
update: adminProcedure
.input(z.object({
urlPublique: z.string().url().optional(),
delaiExpirationQR: z.number().int().positive().optional(),
dureeValiditeToken: z.number().int().positive().optional(),
notificationsActives: z.boolean().optional(),
}))
.mutation(async ({ input, ctx }) => {
const { updateParametres } = await import("./parametresDb");
await updateParametres(input, ctx.user.id, ctx.user.name || "Administrateur");
return { success: true };
}),
updateUrlPublique: adminProcedure
.input(z.object({
urlPublique: z.string().url(),
}))
.mutation(async ({ input, ctx }) => {
const { updateUrlPublique } = await import("./parametresDb");
await updateUrlPublique(input.urlPublique, ctx.user.id, ctx.user.name || "Administrateur");
return { success: true };
}),
testerUrl: adminProcedure
.input(z.object({
url: z.string(),
}))
.mutation(async ({ input }) => {
const { testerUrl } = await import("./parametresDb");
return testerUrl(input.url);
}),
getHistorique: adminProcedure
.input(z.object({
limit: z.number().int().positive().default(50),
}))
.query(async ({ input }) => {
const { getHistoriqueParametres } = await import("./parametresDb");
return getHistoriqueParametres(input.limit);
}),
}),
// ===== ANALYTICS =====
analytics: router({
globalStats: adminProcedure.query(async () => {
return analyticsDb.getGlobalStats();
}),
inscriptionsByMonth: adminProcedure
.input(z.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
}))
.query(async ({ input }) => {
const startDate = input.startDate ? new Date(input.startDate) : undefined;
const endDate = input.endDate ? new Date(input.endDate) : undefined;
return analyticsDb.getInscriptionsByMonth(startDate, endDate);
}),
tauxRemplissageByMonth: adminProcedure.query(async () => {
return analyticsDb.getTauxRemplissageByMonth();
}),
participationByEtablissement: adminProcedure.query(async () => {
return analyticsDb.getParticipationByEtablissement();
}),
participationByFonction: adminProcedure.query(async () => {
return analyticsDb.getParticipationByFonction();
}),
exportExcel: adminProcedure
.input(z.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
}))
.mutation(async ({ input }) => {
const startDate = input.startDate ? new Date(input.startDate) : undefined;
const endDate = input.endDate ? new Date(input.endDate) : undefined;
const buffer = await generateAnalyticsExcel(startDate, endDate);
return {
data: buffer.toString('base64'),
filename: `rapport-analytique-${new Date().toISOString().split('T')[0]}.xlsx`,
};
}),
exportPDF: adminProcedure
.input(z.object({
startDate: z.string().optional(),
endDate: z.string().optional(),
}))
.mutation(async ({ input }) => {
const startDate = input.startDate ? new Date(input.startDate) : undefined;
const endDate = input.endDate ? new Date(input.endDate) : undefined;
const buffer = await generateAnalyticsPDF(startDate, endDate);
return {
data: buffer.toString('base64'),
filename: `rapport-analytique-${new Date().toISOString().split('T')[0]}.pdf`,
};
}),
}),
// ===== QUESTIONNAIRES =====
questionnaires: router({
list: adminProcedure.query(async () => {
const questionnaireDb = await import("./questionnaireDb");
return questionnaireDb.getAllQuestionnaires();
}),
getById: adminProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
return questionnaireDb.getQuestionnaireById(input.id);
}),
create: adminProcedure
.input(z.object({
titre: z.string(),
type: z.enum(["satisfaction", "evaluation_pre", "evaluation_post"]),
description: z.string().optional(),
formationId: z.number().nullable().optional(),
actif: z.boolean().default(true),
envoiAutomatique: z.boolean().default(false),
delaiEnvoiJours: z.number().default(1),
}))
.mutation(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
const id = await questionnaireDb.createQuestionnaire(input);
return { id, success: true };
}),
update: adminProcedure
.input(z.object({
id: z.number(),
titre: z.string().optional(),
type: z.enum(["satisfaction", "evaluation_pre", "evaluation_post"]).optional(),
description: z.string().optional(),
formationId: z.number().nullable().optional(),
actif: z.boolean().optional(),
envoiAutomatique: z.boolean().optional(),
delaiEnvoiJours: z.number().optional(),
}))
.mutation(async ({ input }) => {
const { id, ...data } = input;
const questionnaireDb = await import("./questionnaireDb");
await questionnaireDb.updateQuestionnaire(id, data);
return { success: true };
}),
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
await questionnaireDb.deleteQuestionnaire(input.id);
return { success: true };
}),
getStats: adminProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
return questionnaireDb.getQuestionnaireStats(input.id);
}),
getReponsesDetaillees: adminProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
return questionnaireDb.getReponsesDetailleesQuestionnaire(input.id);
}),
exportExcel: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const { exportQuestionnaireToExcel } = await import("./questionnaireExport");
const buffer = await exportQuestionnaireToExcel(input.id);
return {
data: buffer.toString('base64'),
filename: `questionnaire-${input.id}-${new Date().toISOString().split('T')[0]}.xlsx`,
};
}),
exportPDF: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const { exportQuestionnaireToPDF } = await import("./questionnaireExport");
const buffer = await exportQuestionnaireToPDF(input.id);
return {
data: buffer.toString('base64'),
filename: `questionnaire-${input.id}-${new Date().toISOString().split('T')[0]}.pdf`,
};
}),
}),
// ===== QUESTIONS =====
questions: router({
list: adminProcedure
.input(z.object({ questionnaireId: z.number() }))
.query(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
return questionnaireDb.getQuestionsByQuestionnaireId(input.questionnaireId);
}),
create: adminProcedure
.input(z.object({
questionnaireId: z.number(),
ordre: z.number(),
texte: z.string(),
typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]),
options: z.string().optional(),
echelleMin: z.number().optional(),
echelleMax: z.number().optional(),
echelleLabelMin: z.string().optional(),
echelleLabelMax: z.string().optional(),
obligatoire: z.boolean().default(false),
}))
.mutation(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
const id = await questionnaireDb.createQuestion(input);
return { id, success: true };
}),
update: adminProcedure
.input(z.object({
id: z.number(),
ordre: z.number().optional(),
texte: z.string().optional(),
typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]).optional(),
options: z.string().optional(),
echelleMin: z.number().optional(),
echelleMax: z.number().optional(),
echelleLabelMin: z.string().optional(),
echelleLabelMax: z.string().optional(),
obligatoire: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
const { id, ...data } = input;
const questionnaireDb = await import("./questionnaireDb");
await questionnaireDb.updateQuestion(id, data);
return { success: true };
}),
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
await questionnaireDb.deleteQuestion(input.id);
return { success: true };
}),
}),
// ===== RÉPONSES QUESTIONNAIRES (PUBLIC) =====
reponses: router({
getByToken: publicProcedure
.input(z.object({ token: z.string() }))
.query(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
const envoi = await questionnaireDb.getEnvoiByToken(input.token);
if (!envoi) return null;
const questionnaire = await questionnaireDb.getQuestionnaireById(envoi.questionnaireId);
const questions = await questionnaireDb.getQuestionsByQuestionnaireId(envoi.questionnaireId);
return {
envoi,
questionnaire,
questions,
};
}),
submit: publicProcedure
.input(z.object({
token: z.string(),
reponses: z.array(z.object({
questionId: z.number(),
reponseNumerique: z.number().optional(),
reponseTexte: z.string().optional(),
})),
}))
.mutation(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
// Récupérer l'envoi
const envoi = await questionnaireDb.getEnvoiByToken(input.token);
if (!envoi) throw new Error("Token invalide");
if (envoi.dateReponse) throw new Error("Questionnaire déjà répondu");
// Créer la réponse au questionnaire
const reponseQuestionnaireId = await questionnaireDb.createReponseQuestionnaire({
questionnaireId: envoi.questionnaireId,
apprenantId: envoi.apprenantId,
sequenceId: envoi.sequenceId,
});
// Enregistrer chaque réponse
for (const reponse of input.reponses) {
await questionnaireDb.createReponseQuestion({
reponseQuestionnaireId,
questionId: reponse.questionId,
reponseNumerique: reponse.reponseNumerique,
reponseTexte: reponse.reponseTexte,
});
}
// Mettre à jour l'envoi
await questionnaireDb.updateEnvoiQuestionnaire(envoi.id, {
dateReponse: new Date(),
});
// Envoyer une notification à l'administrateur
try {
const { notifyOwner } = await import('./_core/notification');
const questionnaire = await questionnaireDb.getQuestionnaireById(envoi.questionnaireId);
await notifyOwner({
title: 'Nouvelle réponse au questionnaire',
content: `Un apprenant a répondu au questionnaire "${questionnaire?.titre || 'Sans titre'}"`,
});
} catch (error) {
console.error('[Notification] Erreur lors de l\'envoi de la notification:', error);
// Ne pas bloquer la réponse si la notification échoue
}
return { success: true };
}),
envoyerAutomatiques: adminProcedure
.mutation(async () => {
const { processEnvoisAutomatiques } = await import('./questionnaireScheduler');
const result = await processEnvoisAutomatiques();
return result;
}),
}),
// ===== SUIVI QUESTIONNAIRES =====
questionnaireSuivi: router({
statsGlobales: adminProcedure.query(async () => {
const suiviDb = await import("./questionnaireSuiviDb");
return suiviDb.getStatsGlobales();
}),
statsByFormation: adminProcedure.query(async () => {
const suiviDb = await import("./questionnaireSuiviDb");
return suiviDb.getStatsByFormation();
}),
evolutionTemporelle: adminProcedure
.input(z.object({
startDate: z.date().optional(),
endDate: z.date().optional(),
}))
.query(async ({ input }) => {
const suiviDb = await import("./questionnaireSuiviDb");
return suiviDb.getEvolutionTemporelle(input.startDate, input.endDate);
}),
statsByFormateur: adminProcedure.query(async () => {
const suiviDb = await import("./questionnaireSuiviDb");
return suiviDb.getStatsByFormateur();
}),
}),
// ===== ESPACE FORMATEUR =====
formateur: router({
calendrier: protectedProcedure
.input(z.object({
formateurId: z.number(),
dateDebut: z.date().optional(),
dateFin: z.date().optional(),
}))
.query(async ({ input }) => {
const formateurDb = await import("./formateurDb");
return formateurDb.getCalendrierFormateur(input.formateurId, input.dateDebut, input.dateFin);
}),
apprenants: protectedProcedure
.input(z.object({ sequenceId: z.number() }))
.query(async ({ input }) => {
const formateurDb = await import("./formateurDb");
return formateurDb.getApprenantsSequence(input.sequenceId);
}),
supports: protectedProcedure
.input(z.object({ sequenceId: z.number() }))
.query(async ({ input }) => {
const formateurDb = await import("./formateurDb");
return formateurDb.getSupportsSequence(input.sequenceId);
}),
ajouterSupport: protectedProcedure
.input(z.object({
sequenceId: z.number(),
formateurId: z.number(),
nomFichier: z.string(),
typeFichier: z.string(),
tailleFichier: z.number(),
urlFichier: z.string(),
s3Key: z.string(),
description: z.string().optional(),
}))
.mutation(async ({ input }) => {
const formateurDb = await import("./formateurDb");
return formateurDb.ajouterSupport(input);
}),
supprimerSupport: protectedProcedure
.input(z.object({
supportId: z.number(),
formateurId: z.number(),
}))
.mutation(async ({ input }) => {
const formateurDb = await import("./formateurDb");
const support = await formateurDb.supprimerSupport(input.supportId, input.formateurId);
// Note: La suppression du fichier S3 devrait être gérée par un processus de nettoyage séparé
// car il n'y a pas de fonction storageDelete dans l'API actuelle
return { success: true, support };
}),
validerPresence: protectedProcedure
.input(z.object({
inscriptionId: z.number(),
present: z.boolean(),
}))
.mutation(async ({ input }) => {
const formateurDb = await import("./formateurDb");
await formateurDb.validerPresence(input.inscriptionId, input.present);
return { success: true };
}),
historique: protectedProcedure
.input(z.object({
formateurId: z.number(),
limit: z.number().optional(),
}))
.query(async ({ input }) => {
const formateurDb = await import("./formateurDb");
return formateurDb.getHistoriqueFormateur(input.formateurId, input.limit);
}),
detailSequence: protectedProcedure
.input(z.object({
sequenceId: z.number(),
formateurId: z.number(),
}))
.query(async ({ input }) => {
const formateurDb = await import("./formateurDb");
return formateurDb.getDetailSequence(input.sequenceId, input.formateurId);
}),
}),
// ===== ÉTABLISSEMENTS =====
etablissements: router({
list: adminProcedure.query(async () => {
const { getEtablissementsStats } = await import("./etablissementsDb");
return getEtablissementsStats();
}),
getDetail: adminProcedure
.input(z.object({ codeEtablissement: z.string() }))
.query(async ({ input }) => {
const { getEtablissementDetail } = await import("./etablissementsDb");
return getEtablissementDetail(input.codeEtablissement);
}),
}),
// ===== NOTIFICATIONS =====
notifications: router({
envoyerRemerciements: adminProcedure
.input(z.object({ sequenceId: z.number() }))
.mutation(async ({ input }) => {
const { envoyerRemerciementsSequence } = await import("./remerciementScheduler");
return envoyerRemerciementsSequence(input.sequenceId);
}),
envoyerRemerciementsAutomatiques: adminProcedure
.mutation(async () => {
const { processRemerciementsAutomatiques } = await import("./remerciementScheduler");
await processRemerciementsAutomatiques();
return { success: true };
}),
// Historique des notifications
historique: adminProcedure
.input(z.object({
type: z.enum(["remerciement", "notification_formateur_inscription", "notification_formateur_annulation", "alerte_capacite", "notification_liste_attente"]).optional(),
dateDebut: z.string().optional(),
dateFin: z.string().optional(),
statut: z.enum(["success", "failed"]).optional(),
email: z.string().optional(),
sequenceId: z.number().optional(),
limit: z.number().default(50),
offset: z.number().default(0),
}))
.query(async ({ input }) => {
const { getNotificationLogs } = await import("./notificationLogsDb");
return getNotificationLogs({
type: input.type,
dateDebut: input.dateDebut ? new Date(input.dateDebut) : undefined,
dateFin: input.dateFin ? new Date(input.dateFin) : undefined,
statut: input.statut,
email: input.email,
sequenceId: input.sequenceId,
limit: input.limit,
offset: input.offset,
});
}),
// Statistiques des notifications
statistiques: adminProcedure
.input(z.object({
dateDebut: z.string().optional(),
dateFin: z.string().optional(),
}))
.query(async ({ input }) => {
const { getNotificationStats } = await import("./notificationLogsDb");
return getNotificationStats({
dateDebut: input.dateDebut ? new Date(input.dateDebut) : undefined,
dateFin: input.dateFin ? new Date(input.dateFin) : undefined,
});
}),
}),
// Attestations de formation
attestations: router({
// Générer une attestation pour une inscription
generer: adminProcedure
.input(z.object({
inscriptionId: z.number(),
}))
.mutation(async ({ input }) => {
const { genererAttestationPDF, enregistrerAttestation, getAttestation } = await import("./attestationService");
const db = await import("./db").then(m => m.getDb());
if (!db) throw new Error("Base de données non disponible");
// Vérifier si l'attestation existe déjà
const { inscriptions } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
const [inscription] = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
if (!inscription) {
throw new Error("Inscription introuvable");
}
const attestationExistante = await getAttestation(inscription.apprenantId, inscription.sequenceId);
if (attestationExistante) {
return {
success: true,
attestation: attestationExistante,
message: "Attestation déjà générée",
};
}
// Générer le PDF
const { s3Key, pdfUrl } = await genererAttestationPDF(input.inscriptionId);
// Enregistrer dans la base
const attestationId = await enregistrerAttestation(
input.inscriptionId,
inscription.apprenantId,
inscription.sequenceId,
s3Key,
pdfUrl
);
const attestation = await getAttestation(inscription.apprenantId, inscription.sequenceId);
return {
success: true,
attestation,
message: "Attestation générée avec succès",
};
}),
// Récupérer l'attestation d'un apprenant pour une séquence
get: publicProcedure
.input(z.object({
apprenantId: z.number(),
sequenceId: z.number(),
}))
.query(async ({ input }) => {
const { getAttestation } = await import("./attestationService");
return getAttestation(input.apprenantId, input.sequenceId);
}),
// Récupérer toutes les attestations d'un apprenant
listApprenant: publicProcedure
.input(z.object({
apprenantId: z.number(),
}))
.query(async ({ input }) => {
const { getAttestationsApprenant } = await import("./attestationService");
return getAttestationsApprenant(input.apprenantId);
}),
// Récupérer la configuration des attestations
getConfig: adminProcedure
.query(async () => {
const { getOrCreateConfigAttestation } = await import("./attestationService");
return getOrCreateConfigAttestation();
}),
// Mettre à jour la configuration des attestations
updateConfig: adminProcedure
.input(z.object({
nomSignataire: z.string().optional(),
fonctionSignataire: z.string().optional(),
texteAttestation: z.string().optional(),
logoUrl: z.string().optional(),
logoS3Key: z.string().optional(),
signatureUrl: z.string().optional(),
signatureS3Key: z.string().optional(),
}))
.mutation(async ({ input }) => {
const { updateConfigAttestation } = await import("./attestationService");
await updateConfigAttestation(input);
return { success: true };
}),
// Récupérer l'historique des envois d'attestations
historique: adminProcedure
.query(async () => {
const { getHistoriqueAttestations } = await import("./attestationsDb");
return getHistoriqueAttestations();
}),
// Récupérer l'historique pour une séquence spécifique
historiqueBySequence: adminProcedure
.input(z.object({
sequenceId: z.number(),
}))
.query(async ({ input }) => {
const { getHistoriqueAttestationsBySequence } = await import("./attestationsDb");
return getHistoriqueAttestationsBySequence(input.sequenceId);
}),
// Générer une prévisualisation du modèle d'attestation
previsualiser: adminProcedure
.mutation(async () => {
const { genererPreviewAttestation } = await import("./attestationService");
return genererPreviewAttestation();
}),
// Uploader la signature manuscrite d'un apprenant
uploadSignature: publicProcedure
.input(z.object({
attestationId: z.number(),
signatureDataUrl: z.string(),
}))
.mutation(async ({ input }) => {
const { storagePut } = await import("./storage");
const { getDb } = await import("./db");
const { attestations } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
const db = await getDb();
if (!db) throw new Error("Base de données non disponible");
// Convertir le data URL en buffer
const base64Data = input.signatureDataUrl.replace(/^data:image\/png;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
// Générer un nom de fichier unique
const fileName = `signatures/attestation-${input.attestationId}-${Date.now()}.png`;
// Uploader vers S3
const { url, key } = await storagePut(fileName, buffer, "image/png");
// Mettre à jour l'attestation dans la base de données
await db
.update(attestations)
.set({
signatureUrl: url,
signatureS3Key: key,
dateSigned: new Date(),
})
.where(eq(attestations.id, input.attestationId));
return {
success: true,
signatureUrl: url,
};
}),
}),
// ===== PRESENCES (ÉMARGEMENT NUMÉRIQUE) =====
presences: router({
// Générer un QR code pour une séquence
generateQRCode: protectedProcedure
.input(z.object({
sequenceId: z.number(),
}))
.mutation(async ({ input, ctx }) => {
const { generateQRToken, generateQRCodeDataURL } = await import("./qrCodeGenerator");
const { getDb } = await import("./db");
const { sequences } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
const db = await getDb();
if (!db) throw new Error("Database not available");
// Vérifier que la séquence existe
const sequence = await db.select().from(sequences).where(eq(sequences.id, input.sequenceId)).limit(1);
if (sequence.length === 0) {
throw new Error("Séquence introuvable");
}
// Générer un nouveau token si nécessaire
let token = sequence[0].qrCodeToken;
if (!token) {
token = generateQRToken();
await db.update(sequences).set({ qrCodeToken: token }).where(eq(sequences.id, input.sequenceId));
}
// Générer le QR code
const qrCodeDataURL = await generateQRCodeDataURL(token);
return {
token,
qrCodeDataURL,
};
}),
// Valider une présence (scan QR code ou manuel)
valider: publicProcedure
.input(z.object({
token: z.string().optional(),
inscriptionId: z.number(),
dateFormationId: z.number(),
periode: z.enum(["matin", "apres_midi"]),
modeValidation: z.enum(["qrcode", "manuel"]),
validateurId: z.number().optional(),
commentaire: z.string().optional(),
signatureDataUrl: 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");
const db = await getDb();
if (!db) throw new Error("Database not available");
// Si mode QR code, vérifier le token
if (input.modeValidation === "qrcode" && input.token) {
// Récupérer l'inscription pour vérifier la séquence
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");
}
}
// Stocker la signature localement si fournie
let signatureUrl: string | undefined;
let signatureS3Key: string | undefined;
if (input.signatureDataUrl) {
const fs = await import("fs");
const path = await import("path");
const crypto = await import("crypto");
// Récupérer l'inscription pour obtenir la séquence
const inscriptionData = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
if (inscriptionData.length === 0) throw new Error("Inscription introuvable");
const sequenceData = await db.select().from(sequences).where(eq(sequences.id, inscriptionData[0].sequenceId)).limit(1);
if (sequenceData.length === 0) throw new Error("Séquence introuvable");
const formationId = sequenceData[0].formationId;
const sequenceId = sequenceData[0].id;
// Créer la structure de dossiers
const uploadsDir = path.resolve(process.cwd(), "uploads", "signatures", `${formationId}-${sequenceId}`);
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
// Générer un nom de fichier unique
const randomSuffix = crypto.randomBytes(8).toString("hex");
const fileName = `signature-${input.inscriptionId}-${input.dateFormationId}-${randomSuffix}.png`;
const filePath = path.join(uploadsDir, fileName);
// Convertir le data URL en buffer et sauvegarder
const base64Data = input.signatureDataUrl.split(",")[1];
const buffer = Buffer.from(base64Data, "base64");
fs.writeFileSync(filePath, buffer);
// Générer l'URL publique
signatureUrl = `/uploads/signatures/${formationId}-${sequenceId}/${fileName}`;
signatureS3Key = `signatures/${formationId}-${sequenceId}/${fileName}`;
}
const result = await validerPresence({
inscriptionId: input.inscriptionId,
dateFormationId: input.dateFormationId,
periode: input.periode,
modeValidation: input.modeValidation,
validateurId: input.validateurId,
commentaire: input.commentaire,
signatureUrl,
signatureS3Key,
});
// Vérifier si toutes les présences sont validées
const { checkAllPresencesValidated } = await import("./presenceDb");
const allValidated = await checkAllPresencesValidated(input.inscriptionId);
// Si toutes les présences sont validées, générer l'attestation automatiquement
if (allValidated) {
try {
const { genererAttestationPDF, enregistrerAttestation } = await import("./attestationService");
// Générer le PDF
const { s3Key, pdfUrl } = await genererAttestationPDF(input.inscriptionId);
// Récupérer les données de l'inscription
const db = await getDb();
if (db) {
const inscriptionData = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
if (inscriptionData.length > 0) {
// Enregistrer l'attestation en base
await enregistrerAttestation(
input.inscriptionId,
inscriptionData[0].apprenantId,
inscriptionData[0].sequenceId,
s3Key,
pdfUrl
);
}
}
return {
...result,
attestationGenerated: true,
attestationUrl: pdfUrl,
};
} catch (error) {
console.error("Erreur lors de la génération de l'attestation:", error);
// Ne pas bloquer la validation de présence si l'attestation échoue
}
}
return result;
}),
// Lister les présences pour une séquence
listBySequence: protectedProcedure
.input(z.object({
sequenceId: z.number(),
}))
.query(async ({ input }) => {
const { getPresencesBySequence } = await import("./presenceDb");
return await getPresencesBySequence(input.sequenceId);
}),
// Lister les présences pour une inscription
listByInscription: publicProcedure
.input(z.object({
inscriptionId: z.number(),
}))
.query(async ({ input }) => {
const { getPresencesByInscription } = await import("./presenceDb");
return getPresencesByInscription(input.inscriptionId);
}),
// Supprimer une présence
delete: protectedProcedure
.input(z.object({
presenceId: z.number(),
}))
.mutation(async ({ input }) => {
const { supprimerPresence } = await import("./presenceDb");
return supprimerPresence(input.presenceId);
}),
// Vérifier si toutes les présences sont validées pour une inscription
checkAllValidated: publicProcedure
.input(z.object({
inscriptionId: z.number(),
}))
.query(async ({ input }) => {
const { checkAllPresencesValidated } = await import("./presenceDb");
return checkAllPresencesValidated(input.inscriptionId);
}),
}),
// ===== GESTION DES ATTESTATIONS =====
gestionAttestations: router({
// Récupérer la configuration d'une formation
getFormationConfig: adminProcedure
.input(z.object({ formationId: z.number() }))
.query(async ({ input }) => {
const { getFormationConfig } = await import("./gestionAttestationsDb");
return getFormationConfig(input.formationId);
}),
// Mettre à jour la configuration d'une formation
updateFormationConfig: adminProcedure
.input(z.object({
formationId: z.number(),
modeAttestation: z.enum(["auto", "manuel"]),
modeEnvoi: z.enum(["auto", "manuel"]),
}))
.mutation(async ({ input }) => {
const { updateFormationConfig } = await import("./gestionAttestationsDb");
return updateFormationConfig(
input.formationId,
input.modeAttestation,
input.modeEnvoi
);
}),
// Récupérer les apprenants avec leur statut d'attestation
getApprenantsWithStatus: protectedProcedure
.input(z.object({ formationId: z.number() }))
.query(async ({ input }) => {
try {
const { getApprenantsWithAttestationStatus } = await import("./gestionAttestationsDb");
const results = await getApprenantsWithAttestationStatus(input.formationId);
console.log("[gestionAttestations] Found", results.length, "apprenants for formation", input.formationId);
return results;
} catch (error) {
console.error("[gestionAttestations] Error fetching apprenants:", error);
throw error;
}
}),
// Uploader un document d'attestation
uploadDocument: 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(),
sequenceId: z.number(),
apprenantId: 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");
const { enregistrerEnvoiAttestation } = await import("./attestationsDb");
try {
// Envoyer l'email
await sendAttestationEmail({
to: input.apprenantEmail,
apprenantNom: input.apprenantNom,
apprenantPrenom: input.apprenantPrenom,
formationNom: input.formationNom,
pdfUrl: input.pdfUrl,
});
// Marquer comme envoyé
await markAttestationAsSent(input.attestationId);
// Enregistrer dans l'historique
await enregistrerEnvoiAttestation({
sequenceId: input.sequenceId,
apprenantId: input.apprenantId,
statut: "envoye",
urlAttestation: input.pdfUrl,
});
return { success: true };
} catch (error: any) {
// Enregistrer l'échec dans l'historique
await enregistrerEnvoiAttestation({
sequenceId: input.sequenceId,
apprenantId: input.apprenantId,
statut: "erreur",
messageErreur: error.message,
urlAttestation: input.pdfUrl,
});
throw error;
}
}),
// Envoyer toutes les attestations d'une formation en masse
sendAllAttestations: adminProcedure
.input(z.object({ formationId: z.number() }))
.mutation(async ({ input }) => {
const { getApprenantsWithAttestationStatus } = await import("./gestionAttestationsDb");
const { sendAttestationEmail } = await import("./emailService");
const { markAttestationAsSent } = await import("./gestionAttestationsDb");
const { enregistrerEnvoiAttestation } = await import("./attestationsDb");
const { getDb } = await import("./db");
const { formations } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
try {
// Récupérer tous les apprenants avec attestations
const apprenants = await getApprenantsWithAttestationStatus(input.formationId);
// Récupérer le nom de la formation
const db = await getDb();
if (!db) throw new Error("Database not available");
const formation = await db.select().from(formations).where(eq(formations.id, input.formationId)).limit(1);
const formationNom = formation[0]?.nom || "";
let succes = 0;
let echecs = 0;
const erreurs: string[] = [];
// Filtrer les apprenants qui ont une attestation prête
const apprenantsAvecAttestation = apprenants.filter(item => {
const hasDocument = item.attestation && item.attestation.documentUrl;
return hasDocument;
});
console.log(`[sendAllAttestations] Envoi de ${apprenantsAvecAttestation.length} attestation(s) pour la formation ${formationNom}`);
// Envoyer chaque attestation
for (const item of apprenantsAvecAttestation) {
if (!item.attestation || !item.attestation.documentUrl) continue;
const pdfUrl = item.attestation.documentUrl;
try {
// Envoyer l'email
await sendAttestationEmail({
to: item.apprenant.email,
apprenantNom: item.apprenant.nom,
apprenantPrenom: item.apprenant.prenom,
formationNom: formationNom,
pdfUrl: pdfUrl,
});
// Marquer comme envoyé
await markAttestationAsSent(item.attestation.id);
// Enregistrer dans l'historique
await enregistrerEnvoiAttestation({
sequenceId: item.sequence.id,
apprenantId: item.apprenant.id,
statut: "envoye",
urlAttestation: pdfUrl,
});
succes++;
console.log(`[sendAllAttestations] Envoi réussi pour ${item.apprenant.email}`);
} catch (error: any) {
echecs++;
const messageErreur = error?.message || String(error);
erreurs.push(`${item.apprenant.email}: ${messageErreur}`);
console.error(`[sendAllAttestations] Erreur pour ${item.apprenant.email}:`, error);
// Enregistrer l'échec dans l'historique
await enregistrerEnvoiAttestation({
sequenceId: item.sequence.id,
apprenantId: item.apprenant.id,
statut: "erreur",
messageErreur: messageErreur,
urlAttestation: pdfUrl,
});
}
}
console.log(`[sendAllAttestations] Terminé: ${succes} succès, ${echecs} échecs`);
return {
succes,
echecs,
erreurs,
total: apprenantsAvecAttestation.length,
};
} catch (error: any) {
console.error("[sendAllAttestations] Erreur globale:", error);
throw error;
}
}),
}),
// Monitoring Fail2Ban
fail2ban: router({
// Récupérer le statut et les statistiques Fail2Ban
getStatus: adminProcedure.query(async () => {
return await fail2banDb.getFail2BanStatus();
}),
// Récupérer l'historique des bannissements
getHistory: adminProcedure
.input(z.object({
limit: z.number().optional().default(50),
}))
.query(async ({ input }) => {
return await fail2banDb.getBanHistory(input.limit);
}),
// Débannir une IP
unbanIP: adminProcedure
.input(z.object({
ip: z.string(),
}))
.mutation(async ({ input }) => {
const success = await fail2banDb.unbanIP(input.ip);
if (!success) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `Impossible de débannir l'IP ${input.ip}`,
});
}
return { success: true };
}),
// Compter les bannissements récents
countRecentBans: adminProcedure
.input(z.object({
hours: z.number().optional().default(1),
}))
.query(async ({ input }) => {
return await fail2banDb.countRecentBans(input.hours);
}),
}),
});
export type AppRouter = typeof appRouter;