Files
manus-admin 2cac56b8c6
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 2m22s
fix: corriger le flux de réinitialisation par e-mail
2026-09-02 11:30:37 +02:00

3161 lines
120 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, generateFeuillePresenceParJour } from "./exportService";
import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel";
import crypto from "crypto";
import bcrypt from "bcryptjs";
import { sql } from "drizzle-orm";
import { catalogueRouter } from "./routers/catalogue";
import { planFormationRouter } from "./routers/planFormation";
/**
* Construit le lien de réinitialisation avec le domaine réellement utilisé.
* Cela permet au même code de fonctionner en local, recette et production.
*/
function buildPasswordResetUrl(req: { protocol: string; get: (name: string) => string | undefined }, token: string) {
const forwardedProtocol = req.get("x-forwarded-proto")?.split(",")[0]?.trim();
const protocol = forwardedProtocol || req.protocol;
const forwardedHost = req.get("x-forwarded-host")?.split(",")[0]?.trim();
const host = forwardedHost || req.get("host");
if (!host) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "URL de réinitialisation indisponible" });
}
return `${protocol}://${host}/reset-password?token=${encodeURIComponent(token)}`;
}
// 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 });
});
// Procédure pour formateurs, super-formateurs et admins
const formateurProcedure = protectedProcedure.use(({ ctx, next }) => {
if (!['admin', 'formateur', 'super_formateur'].includes(ctx.user.role)) {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux formateurs et 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, ctx }) => {
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(utilisateur.id, token);
// Envoyer l'email avec le template reset_password
const resetUrl = buildPasswordResetUrl(ctx.req, 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.getUserById(resetToken.userId);
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' || ctx.user.role === 'super_formateur') {
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' || ctx.user.role === 'super_formateur') {
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' || ctx.user.role === 'super_formateur') {
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({
id: z.number().optional(), // ID optionnel pour identifier les dates existantes
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);
// Créer un map des dates existantes par ID
const existingDatesMap = new Map(existingDates.map(d => [d.id, d]));
// Créer un Set des IDs de dates à conserver
const datesToKeep = new Set<number>();
// Traiter chaque date de la nouvelle liste
for (const date of dates) {
if (date.id && existingDatesMap.has(date.id)) {
// Date existante : mettre à jour si nécessaire
const existing = existingDatesMap.get(date.id)!;
const newDebut = parseLocalDateTime(date.dateDebut);
const newFin = parseLocalDateTime(date.dateFin);
const debutChanged = new Date(existing.dateDebut).getTime() !== newDebut.getTime();
const finChanged = new Date(existing.dateFin).getTime() !== newFin.getTime();
const ordreChanged = existing.ordre !== date.ordre;
if (debutChanged || finChanged || ordreChanged) {
console.log('[UPDATE SEQUENCE] Mise à jour date ID:', date.id);
await db.updateDateFormation(date.id, {
dateDebut: newDebut,
dateFin: newFin,
ordre: date.ordre,
});
}
datesToKeep.add(date.id);
} else {
// Nouvelle date : créer
console.log('[UPDATE SEQUENCE] Création nouvelle date:', date);
await db.createDateFormation({
sequenceId: id,
dateDebut: parseLocalDateTime(date.dateDebut),
dateFin: parseLocalDateTime(date.dateFin),
ordre: date.ordre,
});
}
}
// Supprimer les dates qui ne sont plus dans la liste
for (const existing of existingDates) {
if (!datesToKeep.has(existing.id)) {
console.log('[UPDATE SEQUENCE] Suppression date ID:', existing.id);
await db.deleteDateFormation(existing.id);
}
}
console.log('[UPDATE SEQUENCE] Gestion des dates terminée');
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 };
}),
getDatesByFormateurToken: publicProcedure
.input(z.object({ token: z.string() }))
.query(async ({ input }) => {
const sequence = await db.getSequenceByFormateurToken(input.token);
if (!sequence) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
}
const dates = await db.getDatesBySequence(sequence.id);
return { sequence, dates };
}),
}),
// ===== 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();
// Une date de blocage est optionnelle : ne jamais transformer `null` en 01/01/1970.
if (sequence.statut === 'bloquee' || (sequence.dateBlocage && now >= new Date(sequence.dateBlocage))) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Les inscriptions sont fermées pour cette séquence (J-15 dépassé)'
});
}
// Vérifier si l'apprenant a déjà une inscription
const existing = await db.checkExistingInscription(input.apprenantId, input.sequenceId);
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Vous êtes déjà inscrit à cette séquence'
});
}
// Vérifier la capacité
const nbInscrits = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
const statut = nbInscrits >= sequence.capaciteMax ? 'liste_attente' : 'confirmee';
await db.createInscription({
apprenantId: input.apprenantId,
sequenceId: input.sequenceId,
statut,
});
// Envoyer l'email de confirmation avec invitations Outlook pour toutes les dates
const inscriptionSequence = await db.getSequenceById(input.sequenceId);
const inscriptionApprenant = await db.getApprenantById(input.apprenantId);
const inscriptionFormation = inscriptionSequence ? await db.getFormationById(inscriptionSequence.formationId) : null;
const dates = inscriptionSequence ? await db.getDatesBySequence(inscriptionSequence.id) : [];
if (inscriptionSequence && inscriptionApprenant && inscriptionFormation && dates.length > 0) {
// Utiliser la première date pour l'email principal
const premiereDate = dates[0];
await sendInscriptionConfirmation({
apprenantEmail: inscriptionApprenant.email,
apprenantNom: inscriptionApprenant.nom,
apprenantPrenom: inscriptionApprenant.prenom,
apprenantFonction: inscriptionApprenant.fonction || '',
formationNom: inscriptionFormation.nom,
sequenceNom: inscriptionSequence.nom,
dates: dates.map(d => ({
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
lieu: inscriptionSequence.lieu,
statut,
});
// Notification au formateur si présent
if (inscriptionSequence.formateurId) {
const formateur = await db.getFormateurById(inscriptionSequence.formateurId);
if (formateur && formateur.email) {
// Vérifier si les notifications sont activées
const { getParametres } = await import("./parametresDb");
const parametres = await getParametres();
if (parametres.notificationsActives) {
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);
}
} else {
console.log(`[Notification] Notifications désactivées - notification formateur inscription non envoyée`);
}
}
}
// Alerte capacité atteinte pour les admins ET le formateur
const nbInscritsApres = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
if (nbInscritsApres >= inscriptionSequence.capaciteMax) {
try {
const admins = await db.getAdminUsers();
const adminEmails = admins.filter(a => a.email).map(a => a.email!);
if (adminEmails.length > 0) {
const nbListeAttente = await db.countInscriptionsBySequence(input.sequenceId, 'liste_attente');
let formateurNom: string | undefined;
if (inscriptionSequence.formateurId) {
const formateur = await db.getFormateurById(inscriptionSequence.formateurId);
formateurNom = formateur?.nom;
}
await sendAlerteCapaciteAtteinte({
adminEmails,
formationNom: inscriptionFormation.nom,
sequenceNom: inscriptionSequence.nom,
capaciteMax: inscriptionSequence.capaciteMax,
nbInscrits: nbInscritsApres,
nbListeAttente,
formateurNom,
dates: dates.map(d => ({
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
});
console.log(`[Notification] Alerte capacité atteinte envoyée à ${adminEmails.length} admin(s)`);
}
// Notification au formateur pour capacité atteinte
if (inscriptionSequence.formateurId) {
const formateur = await db.getFormateurById(inscriptionSequence.formateurId);
if (formateur && formateur.email) {
const { sendNotificationFormateurCapaciteAtteinte } = await import('./emailService');
await sendNotificationFormateurCapaciteAtteinte({
formateurEmail: formateur.email,
formateurNom: formateur.nom,
formationNom: inscriptionFormation.nom,
sequenceNom: inscriptionSequence.nom,
capaciteMax: inscriptionSequence.capaciteMax,
nbInscrits: nbInscritsApres,
dates: dates.map(d => ({
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
});
console.log(`[Notification] Alerte capacité atteinte envoyée au formateur ${formateur.email}`);
}
}
} catch (e) {
console.error(`[Notification] Erreur envoi alerte capacité:`, e);
}
}
}
return { success: true, statut };
}),
desinscrire: publicProcedure.input(z.object({
apprenantId: z.number(),
sequenceId: z.number(),
})).mutation(async ({ input }) => {
// Vérifier si la séquence existe
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
}
// Vérifier si la séquence est bloquée
const now = new Date();
if (sequence.statut === 'bloquee' || (sequence.dateBlocage && 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) {
// Vérifier si les notifications sont activées
const { getParametres } = await import("./parametresDb");
const parametres = await getParametres();
if (parametres.notificationsActives) {
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);
}
} else {
console.log(`[Notification] Notifications désactivées - notification formateur annulation non envoyée`);
}
}
}
// Notifier le premier en liste d'attente qu'une place s'est libérée
const inscriptionsListeAttente = await db.getInscriptionsBySequence(input.sequenceId);
const premierEnAttente = inscriptionsListeAttente
.filter(i => i.inscription.statut === 'liste_attente')
.sort((a, b) => new Date(a.inscription.dateInscription).getTime() - new Date(b.inscription.dateInscription).getTime())[0];
if (premierEnAttente && premierEnAttente.apprenant) {
try {
await sendNotificationPlaceDisponible({
apprenantEmail: premierEnAttente.apprenant.email,
apprenantNom: premierEnAttente.apprenant.nom,
apprenantPrenom: premierEnAttente.apprenant.prenom,
apprenantFonction: premierEnAttente.apprenant.fonction || '',
formationNom: formation.nom,
sequenceNom: sequence.nom,
positionListeAttente: 1,
delaiReponse: 48,
});
// Logger la notification
await logNotification({
type: "notification_liste_attente",
sequenceId: input.sequenceId,
apprenantId: premierEnAttente.apprenant.id,
emailDestinataire: premierEnAttente.apprenant.email,
sujet: `Place disponible - ${formation.nom}`,
statut: "success",
});
console.log(`[Notification] Email place disponible envoyé à ${premierEnAttente.apprenant.email}`);
} catch (e: any) {
// Logger l'échec
await logNotification({
type: "notification_liste_attente",
sequenceId: input.sequenceId,
apprenantId: premierEnAttente.apprenant.id,
emailDestinataire: premierEnAttente.apprenant.email,
sujet: `Place disponible - ${formation.nom}`,
statut: "failed",
messageErreur: e.message,
});
console.error(`[Notification] Erreur envoi email liste d'attente:`, e);
}
}
}
return { success: true };
}),
updateStatut: adminProcedure.input(z.object({
id: z.number(),
statut: z.enum(['confirmee', 'liste_attente', 'annulee']),
})).mutation(async ({ input }) => {
// Récupérer l'inscription avant modification
const inscriptionAvant = await db.getInscriptionById(input.id);
const ancienStatut = inscriptionAvant?.statut;
await db.updateInscription(input.id, { statut: input.statut });
// Si passage à annulée, notifier le formateur
if (input.statut === 'annulee' && ancienStatut !== 'annulee' && inscriptionAvant) {
const sequence = await db.getSequenceById(inscriptionAvant.sequenceId);
const apprenant = await db.getApprenantById(inscriptionAvant.apprenantId);
if (sequence && apprenant && sequence.formateurId) {
const formateur = await db.getFormateurById(sequence.formateurId);
const formation = await db.getFormationById(sequence.formationId);
if (formateur && formateur.email && formation) {
// Vérifier si les notifications sont activées
const { getParametres } = await import("./parametresDb");
const parametres = await getParametres();
if (parametres.notificationsActives) {
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);
}
} else {
console.log(`[Notification] Notifications désactivées - notification formateur annulation non envoyé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, getSignaturesFormateursBySequence } = await import("./presenceDb");
const presences = await getPresencesBySequence(input.sequenceId);
const signaturesFormateurs = await getSignaturesFormateursBySequence(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,
periode: p.periode as 'matin' | 'apres_midi',
signatureUrl: p.signatureUrl,
}));
return {
nom: i.apprenant!.nom,
prenom: i.apprenant!.prenom,
codeEtablissement: i.apprenant!.codeEtablissement,
fonction: i.apprenant!.fonction,
presences: apprenantPresences,
};
});
const pdfsParJour = await generateFeuillePresenceParJour({
formationNom: formation.nom,
sequenceNom: sequence.nom,
dates: dates.map(d => ({
id: d.id,
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
lieu: sequence.lieu,
publicCible: sequence.publicCible,
formateur: formateurNom,
apprenants: data,
signaturesFormateurs: signaturesFormateurs.map(s => ({
dateFormationId: s.dateFormationId,
periode: s.periode as 'matin' | 'apres_midi',
signatureUrl: s.signatureUrl,
})),
});
// Retourner un tableau de PDFs (un par jour)
return pdfsParJour.map(({ ordre, filename, buffer }) => ({
ordre,
buffer: buffer.toString('base64'),
filename,
}));
}),
// 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" : "liste_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", "super_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", "super_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, ctx }) => {
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.id, token);
// Générer le lien de réinitialisation sur lenvironnement réellement appelé.
const resetLink = buildPasswordResetUrl(ctx.req, 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", "attestation"]),
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(),
fichier2: 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(),
fichier2: 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,
attachmentUrl2: rappelAUtiliser.urlFichier2 || undefined,
attachmentFilename2: rappelAUtiliser.nomFichier2 || undefined,
attachmentMimeType2: rappelAUtiliser.typeFichier2 || 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(),
valeurMin: z.number().optional(),
valeurMax: 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(),
valeurMin: z.number().optional(),
valeurMax: z.number().optional(),
echelleLabelMin: z.string().optional(),
echelleLabelMax: z.string().optional(),
obligatoire: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
const { id, ...data } = input;
const questionnaireDb = await import("./questionnaireDb");
await questionnaireDb.updateQuestion(id, data);
return { success: true };
}),
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
await questionnaireDb.deleteQuestion(input.id);
return { success: true };
}),
}),
// ===== RÉPONSES QUESTIONNAIRES (PUBLIC) =====
reponses: router({
getByToken: publicProcedure
.input(z.object({ token: z.string() }))
.query(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
const envoi = await questionnaireDb.getEnvoiByToken(input.token);
if (!envoi) return null;
const questionnaire = await questionnaireDb.getQuestionnaireById(envoi.questionnaireId);
const questions = await questionnaireDb.getQuestionsByQuestionnaireId(envoi.questionnaireId);
return {
envoi,
questionnaire,
questions,
};
}),
submit: publicProcedure
.input(z.object({
token: z.string(),
reponses: z.array(z.object({
questionId: z.number(),
reponseNumerique: z.number().optional(),
reponseTexte: z.string().optional(),
})),
}))
.mutation(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
// Récupérer l'envoi
const envoi = await questionnaireDb.getEnvoiByToken(input.token);
if (!envoi) throw new Error("Token invalide");
if (envoi.dateReponse) throw new Error("Questionnaire déjà répondu");
// Créer la réponse au questionnaire
const reponseQuestionnaireId = await questionnaireDb.createReponseQuestionnaire({
questionnaireId: envoi.questionnaireId,
apprenantId: envoi.apprenantId,
sequenceId: envoi.sequenceId,
});
// Enregistrer chaque réponse
for (const reponse of input.reponses) {
await questionnaireDb.createReponseQuestion({
reponseQuestionnaireId,
questionId: reponse.questionId,
reponseNumerique: reponse.reponseNumerique,
reponseTexte: reponse.reponseTexte,
});
}
// Mettre à jour l'envoi
await questionnaireDb.updateEnvoiQuestionnaire(envoi.id, {
dateReponse: new Date(),
});
// Envoyer une notification à l'administrateur
try {
const { notifyOwner } = await import('./_core/notification');
const questionnaire = await questionnaireDb.getQuestionnaireById(envoi.questionnaireId);
await notifyOwner({
title: 'Nouvelle réponse au questionnaire',
content: `Un apprenant a répondu au questionnaire "${questionnaire?.titre || 'Sans titre'}"`,
});
} catch (error) {
console.error('[Notification] Erreur lors de l\'envoi de la notification:', error);
// Ne pas bloquer la réponse si la notification échoue
}
return { success: true };
}),
envoyerAutomatiques: adminProcedure
.mutation(async () => {
const { processEnvoisAutomatiques } = await import('./questionnaireScheduler');
const result = await processEnvoisAutomatiques();
return result;
}),
}),
// ===== SUIVI QUESTIONNAIRES =====
questionnaireSuivi: router({
statsGlobales: adminProcedure.query(async () => {
const suiviDb = await import("./questionnaireSuiviDb");
return suiviDb.getStatsGlobales();
}),
statsByFormation: adminProcedure.query(async () => {
const suiviDb = await import("./questionnaireSuiviDb");
return suiviDb.getStatsByFormation();
}),
evolutionTemporelle: adminProcedure
.input(z.object({
startDate: z.date().optional(),
endDate: z.date().optional(),
}))
.query(async ({ input }) => {
const suiviDb = await import("./questionnaireSuiviDb");
return suiviDb.getEvolutionTemporelle(input.startDate, input.endDate);
}),
statsByFormateur: adminProcedure.query(async () => {
const suiviDb = await import("./questionnaireSuiviDb");
return suiviDb.getStatsByFormateur();
}),
}),
// ===== ESPACE FORMATEUR =====
formateur: router({
calendrier: protectedProcedure
.input(z.object({
formateurId: z.number(),
dateDebut: z.date().optional(),
dateFin: z.date().optional(),
}))
.query(async ({ input }) => {
const formateurDb = await import("./formateurDb");
return formateurDb.getCalendrierFormateur(input.formateurId, input.dateDebut, input.dateFin);
}),
apprenants: protectedProcedure
.input(z.object({ sequenceId: z.number() }))
.query(async ({ input }) => {
const formateurDb = await import("./formateurDb");
return formateurDb.getApprenantsSequence(input.sequenceId);
}),
supports: protectedProcedure
.input(z.object({ sequenceId: z.number() }))
.query(async ({ input }) => {
const formateurDb = await import("./formateurDb");
return formateurDb.getSupportsSequence(input.sequenceId);
}),
ajouterSupport: protectedProcedure
.input(z.object({
sequenceId: z.number(),
formateurId: z.number(),
nomFichier: z.string(),
typeFichier: z.string(),
tailleFichier: z.number(),
urlFichier: z.string(),
s3Key: z.string(),
description: z.string().optional(),
}))
.mutation(async ({ input }) => {
const formateurDb = await import("./formateurDb");
return formateurDb.ajouterSupport(input);
}),
supprimerSupport: protectedProcedure
.input(z.object({
supportId: z.number(),
formateurId: z.number(),
}))
.mutation(async ({ input }) => {
const formateurDb = await import("./formateurDb");
const support = await formateurDb.supprimerSupport(input.supportId, input.formateurId);
// Note: La suppression du fichier S3 devrait être gérée par un processus de nettoyage séparé
// car il n'y a pas de fonction storageDelete dans l'API actuelle
return { success: true, support };
}),
validerPresence: protectedProcedure
.input(z.object({
inscriptionId: z.number(),
present: z.boolean(),
}))
.mutation(async ({ input }) => {
const formateurDb = await import("./formateurDb");
await formateurDb.validerPresence(input.inscriptionId, input.present);
return { success: true };
}),
historique: protectedProcedure
.input(z.object({
formateurId: z.number(),
limit: z.number().optional(),
}))
.query(async ({ input }) => {
const formateurDb = await import("./formateurDb");
return formateurDb.getHistoriqueFormateur(input.formateurId, input.limit);
}),
detailSequence: protectedProcedure
.input(z.object({
sequenceId: z.number(),
formateurId: z.number(),
}))
.query(async ({ input }) => {
const formateurDb = await import("./formateurDb");
return formateurDb.getDetailSequence(input.sequenceId, input.formateurId);
}),
}),
// ===== ÉTABLISSEMENTS =====
etablissements: router({
list: adminProcedure.query(async () => {
const { getEtablissementsStats } = await import("./etablissementsDb");
return getEtablissementsStats();
}),
getDetail: adminProcedure
.input(z.object({ codeEtablissement: z.string() }))
.query(async ({ input }) => {
const { getEtablissementDetail } = await import("./etablissementsDb");
return getEtablissementDetail(input.codeEtablissement);
}),
}),
// ===== NOTIFICATIONS =====
notifications: router({
envoyerRemerciements: adminProcedure
.input(z.object({ sequenceId: z.number() }))
.mutation(async ({ input }) => {
const { envoyerRemerciementsSequence } = await import("./remerciementScheduler");
return envoyerRemerciementsSequence(input.sequenceId);
}),
envoyerRemerciementsAutomatiques: adminProcedure
.mutation(async () => {
const { processRemerciementsAutomatiques } = await import("./remerciementScheduler");
await processRemerciementsAutomatiques();
return { success: true };
}),
// Historique des notifications
historique: adminProcedure
.input(z.object({
type: z.enum(["remerciement", "notification_formateur_inscription", "notification_formateur_annulation", "alerte_capacite", "notification_liste_attente"]).optional(),
dateDebut: z.string().optional(),
dateFin: z.string().optional(),
statut: z.enum(["success", "failed"]).optional(),
email: z.string().optional(),
sequenceId: z.number().optional(),
limit: z.number().default(50),
offset: z.number().default(0),
}))
.query(async ({ input }) => {
const { getNotificationLogs } = await import("./notificationLogsDb");
return getNotificationLogs({
type: input.type,
dateDebut: input.dateDebut ? new Date(input.dateDebut) : undefined,
dateFin: input.dateFin ? new Date(input.dateFin) : undefined,
statut: input.statut,
email: input.email,
sequenceId: input.sequenceId,
limit: input.limit,
offset: input.offset,
});
}),
// Statistiques des notifications
statistiques: adminProcedure
.input(z.object({
dateDebut: z.string().optional(),
dateFin: z.string().optional(),
}))
.query(async ({ input }) => {
const { getNotificationStats } = await import("./notificationLogsDb");
return getNotificationStats({
dateDebut: input.dateDebut ? new Date(input.dateDebut) : undefined,
dateFin: input.dateFin ? new Date(input.dateFin) : undefined,
});
}),
}),
// Attestations de formation
attestations: router({
// Générer une attestation pour une inscription
generer: adminProcedure
.input(z.object({
inscriptionId: z.number(),
}))
.mutation(async ({ input }) => {
const { genererAttestationPDF, enregistrerAttestation, getAttestation } = await import("./attestationService");
const db = await import("./db").then(m => m.getDb());
if (!db) throw new Error("Base de données non disponible");
// Vérifier si l'attestation existe déjà
const { inscriptions } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
const [inscription] = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
if (!inscription) {
throw new Error("Inscription introuvable");
}
const attestationExistante = await getAttestation(inscription.apprenantId, inscription.sequenceId);
if (attestationExistante) {
return {
success: true,
attestation: attestationExistante,
message: "Attestation déjà générée",
};
}
// Générer le PDF
const { s3Key, pdfUrl } = await genererAttestationPDF(input.inscriptionId);
// Enregistrer dans la base
const attestationId = await enregistrerAttestation(
input.inscriptionId,
inscription.apprenantId,
inscription.sequenceId,
s3Key,
pdfUrl
);
const attestation = await getAttestation(inscription.apprenantId, inscription.sequenceId);
return {
success: true,
attestation,
message: "Attestation générée avec succès",
};
}),
// Récupérer l'attestation d'un apprenant pour une séquence
get: publicProcedure
.input(z.object({
apprenantId: z.number(),
sequenceId: z.number(),
}))
.query(async ({ input }) => {
const { getAttestation } = await import("./attestationService");
return getAttestation(input.apprenantId, input.sequenceId);
}),
// Récupérer toutes les attestations d'un apprenant
listApprenant: publicProcedure
.input(z.object({
apprenantId: z.number(),
}))
.query(async ({ input }) => {
const { getAttestationsApprenant } = await import("./attestationService");
return getAttestationsApprenant(input.apprenantId);
}),
// Récupérer la configuration des attestations
getConfig: adminProcedure
.query(async () => {
const { getOrCreateConfigAttestation } = await import("./attestationService");
return getOrCreateConfigAttestation();
}),
// Mettre à jour la configuration des attestations
updateConfig: adminProcedure
.input(z.object({
nomSignataire: z.string().optional(),
fonctionSignataire: z.string().optional(),
texteAttestation: z.string().optional(),
logoUrl: z.string().optional(),
logoS3Key: z.string().optional(),
signatureUrl: z.string().optional(),
signatureS3Key: z.string().optional(),
}))
.mutation(async ({ input }) => {
const { updateConfigAttestation } = await import("./attestationService");
await updateConfigAttestation(input);
return { success: true };
}),
// Récupérer l'historique des envois d'attestations
historique: adminProcedure
.query(async () => {
const { getHistoriqueAttestations } = await import("./attestationsDb");
return getHistoriqueAttestations();
}),
// Récupérer l'historique pour une séquence spécifique
historiqueBySequence: adminProcedure
.input(z.object({
sequenceId: z.number(),
}))
.query(async ({ input }) => {
const { getHistoriqueAttestationsBySequence } = await import("./attestationsDb");
return getHistoriqueAttestationsBySequence(input.sequenceId);
}),
// Générer une prévisualisation du modèle d'attestation
previsualiser: adminProcedure
.mutation(async () => {
const { genererPreviewAttestation } = await import("./attestationService");
return genererPreviewAttestation();
}),
}),
// ===== PRESENCES (ÉMARGEMENT NUMÉRIQUE) =====
presences: router({
// Générer un QR code pour une séquence
generateQRCode: protectedProcedure
.input(z.object({
sequenceId: z.number(),
}))
.mutation(async ({ input, ctx }) => {
const { generateQRToken, generateQRCodeDataURL } = await import("./qrCodeGenerator");
const { getDb } = await import("./db");
const { sequences } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
const db = await getDb();
if (!db) throw new Error("Database not available");
// Vérifier que la séquence existe
const sequence = await db.select().from(sequences).where(eq(sequences.id, input.sequenceId)).limit(1);
if (sequence.length === 0) {
throw new Error("Séquence introuvable");
}
// Générer un nouveau token si nécessaire
let token = sequence[0].qrCodeToken;
if (!token) {
token = generateQRToken();
await db.update(sequences).set({ qrCodeToken: token }).where(eq(sequences.id, input.sequenceId));
}
// Générer le QR code
const qrCodeDataURL = await generateQRCodeDataURL(token);
// Construire l'URL complète
const { getParametres } = await import("./parametresDb");
const parametres = await getParametres();
const url = `${parametres.urlPublique}/emargement/${token}`;
return {
token,
qrCodeDataURL,
url,
};
}),
// Générer un QR code pour le formateur
generateQRCodeFormateur: protectedProcedure
.input(z.object({
sequenceId: z.number(),
}))
.mutation(async ({ input, ctx }) => {
const { generateQRToken } = await import("./qrCodeGenerator");
const { getDb } = await import("./db");
const { sequences } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
const QRCode = (await import("qrcode")).default;
const { getParametres } = await import("./parametresDb");
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 pour le formateur
let token = sequence[0].qrCodeFormateurToken;
if (!token) {
token = generateQRToken();
await db.update(sequences).set({ qrCodeFormateurToken: token }).where(eq(sequences.id, input.sequenceId));
}
// Générer le QR code avec URL spécifique formateur
const parametres = await getParametres();
const url = `${parametres.urlPublique}/emargement-formateur/${token}`;
const qrCodeDataURL = await QRCode.toDataURL(url, {
errorCorrectionLevel: "H",
type: "image/png",
width: 400,
margin: 2,
color: {
dark: "#000000",
light: "#FFFFFF",
},
});
return {
token,
qrCodeDataURL,
};
}),
// Valider une présence (scan QR code ou manuel)
valider: publicProcedure
.input(z.object({
token: z.string().optional(),
inscriptionId: z.number().optional(),
dateFormationId: z.number().optional(),
periode: z.enum(["matin", "apres_midi"]),
modeValidation: z.enum(["qrcode", "manuel"]),
validateurId: z.number().optional(),
commentaire: z.string().optional(),
signatureDataUrl: z.string().optional(),
email: z.string().optional(),
typeUtilisateur: z.enum(["apprenant", "formateur"]).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");
// Cas formateur : logique spécifique
if (input.typeUtilisateur === "formateur") {
const { formateurs, datesFormation } = await import("../drizzle/schema");
const { and, sql } = await import("drizzle-orm");
const { storagePut } = await import("./storage");
const crypto = await import("crypto");
// Vérifier le token formateur
if (input.modeValidation === "qrcode" && input.token) {
const sequence = await db.select().from(sequences).where(eq(sequences.qrCodeFormateurToken, input.token)).limit(1);
if (sequence.length === 0) {
throw new Error("QR code formateur invalide");
}
}
// Récupérer le formateur par email
if (!input.email) throw new Error("Email formateur requis");
const formateurData = await db.select().from(formateurs).where(eq(formateurs.email, input.email)).limit(1);
if (formateurData.length === 0) {
throw new Error("Formateur introuvable avec cet email");
}
// Trouver la séquence associée au token
const sequenceData = await db.select().from(sequences).where(eq(sequences.qrCodeFormateurToken, input.token || "")).limit(1);
if (sequenceData.length === 0) {
throw new Error("Séquence introuvable");
}
const finalSequenceId = sequenceData[0].id;
// Utiliser la date sélectionnée par le formateur
if (!input.dateFormationId) {
throw new Error("Date de formation requise");
}
const dateFormId = input.dateFormationId;
// Stocker la signature sur S3
let signatureUrl: string | undefined;
let signatureS3Key: string | undefined;
if (input.signatureDataUrl) {
const fs = await import("fs");
const path = await import("path");
const formationId = sequenceData[0].formationId;
const sequenceId = sequenceData[0].id;
const randomSuffix = crypto.randomBytes(8).toString("hex");
const fileName = `signature-formateur-${formateurData[0].id}-${dateFormId}-${randomSuffix}.png`;
const signatureS3Key = `signatures/${formationId}-${sequenceId}/${fileName}`;
const base64Data = input.signatureDataUrl.split(",")[1];
const buffer = Buffer.from(base64Data, "base64");
// Stockage local
const uploadsDir = path.join(process.cwd(), "uploads", "signatures");
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const filePath = path.join(uploadsDir, fileName);
fs.writeFileSync(filePath, buffer);
signatureUrl = `/uploads/signatures/${fileName}`;
}
// Valider la présence formateur
const { validerPresenceFormateur } = await import("./presenceDb");
const result = await validerPresenceFormateur({
formateurId: formateurData[0].id,
sequenceId: finalSequenceId,
dateFormationId: dateFormId,
periode: input.periode,
modeValidation: input.modeValidation,
commentaire: input.commentaire,
signatureUrl,
signatureS3Key,
});
return { success: true, presence: result };
}
// Cas apprenant : logique existante
// 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
if (!input.inscriptionId) throw new Error("Inscription requise pour un apprenant");
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");
}
}
// Vérifier que inscriptionId et dateFormationId sont fournis pour un apprenant
if (!input.inscriptionId) throw new Error("Inscription requise pour un apprenant");
if (!input.dateFormationId) throw new Error("Date de formation requise pour un apprenant");
// 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 signatures formateurs pour une séquence
listSignaturesFormateurs: protectedProcedure
.input(z.object({
sequenceId: z.number(),
}))
.query(async ({ input }) => {
const { getSignaturesFormateursBySequence } = await import("./presenceDb");
return await getSignaturesFormateursBySequence(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: formateurProcedure
.input(z.object({
inscriptionId: z.number(),
documentUrl: z.string(),
documentS3Key: z.string(),
}))
.mutation(async ({ input, ctx }) => {
const { uploadAttestationDocument, canAccessSequence } = await import("./gestionAttestationsDb");
const { getDb } = await import("./db");
const { inscriptions } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
// Récupérer la séquence de l'inscription
const db = await getDb();
if (!db) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Database not available' });
const inscription = await db.select({ sequenceId: inscriptions.sequenceId }).from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
if (inscription.length === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Inscription not found' });
// Vérifier l'accès
const hasAccess = await canAccessSequence(ctx.user.id, ctx.user.role, inscription[0].sequenceId);
if (!hasAccess) throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès refusé à cette séquence' });
return uploadAttestationDocument(
input.inscriptionId,
input.documentUrl,
input.documentS3Key,
ctx.user.id
);
}),
// Supprimer un document d'attestation
deleteDocument: formateurProcedure
.input(z.object({ attestationId: z.number() }))
.mutation(async ({ input, ctx }) => {
const { deleteAttestationDocument, canAccessSequence } = await import("./gestionAttestationsDb");
const { getDb } = await import("./db");
const { attestations } = await import("../drizzle/schema");
const { eq } = await import("drizzle-orm");
// Récupérer la séquence de l'attestation
const db = await getDb();
if (!db) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Database not available' });
const attestation = await db.select({ sequenceId: attestations.sequenceId }).from(attestations).where(eq(attestations.id, input.attestationId)).limit(1);
if (attestation.length === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Attestation not found' });
// Vérifier l'accès
const hasAccess = await canAccessSequence(ctx.user.id, ctx.user.role, attestation[0].sequenceId);
if (!hasAccess) throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès refusé à cette séquence' });
return deleteAttestationDocument(input.attestationId);
}),
// Envoyer une attestation par email
sendAttestation: formateurProcedure
.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, ctx }) => {
const { sendAttestationEmail } = await import("./emailService");
const { markAttestationAsSent, canAccessSequence } = await import("./gestionAttestationsDb");
const { enregistrerEnvoiAttestation } = await import("./attestationsDb");
// Vérifier l'accès à la séquence
const hasAccess = await canAccessSequence(ctx.user.id, ctx.user.role, input.sequenceId);
if (!hasAccess) throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès refusé à cette séquence' });
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: formateurProcedure
.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);
}),
}),
catalogue: catalogueRouter,
planFormation: planFormationRouter,
});
export type AppRouter = typeof appRouter;