Modifications principales : - Ajout des champs typeUtilisateur et formateurId dans la table presences pour distinguer formateurs et apprenants - Détection automatique du rôle (formateur/apprenant) via l'email lors du scan du QR code - Interface de signature tactile réutilisée pour les formateurs - Intégration des signatures tactiles formateur dans la génération PDF de la feuille de présence - QR code dans l'espace formateur accessible pour l'émargement - Tests unitaires validés avec succès Le système permet maintenant aux formateurs d'émarger (matin et après-midi) via le même QR code que les apprenants, avec détection automatique du rôle et intégration des signatures dans le PDF.
3018 lines
113 KiB
TypeScript
3018 lines
113 KiB
TypeScript
import { COOKIE_NAME } from "@shared/const";
|
|
import { getSessionCookieOptions } from "./_core/cookies";
|
|
import { systemRouter } from "./_core/systemRouter";
|
|
import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
|
|
import { parseLocalDateTime } from "./dateUtils";
|
|
import { z } from "zod";
|
|
import * as db from "./db";
|
|
import * as analyticsDb from "./analyticsDb";
|
|
import * as fail2banDb from "./fail2banDb";
|
|
import { generateAnalyticsExcel, generateAnalyticsPDF } from "./analyticsExportService";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { sendInscriptionConfirmation, sendGroupEmail, sendNotificationFormateurNouvelleInscription, sendNotificationFormateurAnnulation, sendAlerteCapaciteAtteinte, sendNotificationPlaceDisponible, sendRemerciementPostFormation, sendResetPasswordEmail } from "./emailService";
|
|
import { logNotification } from "./notificationLogsDb";
|
|
import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService";
|
|
import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel";
|
|
import crypto from "crypto";
|
|
import bcrypt from "bcryptjs";
|
|
import { sql } from "drizzle-orm";
|
|
|
|
// Procédure admin uniquement
|
|
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
|
if (ctx.user.role !== 'admin') {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux administrateurs' });
|
|
}
|
|
return next({ ctx });
|
|
});
|
|
|
|
// 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 }) => {
|
|
const { email } = input;
|
|
|
|
// Vérifier si l'utilisateur existe
|
|
const utilisateur = await db.getUtilisateurByEmail(email);
|
|
if (!utilisateur) {
|
|
// Ne pas révéler si l'email existe ou non pour des raisons de sécurité
|
|
return { success: true };
|
|
}
|
|
|
|
// Générer un token unique
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
|
|
// Sauvegarder le token dans la base de données
|
|
await db.createPasswordResetToken(email, token);
|
|
|
|
// Envoyer l'email avec le template reset_password
|
|
const resetUrl = `${process.env.VITE_OAUTH_PORTAL_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
|
|
|
|
await sendResetPasswordEmail({
|
|
email,
|
|
prenom: utilisateur.name?.split(' ')[0] || '',
|
|
nom: utilisateur.name?.split(' ').slice(1).join(' ') || utilisateur.name || '',
|
|
lienReinitialisation: resetUrl,
|
|
});
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
// Valider le token et réinitialiser le mot de passe
|
|
resetPassword: publicProcedure
|
|
.input(z.object({
|
|
token: z.string(),
|
|
newPassword: z.string().min(8),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { token, newPassword } = input;
|
|
|
|
// Récupérer le token
|
|
const resetToken = await db.getPasswordResetToken(token);
|
|
|
|
if (!resetToken) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Token invalide ou expiré',
|
|
});
|
|
}
|
|
|
|
// Vérifier si le token est expiré
|
|
if (new Date() > new Date(resetToken.expiresAt)) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Token expiré',
|
|
});
|
|
}
|
|
|
|
// Vérifier si le token a déjà été utilisé
|
|
if (resetToken.used) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Token déjà utilisé',
|
|
});
|
|
}
|
|
|
|
// Récupérer l'utilisateur
|
|
const utilisateur = await db.getUtilisateurByEmail(resetToken.email);
|
|
if (!utilisateur) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: 'Utilisateur introuvable',
|
|
});
|
|
}
|
|
|
|
// Mettre à jour le mot de passe
|
|
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
|
await db.updateUtilisateurPassword(utilisateur.id, hashedPassword);
|
|
|
|
// Marquer le token comme utilisé
|
|
await db.markTokenAsUsed(resetToken.id);
|
|
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ===== FORMATIONS =====
|
|
formations: router({
|
|
list: protectedProcedure.query(async ({ ctx }) => {
|
|
// Si l'utilisateur est formateur, ne montrer que les formations pour lesquelles il a des séquences
|
|
if (ctx.user.role === 'formateur' && ctx.user.formateurId) {
|
|
return db.getFormationsByFormateur(ctx.user.formateurId);
|
|
} else if (ctx.user.role === 'admin' || 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({
|
|
dateDebut: z.string(),
|
|
dateFin: z.string(),
|
|
ordre: z.number(),
|
|
})).min(1).max(4),
|
|
})).mutation(async ({ input }) => {
|
|
const { id, dates, ...sequenceData } = input;
|
|
|
|
console.log('[UPDATE SEQUENCE] Input reçu:', JSON.stringify(input, null, 2));
|
|
|
|
// Valider que chaque date de fin est après la date de début
|
|
for (const date of dates) {
|
|
const debut = new Date(date.dateDebut);
|
|
const fin = new Date(date.dateFin);
|
|
if (fin <= debut) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'La date de fin doit être après la date de début pour toutes les dates de formation',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Mettre à jour la séquence
|
|
const updateData: any = {
|
|
...sequenceData,
|
|
dateBlocage: sequenceData.dateBlocage && sequenceData.dateBlocage.trim() !== ''
|
|
? parseLocalDateTime(sequenceData.dateBlocage)
|
|
: null,
|
|
};
|
|
|
|
console.log('[UPDATE SEQUENCE] UpdateData:', JSON.stringify(updateData, null, 2));
|
|
await db.updateSequence(id, updateData);
|
|
console.log('[UPDATE SEQUENCE] Séquence mise à jour avec succès');
|
|
|
|
// Récupérer les dates existantes
|
|
const existingDates = await db.getDatesBySequence(id);
|
|
console.log('[UPDATE SEQUENCE] Dates existantes:', existingDates.length);
|
|
|
|
// Comparer les dates pour détecter les changements
|
|
const datesChanged = existingDates.length !== dates.length ||
|
|
existingDates.some((existing, index) => {
|
|
const newDate = dates[index];
|
|
if (!newDate) return true;
|
|
const existingDebut = new Date(existing.dateDebut).toISOString();
|
|
const existingFin = new Date(existing.dateFin).toISOString();
|
|
const newDebut = parseLocalDateTime(newDate.dateDebut).toISOString();
|
|
const newFin = parseLocalDateTime(newDate.dateFin).toISOString();
|
|
return existingDebut !== newDebut || existingFin !== newFin;
|
|
});
|
|
|
|
// Ne supprimer/recréer les dates que si elles ont changé
|
|
if (datesChanged) {
|
|
console.log('[UPDATE SEQUENCE] Dates modifiées, suppression et recréation');
|
|
// Supprimer les anciennes dates
|
|
await db.deleteDatesBySequence(id);
|
|
console.log('[UPDATE SEQUENCE] Anciennes dates supprimées');
|
|
|
|
// Créer les nouvelles dates
|
|
for (const date of dates) {
|
|
console.log('[UPDATE SEQUENCE] Création date:', date);
|
|
await db.createDateFormation({
|
|
sequenceId: id,
|
|
dateDebut: parseLocalDateTime(date.dateDebut),
|
|
dateFin: parseLocalDateTime(date.dateFin),
|
|
ordre: date.ordre,
|
|
});
|
|
}
|
|
console.log('[UPDATE SEQUENCE] Toutes les dates ont été créées');
|
|
} else {
|
|
console.log('[UPDATE SEQUENCE] Dates inchangées, conservation des rappels');
|
|
}
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
|
// Supprimer d'abord les dates associées
|
|
await db.deleteDatesBySequence(input.id);
|
|
// Puis supprimer la séquence
|
|
await db.deleteSequence(input.id);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ===== INSCRIPTIONS =====
|
|
inscriptions: router({
|
|
// Liste des inscriptions avec dates de formation (pour formateurs)
|
|
listWithDates: protectedProcedure
|
|
.input(z.object({ sequenceId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const { getInscriptionsWithDates } = await import("./inscriptionWithDates");
|
|
return getInscriptionsWithDates(input.sequenceId);
|
|
}),
|
|
|
|
listAll: adminProcedure.query(async () => {
|
|
return db.getAllInscriptions();
|
|
}),
|
|
|
|
listBySequence: protectedProcedure.input(z.object({ sequenceId: z.number() })).query(async ({ input }) => {
|
|
return db.getInscriptionsBySequence(input.sequenceId);
|
|
}),
|
|
|
|
listByApprenant: publicProcedure.input(z.object({ apprenantId: z.number() })).query(async ({ input }) => {
|
|
return db.getInscriptionsByApprenant(input.apprenantId);
|
|
}),
|
|
|
|
listByApprenantWithDates: publicProcedure
|
|
.input(z.object({ apprenantId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const { getInscriptionsByApprenantWithDates } = await import("./inscriptionWithDates");
|
|
return getInscriptionsByApprenantWithDates(input.apprenantId);
|
|
}),
|
|
|
|
checkExisting: publicProcedure.input(z.object({
|
|
apprenantId: z.number(),
|
|
sequenceId: z.number(),
|
|
})).query(async ({ input }) => {
|
|
return db.checkExistingInscription(input.apprenantId, input.sequenceId);
|
|
}),
|
|
|
|
inscrire: publicProcedure.input(z.object({
|
|
apprenantId: z.number(),
|
|
sequenceId: z.number(),
|
|
})).mutation(async ({ input }) => {
|
|
// Vérifier si la séquence existe
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
}
|
|
|
|
// Vérifier si la séquence est bloquée
|
|
const now = new Date();
|
|
if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'Les inscriptions sont fermées pour cette séquence (J-15 dépassé)'
|
|
});
|
|
}
|
|
|
|
// Vérifier si l'apprenant a déjà une inscription
|
|
const existing = await db.checkExistingInscription(input.apprenantId, input.sequenceId);
|
|
if (existing) {
|
|
throw new TRPCError({
|
|
code: 'CONFLICT',
|
|
message: 'Vous êtes déjà inscrit à cette séquence'
|
|
});
|
|
}
|
|
|
|
// Vérifier la capacité
|
|
const nbInscrits = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
|
|
const statut = nbInscrits >= sequence.capaciteMax ? 'en_attente' : 'confirmee';
|
|
|
|
await db.createInscription({
|
|
apprenantId: input.apprenantId,
|
|
sequenceId: input.sequenceId,
|
|
statut,
|
|
});
|
|
|
|
// Envoyer l'email de confirmation avec invitations Outlook pour toutes les dates
|
|
const inscriptionSequence = await db.getSequenceById(input.sequenceId);
|
|
const inscriptionApprenant = await db.getApprenantById(input.apprenantId);
|
|
const inscriptionFormation = inscriptionSequence ? await db.getFormationById(inscriptionSequence.formationId) : null;
|
|
const dates = inscriptionSequence ? await db.getDatesBySequence(inscriptionSequence.id) : [];
|
|
|
|
if (inscriptionSequence && inscriptionApprenant && inscriptionFormation && dates.length > 0) {
|
|
// Utiliser la première date pour l'email principal
|
|
const premiereDate = dates[0];
|
|
|
|
await sendInscriptionConfirmation({
|
|
apprenantEmail: inscriptionApprenant.email,
|
|
apprenantNom: inscriptionApprenant.nom,
|
|
apprenantPrenom: inscriptionApprenant.prenom,
|
|
apprenantFonction: inscriptionApprenant.fonction || '',
|
|
formationNom: inscriptionFormation.nom,
|
|
sequenceNom: inscriptionSequence.nom,
|
|
dates: dates.map(d => ({
|
|
dateDebut: new Date(d.dateDebut),
|
|
dateFin: new Date(d.dateFin),
|
|
ordre: d.ordre,
|
|
})),
|
|
lieu: inscriptionSequence.lieu,
|
|
statut,
|
|
});
|
|
|
|
// Notification au formateur si présent
|
|
if (inscriptionSequence.formateurId) {
|
|
const formateur = await db.getFormateurById(inscriptionSequence.formateurId);
|
|
if (formateur && formateur.email) {
|
|
const nbInscritsActuel = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
|
|
try {
|
|
await sendNotificationFormateurNouvelleInscription({
|
|
formateurEmail: formateur.email,
|
|
formateurNom: formateur.nom,
|
|
apprenantNom: inscriptionApprenant.nom,
|
|
apprenantPrenom: inscriptionApprenant.prenom,
|
|
apprenantFonction: inscriptionApprenant.fonction || '',
|
|
apprenantEtablissement: inscriptionApprenant.codeEtablissement || '',
|
|
formationNom: inscriptionFormation.nom,
|
|
sequenceNom: inscriptionSequence.nom,
|
|
nbInscrits: nbInscritsActuel,
|
|
capaciteMax: inscriptionSequence.capaciteMax,
|
|
dates: dates.map(d => ({
|
|
dateDebut: new Date(d.dateDebut),
|
|
dateFin: new Date(d.dateFin),
|
|
ordre: d.ordre,
|
|
})),
|
|
});
|
|
// Logger la notification
|
|
await logNotification({
|
|
type: "notification_formateur_inscription",
|
|
sequenceId: input.sequenceId,
|
|
apprenantId: inscriptionApprenant.id,
|
|
formateurId: formateur.id,
|
|
emailDestinataire: formateur.email,
|
|
sujet: `Nouvelle inscription - ${inscriptionFormation.nom}`,
|
|
statut: "success",
|
|
});
|
|
console.log(`[Notification] Email envoyé au formateur ${formateur.email} pour nouvelle inscription`);
|
|
} catch (e: any) {
|
|
// Logger l'échec
|
|
await logNotification({
|
|
type: "notification_formateur_inscription",
|
|
sequenceId: input.sequenceId,
|
|
apprenantId: inscriptionApprenant.id,
|
|
formateurId: formateur.id,
|
|
emailDestinataire: formateur.email,
|
|
sujet: `Nouvelle inscription - ${inscriptionFormation.nom}`,
|
|
statut: "failed",
|
|
messageErreur: e.message,
|
|
});
|
|
console.error(`[Notification] Erreur envoi email formateur:`, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Alerte capacité atteinte pour les admins ET le formateur
|
|
const nbInscritsApres = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
|
|
if (nbInscritsApres >= inscriptionSequence.capaciteMax) {
|
|
try {
|
|
const admins = await db.getAdminUsers();
|
|
const adminEmails = admins.filter(a => a.email).map(a => a.email!);
|
|
if (adminEmails.length > 0) {
|
|
const nbListeAttente = await db.countInscriptionsBySequence(input.sequenceId, 'en_attente');
|
|
let formateurNom: string | undefined;
|
|
if (inscriptionSequence.formateurId) {
|
|
const formateur = await db.getFormateurById(inscriptionSequence.formateurId);
|
|
formateurNom = formateur?.nom;
|
|
}
|
|
await sendAlerteCapaciteAtteinte({
|
|
adminEmails,
|
|
formationNom: inscriptionFormation.nom,
|
|
sequenceNom: inscriptionSequence.nom,
|
|
capaciteMax: inscriptionSequence.capaciteMax,
|
|
nbInscrits: nbInscritsApres,
|
|
nbListeAttente,
|
|
formateurNom,
|
|
dates: dates.map(d => ({
|
|
dateDebut: new Date(d.dateDebut),
|
|
dateFin: new Date(d.dateFin),
|
|
ordre: d.ordre,
|
|
})),
|
|
});
|
|
console.log(`[Notification] Alerte capacité atteinte envoyée à ${adminEmails.length} admin(s)`);
|
|
}
|
|
|
|
// Notification au formateur pour capacité atteinte
|
|
if (inscriptionSequence.formateurId) {
|
|
const formateur = await db.getFormateurById(inscriptionSequence.formateurId);
|
|
if (formateur && formateur.email) {
|
|
const { sendNotificationFormateurCapaciteAtteinte } = await import('./emailService');
|
|
await sendNotificationFormateurCapaciteAtteinte({
|
|
formateurEmail: formateur.email,
|
|
formateurNom: formateur.nom,
|
|
formationNom: inscriptionFormation.nom,
|
|
sequenceNom: inscriptionSequence.nom,
|
|
capaciteMax: inscriptionSequence.capaciteMax,
|
|
nbInscrits: nbInscritsApres,
|
|
dates: dates.map(d => ({
|
|
dateDebut: new Date(d.dateDebut),
|
|
dateFin: new Date(d.dateFin),
|
|
ordre: d.ordre,
|
|
})),
|
|
});
|
|
console.log(`[Notification] Alerte capacité atteinte envoyée au formateur ${formateur.email}`);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error(`[Notification] Erreur envoi alerte capacité:`, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: true, statut };
|
|
}),
|
|
|
|
desinscrire: publicProcedure.input(z.object({
|
|
apprenantId: z.number(),
|
|
sequenceId: z.number(),
|
|
})).mutation(async ({ input }) => {
|
|
// Vérifier si la séquence existe
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
}
|
|
|
|
// Vérifier si la séquence est bloquée
|
|
const now = new Date();
|
|
if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'Les désinscriptions sont fermées pour cette séquence (J-15 dépassé)'
|
|
});
|
|
}
|
|
|
|
// Trouver l'inscription
|
|
const inscription = await db.checkExistingInscription(input.apprenantId, input.sequenceId);
|
|
if (!inscription) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Inscription introuvable' });
|
|
}
|
|
|
|
// Récupérer les infos avant la mise à jour pour les notifications
|
|
const apprenant = await db.getApprenantById(input.apprenantId);
|
|
const formation = await db.getFormationById(sequence.formationId);
|
|
|
|
await db.updateInscription(inscription.id, { statut: 'annulee' });
|
|
|
|
// Notifications après désinscription
|
|
if (apprenant && formation) {
|
|
// Notification au formateur
|
|
if (sequence.formateurId) {
|
|
const formateur = await db.getFormateurById(sequence.formateurId);
|
|
if (formateur && formateur.email) {
|
|
const nbInscritsApres = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
|
|
try {
|
|
await sendNotificationFormateurAnnulation({
|
|
formateurEmail: formateur.email,
|
|
formateurNom: formateur.nom,
|
|
apprenantNom: apprenant.nom,
|
|
apprenantPrenom: apprenant.prenom,
|
|
apprenantFonction: apprenant.fonction || '',
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
nbInscrits: nbInscritsApres,
|
|
capaciteMax: sequence.capaciteMax,
|
|
});
|
|
// Logger la notification
|
|
await logNotification({
|
|
type: "notification_formateur_annulation",
|
|
sequenceId: input.sequenceId,
|
|
apprenantId: apprenant.id,
|
|
formateurId: formateur.id,
|
|
emailDestinataire: formateur.email,
|
|
sujet: `Annulation d'inscription - ${formation.nom}`,
|
|
statut: "success",
|
|
});
|
|
console.log(`[Notification] Email d'annulation envoyé au formateur ${formateur.email}`);
|
|
} catch (e: any) {
|
|
// Logger l'échec
|
|
await logNotification({
|
|
type: "notification_formateur_annulation",
|
|
sequenceId: input.sequenceId,
|
|
apprenantId: apprenant.id,
|
|
formateurId: formateur.id,
|
|
emailDestinataire: formateur.email,
|
|
sujet: `Annulation d'inscription - ${formation.nom}`,
|
|
statut: "failed",
|
|
messageErreur: e.message,
|
|
});
|
|
console.error(`[Notification] Erreur envoi email formateur:`, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Notifier le premier en liste d'attente qu'une place s'est libérée
|
|
const inscriptionsListeAttente = await db.getInscriptionsBySequence(input.sequenceId);
|
|
const premierEnAttente = inscriptionsListeAttente
|
|
.filter(i => i.inscription.statut === 'en_attente')
|
|
.sort((a, b) => new Date(a.inscription.dateInscription).getTime() - new Date(b.inscription.dateInscription).getTime())[0];
|
|
|
|
if (premierEnAttente && premierEnAttente.apprenant) {
|
|
try {
|
|
await sendNotificationPlaceDisponible({
|
|
apprenantEmail: premierEnAttente.apprenant.email,
|
|
apprenantNom: premierEnAttente.apprenant.nom,
|
|
apprenantPrenom: premierEnAttente.apprenant.prenom,
|
|
apprenantFonction: premierEnAttente.apprenant.fonction || '',
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
positionListeAttente: 1,
|
|
delaiReponse: 48,
|
|
});
|
|
// Logger la notification
|
|
await logNotification({
|
|
type: "notification_liste_attente",
|
|
sequenceId: input.sequenceId,
|
|
apprenantId: premierEnAttente.apprenant.id,
|
|
emailDestinataire: premierEnAttente.apprenant.email,
|
|
sujet: `Place disponible - ${formation.nom}`,
|
|
statut: "success",
|
|
});
|
|
console.log(`[Notification] Email place disponible envoyé à ${premierEnAttente.apprenant.email}`);
|
|
} catch (e: any) {
|
|
// Logger l'échec
|
|
await logNotification({
|
|
type: "notification_liste_attente",
|
|
sequenceId: input.sequenceId,
|
|
apprenantId: premierEnAttente.apprenant.id,
|
|
emailDestinataire: premierEnAttente.apprenant.email,
|
|
sujet: `Place disponible - ${formation.nom}`,
|
|
statut: "failed",
|
|
messageErreur: e.message,
|
|
});
|
|
console.error(`[Notification] Erreur envoi email liste d'attente:`, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
updateStatut: adminProcedure.input(z.object({
|
|
id: z.number(),
|
|
statut: z.enum(['confirmee', 'en_attente', 'annulee']),
|
|
})).mutation(async ({ input }) => {
|
|
// Récupérer l'inscription avant modification
|
|
const inscriptionAvant = await db.getInscriptionById(input.id);
|
|
const ancienStatut = inscriptionAvant?.statut;
|
|
|
|
await db.updateInscription(input.id, { statut: input.statut });
|
|
|
|
// Si passage à annulée, notifier le formateur
|
|
if (input.statut === 'annulee' && ancienStatut !== 'annulee' && inscriptionAvant) {
|
|
const sequence = await db.getSequenceById(inscriptionAvant.sequenceId);
|
|
const apprenant = await db.getApprenantById(inscriptionAvant.apprenantId);
|
|
|
|
if (sequence && apprenant && sequence.formateurId) {
|
|
const formateur = await db.getFormateurById(sequence.formateurId);
|
|
const formation = await db.getFormationById(sequence.formationId);
|
|
|
|
if (formateur && formateur.email && formation) {
|
|
const nbInscritsApres = await db.countInscriptionsBySequence(sequence.id, 'confirmee');
|
|
|
|
try {
|
|
await sendNotificationFormateurAnnulation({
|
|
formateurEmail: formateur.email,
|
|
formateurNom: formateur.nom,
|
|
apprenantNom: apprenant.nom,
|
|
apprenantPrenom: apprenant.prenom,
|
|
apprenantFonction: apprenant.fonction || '',
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
nbInscrits: nbInscritsApres,
|
|
capaciteMax: sequence.capaciteMax,
|
|
});
|
|
console.log(`[Notification] Annulation envoyée au formateur ${formateur.email}`);
|
|
|
|
// Si une place se libère (passage de capacité max à disponible)
|
|
if (nbInscritsApres < sequence.capaciteMax && (nbInscritsApres + 1) >= sequence.capaciteMax) {
|
|
const { sendNotificationFormateurPlaceDisponible } = await import('./emailService');
|
|
await sendNotificationFormateurPlaceDisponible({
|
|
formateurEmail: formateur.email,
|
|
formateurNom: formateur.nom,
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
capaciteMax: sequence.capaciteMax,
|
|
nbInscrits: nbInscritsApres,
|
|
placesDisponibles: sequence.capaciteMax - nbInscritsApres,
|
|
});
|
|
console.log(`[Notification] Place disponible envoyée au formateur ${formateur.email}`);
|
|
}
|
|
} catch (e) {
|
|
console.error(`[Notification] Erreur envoi notification formateur:`, e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
sendGroupEmail: adminProcedure.input(z.object({ sequenceId: z.number(), type: z.enum(['teaser', 'rappel1', 'rappel2', 'rappel3', 'rappel4', 'rappel5', 'rappel6']) })).mutation(async ({ input }) => {
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
}
|
|
|
|
const formation = await db.getFormationById(sequence.formationId);
|
|
if (!formation) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
|
}
|
|
|
|
const dates = await db.getDatesBySequence(sequence.id);
|
|
if (dates.length === 0) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune date trouvée pour cette séquence' });
|
|
}
|
|
|
|
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
|
|
const recipients = inscriptions
|
|
.filter(i => i.inscription.statut === 'confirmee' && i.apprenant)
|
|
.map(i => ({
|
|
email: i.apprenant!.email,
|
|
prenom: i.apprenant!.prenom,
|
|
nom: i.apprenant!.nom,
|
|
fonction: i.apprenant!.fonction,
|
|
}));
|
|
|
|
console.log(`[Email Group] Envoi de ${recipients.length} emails de type ${input.type}`);
|
|
console.log(`[Email Group] Destinataires:`, recipients.map(r => r.email));
|
|
|
|
let formateurNom: string | undefined;
|
|
if (sequence.formateurId) {
|
|
const formateur = await db.getFormateurById(sequence.formateurId);
|
|
formateurNom = formateur?.nom;
|
|
}
|
|
|
|
const result = await sendGroupEmail({
|
|
recipients,
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
type: input.type,
|
|
dates: dates.map(d => ({
|
|
dateDebut: new Date(d.dateDebut),
|
|
dateFin: new Date(d.dateFin),
|
|
ordre: d.ordre,
|
|
})),
|
|
lieu: sequence.lieu,
|
|
formateur: formateurNom,
|
|
});
|
|
|
|
console.log(`[Email Group] Résultat: ${result.sent} envoyés, ${result.failed} échoués`);
|
|
|
|
return {
|
|
...result,
|
|
message: `${result.sent} email(s) envoyé(s) avec succès, ${result.failed} échoué(s)`
|
|
};
|
|
}),
|
|
|
|
emailPreview: adminProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
type: z.enum(['teaser', 'rappel1', 'rappel2', 'rappel3', 'rappel4', 'rappel5', 'rappel6']),
|
|
apprenantId: z.number().optional(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { generateEmailPreview } = await import('./emailPreview');
|
|
return await generateEmailPreview(input);
|
|
}),
|
|
|
|
exportExcel: protectedProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
|
|
const formation = await db.getFormationById(sequence.formationId);
|
|
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
|
|
|
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
|
|
const dates = await db.getDatesBySequence(input.sequenceId);
|
|
|
|
const data = inscriptions
|
|
.filter(i => i.apprenant)
|
|
.map(i => ({
|
|
nom: i.apprenant!.nom,
|
|
prenom: i.apprenant!.prenom,
|
|
email: i.apprenant!.email,
|
|
codeEtablissement: i.apprenant!.codeEtablissement,
|
|
fonction: i.apprenant!.fonction,
|
|
statut: i.inscription.statut,
|
|
dateInscription: i.inscription.dateInscription,
|
|
}));
|
|
|
|
const buffer = await generateExcelExport({
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
dates: dates.map(d => ({
|
|
dateDebut: new Date(d.dateDebut),
|
|
dateFin: new Date(d.dateFin),
|
|
ordre: d.ordre,
|
|
})),
|
|
lieu: sequence.lieu,
|
|
publicCible: sequence.publicCible,
|
|
inscriptions: data,
|
|
});
|
|
|
|
return {
|
|
buffer: buffer.toString('base64'),
|
|
filename: `inscriptions_${sequence.nom.replace(/\s+/g, '_')}.xlsx`,
|
|
};
|
|
}),
|
|
|
|
exportPDF: protectedProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
|
|
const formation = await db.getFormationById(sequence.formationId);
|
|
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
|
|
|
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
|
|
const dates = await db.getDatesBySequence(input.sequenceId);
|
|
|
|
const data = inscriptions
|
|
.filter(i => i.apprenant)
|
|
.map(i => ({
|
|
nom: i.apprenant!.nom,
|
|
prenom: i.apprenant!.prenom,
|
|
email: i.apprenant!.email,
|
|
codeEtablissement: i.apprenant!.codeEtablissement,
|
|
fonction: i.apprenant!.fonction,
|
|
statut: i.inscription.statut,
|
|
dateInscription: i.inscription.dateInscription,
|
|
}));
|
|
|
|
const buffer = await generatePDFExport({
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
dates: dates.map(d => ({
|
|
dateDebut: new Date(d.dateDebut),
|
|
dateFin: new Date(d.dateFin),
|
|
ordre: d.ordre,
|
|
})),
|
|
lieu: sequence.lieu,
|
|
publicCible: sequence.publicCible,
|
|
inscriptions: data,
|
|
});
|
|
|
|
return {
|
|
buffer: buffer.toString('base64'),
|
|
filename: `inscriptions_${sequence.nom.replace(/\s+/g, '_')}.pdf`,
|
|
};
|
|
}),
|
|
|
|
exportFeuillePresence: protectedProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
|
|
const formation = await db.getFormationById(sequence.formationId);
|
|
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
|
|
|
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
|
|
const dates = await db.getDatesBySequence(input.sequenceId);
|
|
|
|
// Récupérer le formateur si défini
|
|
let formateurNom: string | undefined = undefined;
|
|
if (sequence.formateurId) {
|
|
const formateur = await db.getFormateurById(sequence.formateurId);
|
|
if (formateur) {
|
|
formateurNom = formateur.nom;
|
|
}
|
|
}
|
|
|
|
// Récupérer toutes les présences avec signatures pour cette séquence
|
|
const { getPresencesBySequence, getPresencesFormateurBySequence } = await import("./presenceDb");
|
|
const presences = await getPresencesBySequence(input.sequenceId);
|
|
|
|
const data = inscriptions
|
|
.filter(i => i.inscription.statut === 'confirmee' && i.apprenant)
|
|
.map(i => {
|
|
// Récupérer les présences de cet apprenant
|
|
const apprenantPresences = presences
|
|
.filter(p => p.inscriptionId === i.inscription.id)
|
|
.map(p => ({
|
|
dateFormationId: p.dateFormationId,
|
|
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,
|
|
};
|
|
});
|
|
|
|
// Récupérer les présences du formateur
|
|
const presencesFormateur = await getPresencesFormateurBySequence(input.sequenceId);
|
|
|
|
const buffer = await generateFeuillePresence({
|
|
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,
|
|
presencesFormateur: presencesFormateur.map(p => ({
|
|
dateFormationId: p.dateFormationId,
|
|
periode: p.periode as 'matin' | 'apres_midi',
|
|
signatureUrl: p.signatureUrl,
|
|
})),
|
|
});
|
|
|
|
return {
|
|
buffer: buffer.toString('base64'),
|
|
filename: `feuille_presence_${sequence.nom.replace(/\s+/g, '_')}.pdf`,
|
|
};
|
|
}),
|
|
|
|
// Ajout manuel d'un inscrit par l'admin (sans vérifications de blocage/capacité)
|
|
create: adminProcedure.input(z.object({
|
|
apprenantId: z.number(),
|
|
sequenceId: z.number(),
|
|
statut: z.enum(["confirme", "liste_attente"]),
|
|
})).mutation(async ({ input }) => {
|
|
// Vérifier si l'apprenant existe
|
|
const apprenant = await db.getApprenantById(input.apprenantId);
|
|
if (!apprenant) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Apprenant introuvable' });
|
|
}
|
|
|
|
// Vérifier si la séquence existe
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
}
|
|
|
|
// Vérifier si l'apprenant est déjà inscrit
|
|
const existing = await db.checkExistingInscription(input.apprenantId, input.sequenceId);
|
|
if (existing) {
|
|
throw new TRPCError({
|
|
code: 'CONFLICT',
|
|
message: 'Cet apprenant est déjà inscrit à cette séquence'
|
|
});
|
|
}
|
|
|
|
// Créer l'inscription avec le statut choisi
|
|
await db.createInscription({
|
|
apprenantId: input.apprenantId,
|
|
sequenceId: input.sequenceId,
|
|
statut: input.statut === "confirme" ? "confirmee" : "en_attente",
|
|
});
|
|
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ===== GESTION DES UTILISATEURS =====
|
|
users: router({
|
|
list: adminProcedure.query(async () => {
|
|
return db.getAllUsers();
|
|
}),
|
|
|
|
listWithFormateurs: adminProcedure.query(async () => {
|
|
const dbInstance = await import("./db").then(m => m.getDb());
|
|
if (!dbInstance) throw new Error("Base de données non disponible");
|
|
|
|
const { formateurs, users } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
|
|
// Récupérer tous les formateurs
|
|
const allFormateurs = await dbInstance.select().from(formateurs);
|
|
|
|
// Récupérer tous les utilisateurs
|
|
const allUsers = await dbInstance.select().from(users);
|
|
|
|
// Créer une map des formateurs liés à des comptes
|
|
const formateurIdToUser = new Map();
|
|
allUsers.forEach(user => {
|
|
if (user.formateurId) {
|
|
formateurIdToUser.set(user.formateurId, user);
|
|
}
|
|
});
|
|
|
|
// Combiner les données
|
|
return {
|
|
users: allUsers,
|
|
formateurs: allFormateurs.map(formateur => ({
|
|
...formateur,
|
|
hasAccount: formateurIdToUser.has(formateur.id),
|
|
user: formateurIdToUser.get(formateur.id) || null,
|
|
})),
|
|
};
|
|
}),
|
|
|
|
getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
|
|
return db.getUserById(input.id);
|
|
}),
|
|
|
|
create: adminProcedure.input(z.object({
|
|
openId: z.string().min(1),
|
|
name: z.string().optional(),
|
|
email: z.string().email().optional(),
|
|
username: z.string().min(3).optional(),
|
|
password: z.string().min(6).optional(),
|
|
role: z.enum(["user", "admin", "formateur", "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 }) => {
|
|
const user = await db.getUserById(input.id);
|
|
if (!user) {
|
|
throw new Error("Utilisateur introuvable");
|
|
}
|
|
|
|
if (!user.email) {
|
|
throw new Error("Cet utilisateur n'a pas d'adresse email");
|
|
}
|
|
|
|
// Générer un token unique
|
|
const crypto = await import('crypto');
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
|
|
// Sauvegarder le token en base (la fonction gère l'expiration automatiquement)
|
|
await db.createPasswordResetToken(user.email, token);
|
|
|
|
// Générer le lien de réinitialisation
|
|
// TODO: Remplacer par l'URL réelle de votre application en production
|
|
const resetLink = `https://votre-domaine.com/reset-password?token=${token}`;
|
|
|
|
// Envoyer l'email
|
|
const emailService = await import('./emailService');
|
|
const emailSent = await emailService.sendPasswordResetEmail({
|
|
apprenantEmail: user.email,
|
|
apprenantNom: user.name || 'Utilisateur',
|
|
apprenantPrenom: '',
|
|
resetLink,
|
|
});
|
|
|
|
if (!emailSent) {
|
|
throw new Error("Échec de l'envoi de l'email");
|
|
}
|
|
|
|
return { success: true, message: "Email de réinitialisation envoyé" };
|
|
}),
|
|
}),
|
|
|
|
// ===== GESTION DES TEMPLATES D'EMAILS =====
|
|
emailTemplates: router({
|
|
list: adminProcedure.query(async () => {
|
|
return db.getAllEmailTemplates();
|
|
}),
|
|
|
|
getByType: adminProcedure
|
|
.input(z.object({ type: z.string() }))
|
|
.query(async ({ input }) => {
|
|
return db.getEmailTemplateByType(input.type);
|
|
}),
|
|
|
|
upsert: adminProcedure
|
|
.input(z.object({
|
|
type: z.enum(["inscription", "teaser", "rappel1", "rappel2", "rappel3", "rappel4", "rappel5", "rappel6", "reset_password", "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(),
|
|
echelleMin: z.number().optional(),
|
|
echelleMax: z.number().optional(),
|
|
echelleLabelMin: z.string().optional(),
|
|
echelleLabelMax: z.string().optional(),
|
|
obligatoire: z.boolean().default(false),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const questionnaireDb = await import("./questionnaireDb");
|
|
const id = await questionnaireDb.createQuestion(input);
|
|
return { id, success: true };
|
|
}),
|
|
|
|
update: adminProcedure
|
|
.input(z.object({
|
|
id: z.number(),
|
|
ordre: z.number().optional(),
|
|
texte: z.string().optional(),
|
|
typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]).optional(),
|
|
options: z.string().optional(),
|
|
echelleMin: z.number().optional(),
|
|
echelleMax: z.number().optional(),
|
|
echelleLabelMin: z.string().optional(),
|
|
echelleLabelMax: z.string().optional(),
|
|
obligatoire: z.boolean().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { id, ...data } = input;
|
|
const questionnaireDb = await import("./questionnaireDb");
|
|
await questionnaireDb.updateQuestion(id, data);
|
|
return { success: true };
|
|
}),
|
|
|
|
delete: adminProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
const questionnaireDb = await import("./questionnaireDb");
|
|
await questionnaireDb.deleteQuestion(input.id);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ===== RÉPONSES QUESTIONNAIRES (PUBLIC) =====
|
|
reponses: router({
|
|
getByToken: publicProcedure
|
|
.input(z.object({ token: z.string() }))
|
|
.query(async ({ input }) => {
|
|
const questionnaireDb = await import("./questionnaireDb");
|
|
const envoi = await questionnaireDb.getEnvoiByToken(input.token);
|
|
if (!envoi) return null;
|
|
|
|
const questionnaire = await questionnaireDb.getQuestionnaireById(envoi.questionnaireId);
|
|
const questions = await questionnaireDb.getQuestionsByQuestionnaireId(envoi.questionnaireId);
|
|
|
|
return {
|
|
envoi,
|
|
questionnaire,
|
|
questions,
|
|
};
|
|
}),
|
|
|
|
submit: publicProcedure
|
|
.input(z.object({
|
|
token: z.string(),
|
|
reponses: z.array(z.object({
|
|
questionId: z.number(),
|
|
reponseNumerique: z.number().optional(),
|
|
reponseTexte: z.string().optional(),
|
|
})),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const questionnaireDb = await import("./questionnaireDb");
|
|
|
|
// Récupérer l'envoi
|
|
const envoi = await questionnaireDb.getEnvoiByToken(input.token);
|
|
if (!envoi) throw new Error("Token invalide");
|
|
if (envoi.dateReponse) throw new Error("Questionnaire déjà répondu");
|
|
|
|
// Créer la réponse au questionnaire
|
|
const reponseQuestionnaireId = await questionnaireDb.createReponseQuestionnaire({
|
|
questionnaireId: envoi.questionnaireId,
|
|
apprenantId: envoi.apprenantId,
|
|
sequenceId: envoi.sequenceId,
|
|
});
|
|
|
|
// Enregistrer chaque réponse
|
|
for (const reponse of input.reponses) {
|
|
await questionnaireDb.createReponseQuestion({
|
|
reponseQuestionnaireId,
|
|
questionId: reponse.questionId,
|
|
reponseNumerique: reponse.reponseNumerique,
|
|
reponseTexte: reponse.reponseTexte,
|
|
});
|
|
}
|
|
|
|
// Mettre à jour l'envoi
|
|
await questionnaireDb.updateEnvoiQuestionnaire(envoi.id, {
|
|
dateReponse: new Date(),
|
|
});
|
|
|
|
// Envoyer une notification à l'administrateur
|
|
try {
|
|
const { notifyOwner } = await import('./_core/notification');
|
|
const questionnaire = await questionnaireDb.getQuestionnaireById(envoi.questionnaireId);
|
|
|
|
await notifyOwner({
|
|
title: 'Nouvelle réponse au questionnaire',
|
|
content: `Un apprenant a répondu au questionnaire "${questionnaire?.titre || 'Sans titre'}"`,
|
|
});
|
|
} catch (error) {
|
|
console.error('[Notification] Erreur lors de l\'envoi de la notification:', error);
|
|
// Ne pas bloquer la réponse si la notification échoue
|
|
}
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
envoyerAutomatiques: adminProcedure
|
|
.mutation(async () => {
|
|
const { processEnvoisAutomatiques } = await import('./questionnaireScheduler');
|
|
const result = await processEnvoisAutomatiques();
|
|
return result;
|
|
}),
|
|
}),
|
|
|
|
// ===== SUIVI QUESTIONNAIRES =====
|
|
questionnaireSuivi: router({
|
|
statsGlobales: adminProcedure.query(async () => {
|
|
const suiviDb = await import("./questionnaireSuiviDb");
|
|
return suiviDb.getStatsGlobales();
|
|
}),
|
|
|
|
statsByFormation: adminProcedure.query(async () => {
|
|
const suiviDb = await import("./questionnaireSuiviDb");
|
|
return suiviDb.getStatsByFormation();
|
|
}),
|
|
|
|
evolutionTemporelle: adminProcedure
|
|
.input(z.object({
|
|
startDate: z.date().optional(),
|
|
endDate: z.date().optional(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const suiviDb = await import("./questionnaireSuiviDb");
|
|
return suiviDb.getEvolutionTemporelle(input.startDate, input.endDate);
|
|
}),
|
|
|
|
statsByFormateur: adminProcedure.query(async () => {
|
|
const suiviDb = await import("./questionnaireSuiviDb");
|
|
return suiviDb.getStatsByFormateur();
|
|
}),
|
|
}),
|
|
|
|
// ===== ESPACE FORMATEUR =====
|
|
formateur: router({
|
|
calendrier: protectedProcedure
|
|
.input(z.object({
|
|
formateurId: z.number(),
|
|
dateDebut: z.date().optional(),
|
|
dateFin: z.date().optional(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const formateurDb = await import("./formateurDb");
|
|
return formateurDb.getCalendrierFormateur(input.formateurId, input.dateDebut, input.dateFin);
|
|
}),
|
|
|
|
apprenants: protectedProcedure
|
|
.input(z.object({ sequenceId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const formateurDb = await import("./formateurDb");
|
|
return formateurDb.getApprenantsSequence(input.sequenceId);
|
|
}),
|
|
|
|
supports: protectedProcedure
|
|
.input(z.object({ sequenceId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const formateurDb = await import("./formateurDb");
|
|
return formateurDb.getSupportsSequence(input.sequenceId);
|
|
}),
|
|
|
|
ajouterSupport: protectedProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
formateurId: z.number(),
|
|
nomFichier: z.string(),
|
|
typeFichier: z.string(),
|
|
tailleFichier: z.number(),
|
|
urlFichier: z.string(),
|
|
s3Key: z.string(),
|
|
description: z.string().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const formateurDb = await import("./formateurDb");
|
|
return formateurDb.ajouterSupport(input);
|
|
}),
|
|
|
|
supprimerSupport: protectedProcedure
|
|
.input(z.object({
|
|
supportId: z.number(),
|
|
formateurId: z.number(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const formateurDb = await import("./formateurDb");
|
|
const support = await formateurDb.supprimerSupport(input.supportId, input.formateurId);
|
|
|
|
// Note: La suppression du fichier S3 devrait être gérée par un processus de nettoyage séparé
|
|
// car il n'y a pas de fonction storageDelete dans l'API actuelle
|
|
|
|
return { success: true, support };
|
|
}),
|
|
|
|
validerPresence: protectedProcedure
|
|
.input(z.object({
|
|
inscriptionId: z.number(),
|
|
present: z.boolean(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const formateurDb = await import("./formateurDb");
|
|
await formateurDb.validerPresence(input.inscriptionId, input.present);
|
|
return { success: true };
|
|
}),
|
|
|
|
historique: protectedProcedure
|
|
.input(z.object({
|
|
formateurId: z.number(),
|
|
limit: z.number().optional(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const formateurDb = await import("./formateurDb");
|
|
return formateurDb.getHistoriqueFormateur(input.formateurId, input.limit);
|
|
}),
|
|
|
|
detailSequence: protectedProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
formateurId: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const formateurDb = await import("./formateurDb");
|
|
return formateurDb.getDetailSequence(input.sequenceId, input.formateurId);
|
|
}),
|
|
}),
|
|
|
|
// ===== ÉTABLISSEMENTS =====
|
|
etablissements: router({
|
|
list: adminProcedure.query(async () => {
|
|
const { getEtablissementsStats } = await import("./etablissementsDb");
|
|
return getEtablissementsStats();
|
|
}),
|
|
|
|
getDetail: adminProcedure
|
|
.input(z.object({ codeEtablissement: z.string() }))
|
|
.query(async ({ input }) => {
|
|
const { getEtablissementDetail } = await import("./etablissementsDb");
|
|
return getEtablissementDetail(input.codeEtablissement);
|
|
}),
|
|
}),
|
|
|
|
// ===== NOTIFICATIONS =====
|
|
notifications: router({
|
|
envoyerRemerciements: adminProcedure
|
|
.input(z.object({ sequenceId: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
const { envoyerRemerciementsSequence } = await import("./remerciementScheduler");
|
|
return envoyerRemerciementsSequence(input.sequenceId);
|
|
}),
|
|
|
|
envoyerRemerciementsAutomatiques: adminProcedure
|
|
.mutation(async () => {
|
|
const { processRemerciementsAutomatiques } = await import("./remerciementScheduler");
|
|
await processRemerciementsAutomatiques();
|
|
return { success: true };
|
|
}),
|
|
|
|
// Historique des notifications
|
|
historique: adminProcedure
|
|
.input(z.object({
|
|
type: z.enum(["remerciement", "notification_formateur_inscription", "notification_formateur_annulation", "alerte_capacite", "notification_liste_attente"]).optional(),
|
|
dateDebut: z.string().optional(),
|
|
dateFin: z.string().optional(),
|
|
statut: z.enum(["success", "failed"]).optional(),
|
|
email: z.string().optional(),
|
|
sequenceId: z.number().optional(),
|
|
limit: z.number().default(50),
|
|
offset: z.number().default(0),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { getNotificationLogs } = await import("./notificationLogsDb");
|
|
return getNotificationLogs({
|
|
type: input.type,
|
|
dateDebut: input.dateDebut ? new Date(input.dateDebut) : undefined,
|
|
dateFin: input.dateFin ? new Date(input.dateFin) : undefined,
|
|
statut: input.statut,
|
|
email: input.email,
|
|
sequenceId: input.sequenceId,
|
|
limit: input.limit,
|
|
offset: input.offset,
|
|
});
|
|
}),
|
|
|
|
// Statistiques des notifications
|
|
statistiques: adminProcedure
|
|
.input(z.object({
|
|
dateDebut: z.string().optional(),
|
|
dateFin: z.string().optional(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { getNotificationStats } = await import("./notificationLogsDb");
|
|
return getNotificationStats({
|
|
dateDebut: input.dateDebut ? new Date(input.dateDebut) : undefined,
|
|
dateFin: input.dateFin ? new Date(input.dateFin) : undefined,
|
|
});
|
|
}),
|
|
}),
|
|
|
|
// Attestations de formation
|
|
attestations: router({
|
|
// Générer une attestation pour une inscription
|
|
generer: adminProcedure
|
|
.input(z.object({
|
|
inscriptionId: z.number(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { genererAttestationPDF, enregistrerAttestation, getAttestation } = await import("./attestationService");
|
|
const db = await import("./db").then(m => m.getDb());
|
|
if (!db) throw new Error("Base de données non disponible");
|
|
|
|
// Vérifier si l'attestation existe déjà
|
|
const { inscriptions } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
const [inscription] = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
|
|
if (!inscription) {
|
|
throw new Error("Inscription introuvable");
|
|
}
|
|
|
|
const attestationExistante = await getAttestation(inscription.apprenantId, inscription.sequenceId);
|
|
if (attestationExistante) {
|
|
return {
|
|
success: true,
|
|
attestation: attestationExistante,
|
|
message: "Attestation déjà générée",
|
|
};
|
|
}
|
|
|
|
// Générer le PDF
|
|
const { s3Key, pdfUrl } = await genererAttestationPDF(input.inscriptionId);
|
|
|
|
// Enregistrer dans la base
|
|
const attestationId = await enregistrerAttestation(
|
|
input.inscriptionId,
|
|
inscription.apprenantId,
|
|
inscription.sequenceId,
|
|
s3Key,
|
|
pdfUrl
|
|
);
|
|
|
|
const attestation = await getAttestation(inscription.apprenantId, inscription.sequenceId);
|
|
|
|
return {
|
|
success: true,
|
|
attestation,
|
|
message: "Attestation générée avec succès",
|
|
};
|
|
}),
|
|
|
|
// Récupérer l'attestation d'un apprenant pour une séquence
|
|
get: publicProcedure
|
|
.input(z.object({
|
|
apprenantId: z.number(),
|
|
sequenceId: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { getAttestation } = await import("./attestationService");
|
|
return getAttestation(input.apprenantId, input.sequenceId);
|
|
}),
|
|
|
|
// Récupérer toutes les attestations d'un apprenant
|
|
listApprenant: publicProcedure
|
|
.input(z.object({
|
|
apprenantId: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { getAttestationsApprenant } = await import("./attestationService");
|
|
return getAttestationsApprenant(input.apprenantId);
|
|
}),
|
|
|
|
// Récupérer la configuration des attestations
|
|
getConfig: adminProcedure
|
|
.query(async () => {
|
|
const { getOrCreateConfigAttestation } = await import("./attestationService");
|
|
return getOrCreateConfigAttestation();
|
|
}),
|
|
|
|
// Mettre à jour la configuration des attestations
|
|
updateConfig: adminProcedure
|
|
.input(z.object({
|
|
nomSignataire: z.string().optional(),
|
|
fonctionSignataire: z.string().optional(),
|
|
texteAttestation: z.string().optional(),
|
|
logoUrl: z.string().optional(),
|
|
logoS3Key: z.string().optional(),
|
|
signatureUrl: z.string().optional(),
|
|
signatureS3Key: z.string().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { updateConfigAttestation } = await import("./attestationService");
|
|
await updateConfigAttestation(input);
|
|
return { success: true };
|
|
}),
|
|
|
|
// Récupérer l'historique des envois d'attestations
|
|
historique: adminProcedure
|
|
.query(async () => {
|
|
const { getHistoriqueAttestations } = await import("./attestationsDb");
|
|
return getHistoriqueAttestations();
|
|
}),
|
|
|
|
// Récupérer l'historique pour une séquence spécifique
|
|
historiqueBySequence: adminProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { getHistoriqueAttestationsBySequence } = await import("./attestationsDb");
|
|
return getHistoriqueAttestationsBySequence(input.sequenceId);
|
|
}),
|
|
|
|
// Générer une prévisualisation du modèle d'attestation
|
|
previsualiser: adminProcedure
|
|
.mutation(async () => {
|
|
const { genererPreviewAttestation } = await import("./attestationService");
|
|
return genererPreviewAttestation();
|
|
}),
|
|
|
|
// Uploader la signature manuscrite d'un apprenant
|
|
uploadSignature: publicProcedure
|
|
.input(z.object({
|
|
attestationId: z.number(),
|
|
signatureDataUrl: z.string(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { storagePut } = await import("./storage");
|
|
const { getDb } = await import("./db");
|
|
const { attestations } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
|
|
const db = await getDb();
|
|
if (!db) throw new Error("Base de données non disponible");
|
|
|
|
// Convertir le data URL en buffer
|
|
const base64Data = input.signatureDataUrl.replace(/^data:image\/png;base64,/, "");
|
|
const buffer = Buffer.from(base64Data, "base64");
|
|
|
|
// Générer un nom de fichier unique
|
|
const fileName = `signatures/attestation-${input.attestationId}-${Date.now()}.png`;
|
|
|
|
// Uploader vers S3
|
|
const { url, key } = await storagePut(fileName, buffer, "image/png");
|
|
|
|
// Mettre à jour l'attestation dans la base de données
|
|
await db
|
|
.update(attestations)
|
|
.set({
|
|
signatureUrl: url,
|
|
signatureS3Key: key,
|
|
dateSigned: new Date(),
|
|
})
|
|
.where(eq(attestations.id, input.attestationId));
|
|
|
|
return {
|
|
success: true,
|
|
signatureUrl: url,
|
|
};
|
|
}),
|
|
}),
|
|
|
|
// ===== PRESENCES (ÉMARGEMENT NUMÉRIQUE) =====
|
|
presences: router({
|
|
// Générer un QR code pour une séquence
|
|
generateQRCode: protectedProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const { generateQRToken, generateQRCodeDataURL } = await import("./qrCodeGenerator");
|
|
const { getDb } = await import("./db");
|
|
const { sequences } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
|
|
const db = await getDb();
|
|
if (!db) throw new Error("Database not available");
|
|
|
|
// Vérifier que la séquence existe
|
|
const sequence = await db.select().from(sequences).where(eq(sequences.id, input.sequenceId)).limit(1);
|
|
if (sequence.length === 0) {
|
|
throw new Error("Séquence introuvable");
|
|
}
|
|
|
|
// Générer un nouveau token si nécessaire
|
|
let token = sequence[0].qrCodeToken;
|
|
if (!token) {
|
|
token = generateQRToken();
|
|
await db.update(sequences).set({ qrCodeToken: token }).where(eq(sequences.id, input.sequenceId));
|
|
}
|
|
|
|
// Générer le QR code
|
|
const qrCodeDataURL = await generateQRCodeDataURL(token);
|
|
|
|
return {
|
|
token,
|
|
qrCodeDataURL,
|
|
};
|
|
}),
|
|
|
|
// Valider une présence (scan QR code ou manuel)
|
|
valider: publicProcedure
|
|
.input(z.object({
|
|
token: z.string().optional(),
|
|
email: z.string().email(),
|
|
inscriptionId: z.number().optional(),
|
|
dateFormationId: z.number(),
|
|
periode: z.enum(["matin", "apres_midi"]),
|
|
modeValidation: z.enum(["qrcode", "manuel"]),
|
|
validateurId: z.number().optional(),
|
|
commentaire: z.string().optional(),
|
|
signatureDataUrl: z.string().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { validerPresence, validerPresenceFormateur } = await import("./presenceDb");
|
|
const { getDb } = await import("./db");
|
|
const { sequences, inscriptions, formateurs } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
|
|
const db = await getDb();
|
|
if (!db) throw new Error("Database not available");
|
|
|
|
// Détecter si l'utilisateur est un formateur
|
|
const formateurData = await db.select().from(formateurs).where(eq(formateurs.email, input.email)).limit(1);
|
|
const isFormateur = formateurData.length > 0;
|
|
|
|
// Si mode QR code, vérifier le token
|
|
let sequenceId: number | undefined;
|
|
if (input.modeValidation === "qrcode" && input.token) {
|
|
// Vérifier que le token correspond à une séquence
|
|
const sequence = await db.select().from(sequences).where(eq(sequences.qrCodeToken, input.token)).limit(1);
|
|
if (sequence.length === 0) {
|
|
throw new Error("QR code invalide");
|
|
}
|
|
sequenceId = sequence[0].id;
|
|
|
|
// Si apprenant, vérifier l'inscription
|
|
if (!isFormateur && input.inscriptionId) {
|
|
const inscription = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
|
|
if (inscription.length === 0) {
|
|
throw new Error("Inscription introuvable");
|
|
}
|
|
if (inscription[0].sequenceId !== sequenceId) {
|
|
throw new Error("QR code invalide pour cette inscription");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Stocker la signature localement si fournie
|
|
let signatureUrl: string | undefined;
|
|
let signatureS3Key: string | undefined;
|
|
let finalSequenceId = sequenceId;
|
|
|
|
if (input.signatureDataUrl) {
|
|
const fs = await import("fs");
|
|
const path = await import("path");
|
|
const crypto = await import("crypto");
|
|
|
|
// Déterminer la séquence
|
|
if (!finalSequenceId) {
|
|
if (input.inscriptionId) {
|
|
const inscriptionData = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
|
|
if (inscriptionData.length === 0) throw new Error("Inscription introuvable");
|
|
finalSequenceId = inscriptionData[0].sequenceId;
|
|
} else {
|
|
throw new Error("Impossible de déterminer la séquence");
|
|
}
|
|
}
|
|
|
|
const sequenceData = await db.select().from(sequences).where(eq(sequences.id, finalSequenceId)).limit(1);
|
|
if (sequenceData.length === 0) throw new Error("Séquence introuvable");
|
|
|
|
const formationId = sequenceData[0].formationId;
|
|
|
|
// Créer la structure de dossiers
|
|
const uploadsDir = path.resolve(process.cwd(), "uploads", "signatures", `${formationId}-${finalSequenceId}`);
|
|
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 userType = isFormateur ? "formateur" : "apprenant";
|
|
const userId = isFormateur ? formateurData[0].id : input.inscriptionId;
|
|
const fileName = `signature-${userType}-${userId}-${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}-${finalSequenceId}/${fileName}`;
|
|
signatureS3Key = `signatures/${formationId}-${finalSequenceId}/${fileName}`;
|
|
}
|
|
|
|
let result;
|
|
if (isFormateur) {
|
|
// Valider la présence du formateur
|
|
if (!finalSequenceId) throw new Error("Impossible de déterminer la séquence");
|
|
result = await validerPresenceFormateur({
|
|
formateurId: formateurData[0].id,
|
|
sequenceId: finalSequenceId,
|
|
dateFormationId: input.dateFormationId,
|
|
periode: input.periode,
|
|
modeValidation: input.modeValidation,
|
|
commentaire: input.commentaire,
|
|
signatureUrl,
|
|
signatureS3Key,
|
|
});
|
|
} else {
|
|
// Valider la présence de l'apprenant
|
|
if (!input.inscriptionId) throw new Error("Inscription requise pour un apprenant");
|
|
result = await validerPresence({
|
|
inscriptionId: input.inscriptionId,
|
|
dateFormationId: input.dateFormationId,
|
|
periode: input.periode,
|
|
modeValidation: input.modeValidation,
|
|
validateurId: input.validateurId,
|
|
commentaire: input.commentaire,
|
|
signatureUrl,
|
|
signatureS3Key,
|
|
});
|
|
|
|
// Vérifier si toutes les présences sont validées
|
|
const { checkAllPresencesValidated } = await import("./presenceDb");
|
|
const allValidated = await checkAllPresencesValidated(input.inscriptionId);
|
|
|
|
// Si toutes les présences sont validées, générer l'attestation automatiquement
|
|
if (allValidated) {
|
|
try {
|
|
const { genererAttestationPDF, enregistrerAttestation } = await import("./attestationService");
|
|
|
|
// Générer le PDF
|
|
const { s3Key, pdfUrl } = await genererAttestationPDF(input.inscriptionId);
|
|
|
|
// Récupérer les données de l'inscription
|
|
const db = await getDb();
|
|
if (db) {
|
|
const inscriptionData = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
|
|
if (inscriptionData.length > 0) {
|
|
// Enregistrer l'attestation en base
|
|
await enregistrerAttestation(
|
|
input.inscriptionId,
|
|
inscriptionData[0].apprenantId,
|
|
inscriptionData[0].sequenceId,
|
|
s3Key,
|
|
pdfUrl
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
...result,
|
|
attestationGenerated: true,
|
|
attestationUrl: pdfUrl,
|
|
};
|
|
} catch (error) {
|
|
console.error("Erreur lors de la génération de l'attestation:", error);
|
|
// Ne pas bloquer la validation de présence si l'attestation échoue
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}),
|
|
|
|
// Lister les présences pour une séquence
|
|
listBySequence: protectedProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { getPresencesBySequence } = await import("./presenceDb");
|
|
return await getPresencesBySequence(input.sequenceId);
|
|
}),
|
|
|
|
// Lister les présences pour une inscription
|
|
listByInscription: publicProcedure
|
|
.input(z.object({
|
|
inscriptionId: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { getPresencesByInscription } = await import("./presenceDb");
|
|
return getPresencesByInscription(input.inscriptionId);
|
|
}),
|
|
|
|
// Supprimer une présence
|
|
delete: protectedProcedure
|
|
.input(z.object({
|
|
presenceId: z.number(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { supprimerPresence } = await import("./presenceDb");
|
|
return supprimerPresence(input.presenceId);
|
|
}),
|
|
|
|
// Vérifier si toutes les présences sont validées pour une inscription
|
|
checkAllValidated: publicProcedure
|
|
.input(z.object({
|
|
inscriptionId: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { checkAllPresencesValidated } = await import("./presenceDb");
|
|
return checkAllPresencesValidated(input.inscriptionId);
|
|
}),
|
|
}),
|
|
|
|
// ===== GESTION DES ATTESTATIONS =====
|
|
gestionAttestations: router({
|
|
// Récupérer la configuration d'une formation
|
|
getFormationConfig: adminProcedure
|
|
.input(z.object({ formationId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const { getFormationConfig } = await import("./gestionAttestationsDb");
|
|
return getFormationConfig(input.formationId);
|
|
}),
|
|
|
|
// Mettre à jour la configuration d'une formation
|
|
updateFormationConfig: adminProcedure
|
|
.input(z.object({
|
|
formationId: z.number(),
|
|
modeAttestation: z.enum(["auto", "manuel"]),
|
|
modeEnvoi: z.enum(["auto", "manuel"]),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { updateFormationConfig } = await import("./gestionAttestationsDb");
|
|
return updateFormationConfig(
|
|
input.formationId,
|
|
input.modeAttestation,
|
|
input.modeEnvoi
|
|
);
|
|
}),
|
|
|
|
// Récupérer les apprenants avec leur statut d'attestation
|
|
getApprenantsWithStatus: protectedProcedure
|
|
.input(z.object({ formationId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
try {
|
|
const { getApprenantsWithAttestationStatus } = await import("./gestionAttestationsDb");
|
|
const results = await getApprenantsWithAttestationStatus(input.formationId);
|
|
console.log("[gestionAttestations] Found", results.length, "apprenants for formation", input.formationId);
|
|
return results;
|
|
} catch (error) {
|
|
console.error("[gestionAttestations] Error fetching apprenants:", error);
|
|
throw error;
|
|
}
|
|
}),
|
|
|
|
// Uploader un document d'attestation
|
|
uploadDocument: 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);
|
|
}),
|
|
}),
|
|
});
|
|
|
|
export type AppRouter = typeof appRouter;
|