3141 lines
119 KiB
TypeScript
3141 lines
119 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, generateFeuillePresenceParJour } 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({
|
|
id: z.number().optional(), // ID optionnel pour identifier les dates existantes
|
|
dateDebut: z.string(),
|
|
dateFin: z.string(),
|
|
ordre: z.number(),
|
|
})).min(1).max(4),
|
|
})).mutation(async ({ input }) => {
|
|
const { id, dates, ...sequenceData } = input;
|
|
|
|
console.log('[UPDATE SEQUENCE] Input reçu:', JSON.stringify(input, null, 2));
|
|
|
|
// Valider que chaque date de fin est après la date de début
|
|
for (const date of dates) {
|
|
const debut = new Date(date.dateDebut);
|
|
const fin = new Date(date.dateFin);
|
|
if (fin <= debut) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'La date de fin doit être après la date de début pour toutes les dates de formation',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Mettre à jour la séquence
|
|
const updateData: any = {
|
|
...sequenceData,
|
|
dateBlocage: sequenceData.dateBlocage && sequenceData.dateBlocage.trim() !== ''
|
|
? parseLocalDateTime(sequenceData.dateBlocage)
|
|
: null,
|
|
};
|
|
|
|
console.log('[UPDATE SEQUENCE] UpdateData:', JSON.stringify(updateData, null, 2));
|
|
await db.updateSequence(id, updateData);
|
|
console.log('[UPDATE SEQUENCE] Séquence mise à jour avec succès');
|
|
|
|
// Récupérer les dates existantes
|
|
const existingDates = await db.getDatesBySequence(id);
|
|
console.log('[UPDATE SEQUENCE] Dates existantes:', existingDates.length);
|
|
|
|
// Créer un map des dates existantes par ID
|
|
const existingDatesMap = new Map(existingDates.map(d => [d.id, d]));
|
|
|
|
// Créer un Set des IDs de dates à conserver
|
|
const datesToKeep = new Set<number>();
|
|
|
|
// Traiter chaque date de la nouvelle liste
|
|
for (const date of dates) {
|
|
if (date.id && existingDatesMap.has(date.id)) {
|
|
// Date existante : mettre à jour si nécessaire
|
|
const existing = existingDatesMap.get(date.id)!;
|
|
const newDebut = parseLocalDateTime(date.dateDebut);
|
|
const newFin = parseLocalDateTime(date.dateFin);
|
|
|
|
const debutChanged = new Date(existing.dateDebut).getTime() !== newDebut.getTime();
|
|
const finChanged = new Date(existing.dateFin).getTime() !== newFin.getTime();
|
|
const ordreChanged = existing.ordre !== date.ordre;
|
|
|
|
if (debutChanged || finChanged || ordreChanged) {
|
|
console.log('[UPDATE SEQUENCE] Mise à jour date ID:', date.id);
|
|
await db.updateDateFormation(date.id, {
|
|
dateDebut: newDebut,
|
|
dateFin: newFin,
|
|
ordre: date.ordre,
|
|
});
|
|
}
|
|
datesToKeep.add(date.id);
|
|
} else {
|
|
// Nouvelle date : créer
|
|
console.log('[UPDATE SEQUENCE] Création nouvelle date:', date);
|
|
await db.createDateFormation({
|
|
sequenceId: id,
|
|
dateDebut: parseLocalDateTime(date.dateDebut),
|
|
dateFin: parseLocalDateTime(date.dateFin),
|
|
ordre: date.ordre,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Supprimer les dates qui ne sont plus dans la liste
|
|
for (const existing of existingDates) {
|
|
if (!datesToKeep.has(existing.id)) {
|
|
console.log('[UPDATE SEQUENCE] Suppression date ID:', existing.id);
|
|
await db.deleteDateFormation(existing.id);
|
|
}
|
|
}
|
|
|
|
console.log('[UPDATE SEQUENCE] Gestion des dates terminée');
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
|
// Supprimer d'abord les dates associées
|
|
await db.deleteDatesBySequence(input.id);
|
|
// Puis supprimer la séquence
|
|
await db.deleteSequence(input.id);
|
|
return { success: true };
|
|
}),
|
|
|
|
getDatesByFormateurToken: publicProcedure
|
|
.input(z.object({ token: z.string() }))
|
|
.query(async ({ input }) => {
|
|
const sequence = await db.getSequenceByFormateurToken(input.token);
|
|
if (!sequence) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
}
|
|
const dates = await db.getDatesBySequence(sequence.id);
|
|
return { sequence, dates };
|
|
}),
|
|
}),
|
|
|
|
// ===== INSCRIPTIONS =====
|
|
inscriptions: router({
|
|
// Liste des inscriptions avec dates de formation (pour formateurs)
|
|
listWithDates: protectedProcedure
|
|
.input(z.object({ sequenceId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const { getInscriptionsWithDates } = await import("./inscriptionWithDates");
|
|
return getInscriptionsWithDates(input.sequenceId);
|
|
}),
|
|
|
|
listAll: adminProcedure.query(async () => {
|
|
return db.getAllInscriptions();
|
|
}),
|
|
|
|
listBySequence: protectedProcedure.input(z.object({ sequenceId: z.number() })).query(async ({ input }) => {
|
|
return db.getInscriptionsBySequence(input.sequenceId);
|
|
}),
|
|
|
|
listByApprenant: publicProcedure.input(z.object({ apprenantId: z.number() })).query(async ({ input }) => {
|
|
return db.getInscriptionsByApprenant(input.apprenantId);
|
|
}),
|
|
|
|
listByApprenantWithDates: publicProcedure
|
|
.input(z.object({ apprenantId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const { getInscriptionsByApprenantWithDates } = await import("./inscriptionWithDates");
|
|
return getInscriptionsByApprenantWithDates(input.apprenantId);
|
|
}),
|
|
|
|
checkExisting: publicProcedure.input(z.object({
|
|
apprenantId: z.number(),
|
|
sequenceId: z.number(),
|
|
})).query(async ({ input }) => {
|
|
return db.checkExistingInscription(input.apprenantId, input.sequenceId);
|
|
}),
|
|
|
|
inscrire: publicProcedure.input(z.object({
|
|
apprenantId: z.number(),
|
|
sequenceId: z.number(),
|
|
})).mutation(async ({ input }) => {
|
|
// Vérifier si la séquence existe
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
}
|
|
|
|
// Vérifier si la séquence est bloquée
|
|
const now = new Date();
|
|
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) {
|
|
// Vérifier si les notifications sont activées
|
|
const { getParametres } = await import("./parametresDb");
|
|
const parametres = await getParametres();
|
|
|
|
if (parametres.notificationsActives) {
|
|
const nbInscritsActuel = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
|
|
try {
|
|
await sendNotificationFormateurNouvelleInscription({
|
|
formateurEmail: formateur.email,
|
|
formateurNom: formateur.nom,
|
|
apprenantNom: inscriptionApprenant.nom,
|
|
apprenantPrenom: inscriptionApprenant.prenom,
|
|
apprenantFonction: inscriptionApprenant.fonction || '',
|
|
apprenantEtablissement: inscriptionApprenant.codeEtablissement || '',
|
|
formationNom: inscriptionFormation.nom,
|
|
sequenceNom: inscriptionSequence.nom,
|
|
nbInscrits: nbInscritsActuel,
|
|
capaciteMax: inscriptionSequence.capaciteMax,
|
|
dates: dates.map(d => ({
|
|
dateDebut: new Date(d.dateDebut),
|
|
dateFin: new Date(d.dateFin),
|
|
ordre: d.ordre,
|
|
})),
|
|
});
|
|
// Logger la notification
|
|
await logNotification({
|
|
type: "notification_formateur_inscription",
|
|
sequenceId: input.sequenceId,
|
|
apprenantId: inscriptionApprenant.id,
|
|
formateurId: formateur.id,
|
|
emailDestinataire: formateur.email,
|
|
sujet: `Nouvelle inscription - ${inscriptionFormation.nom}`,
|
|
statut: "success",
|
|
});
|
|
console.log(`[Notification] Email envoyé au formateur ${formateur.email} pour nouvelle inscription`);
|
|
} catch (e: any) {
|
|
// Logger l'échec
|
|
await logNotification({
|
|
type: "notification_formateur_inscription",
|
|
sequenceId: input.sequenceId,
|
|
apprenantId: inscriptionApprenant.id,
|
|
formateurId: formateur.id,
|
|
emailDestinataire: formateur.email,
|
|
sujet: `Nouvelle inscription - ${inscriptionFormation.nom}`,
|
|
statut: "failed",
|
|
messageErreur: e.message,
|
|
});
|
|
console.error(`[Notification] Erreur envoi email formateur:`, e);
|
|
}
|
|
} else {
|
|
console.log(`[Notification] Notifications désactivées - notification formateur inscription non envoyée`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Alerte capacité atteinte pour les admins ET le formateur
|
|
const nbInscritsApres = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
|
|
if (nbInscritsApres >= inscriptionSequence.capaciteMax) {
|
|
try {
|
|
const admins = await db.getAdminUsers();
|
|
const adminEmails = admins.filter(a => a.email).map(a => a.email!);
|
|
if (adminEmails.length > 0) {
|
|
const nbListeAttente = await db.countInscriptionsBySequence(input.sequenceId, '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) {
|
|
// Vérifier si les notifications sont activées
|
|
const { getParametres } = await import("./parametresDb");
|
|
const parametres = await getParametres();
|
|
|
|
if (parametres.notificationsActives) {
|
|
const nbInscritsApres = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
|
|
try {
|
|
await sendNotificationFormateurAnnulation({
|
|
formateurEmail: formateur.email,
|
|
formateurNom: formateur.nom,
|
|
apprenantNom: apprenant.nom,
|
|
apprenantPrenom: apprenant.prenom,
|
|
apprenantFonction: apprenant.fonction || '',
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
nbInscrits: nbInscritsApres,
|
|
capaciteMax: sequence.capaciteMax,
|
|
});
|
|
// Logger la notification
|
|
await logNotification({
|
|
type: "notification_formateur_annulation",
|
|
sequenceId: input.sequenceId,
|
|
apprenantId: apprenant.id,
|
|
formateurId: formateur.id,
|
|
emailDestinataire: formateur.email,
|
|
sujet: `Annulation d'inscription - ${formation.nom}`,
|
|
statut: "success",
|
|
});
|
|
console.log(`[Notification] Email d'annulation envoyé au formateur ${formateur.email}`);
|
|
} catch (e: any) {
|
|
// Logger l'échec
|
|
await logNotification({
|
|
type: "notification_formateur_annulation",
|
|
sequenceId: input.sequenceId,
|
|
apprenantId: apprenant.id,
|
|
formateurId: formateur.id,
|
|
emailDestinataire: formateur.email,
|
|
sujet: `Annulation d'inscription - ${formation.nom}`,
|
|
statut: "failed",
|
|
messageErreur: e.message,
|
|
});
|
|
console.error(`[Notification] Erreur envoi email formateur:`, e);
|
|
}
|
|
} else {
|
|
console.log(`[Notification] Notifications désactivées - notification formateur annulation non envoyée`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Notifier le premier en liste d'attente qu'une place s'est libérée
|
|
const inscriptionsListeAttente = await db.getInscriptionsBySequence(input.sequenceId);
|
|
const premierEnAttente = inscriptionsListeAttente
|
|
.filter(i => i.inscription.statut === '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) {
|
|
// Vérifier si les notifications sont activées
|
|
const { getParametres } = await import("./parametresDb");
|
|
const parametres = await getParametres();
|
|
|
|
if (parametres.notificationsActives) {
|
|
const nbInscritsApres = await db.countInscriptionsBySequence(sequence.id, 'confirmee');
|
|
|
|
try {
|
|
await sendNotificationFormateurAnnulation({
|
|
formateurEmail: formateur.email,
|
|
formateurNom: formateur.nom,
|
|
apprenantNom: apprenant.nom,
|
|
apprenantPrenom: apprenant.prenom,
|
|
apprenantFonction: apprenant.fonction || '',
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
nbInscrits: nbInscritsApres,
|
|
capaciteMax: sequence.capaciteMax,
|
|
});
|
|
console.log(`[Notification] Annulation envoyée au formateur ${formateur.email}`);
|
|
|
|
// Si une place se libère (passage de capacité max à disponible)
|
|
if (nbInscritsApres < sequence.capaciteMax && (nbInscritsApres + 1) >= sequence.capaciteMax) {
|
|
const { sendNotificationFormateurPlaceDisponible } = await import('./emailService');
|
|
await sendNotificationFormateurPlaceDisponible({
|
|
formateurEmail: formateur.email,
|
|
formateurNom: formateur.nom,
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
capaciteMax: sequence.capaciteMax,
|
|
nbInscrits: nbInscritsApres,
|
|
placesDisponibles: sequence.capaciteMax - nbInscritsApres,
|
|
});
|
|
console.log(`[Notification] Place disponible envoyée au formateur ${formateur.email}`);
|
|
}
|
|
} catch (e) {
|
|
console.error(`[Notification] Erreur envoi notification formateur:`, e);
|
|
}
|
|
} else {
|
|
console.log(`[Notification] Notifications désactivées - notification formateur annulation non envoyée`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
sendGroupEmail: adminProcedure.input(z.object({ sequenceId: z.number(), type: z.enum(['teaser', 'rappel1', 'rappel2', 'rappel3', 'rappel4', 'rappel5', 'rappel6']) })).mutation(async ({ input }) => {
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
}
|
|
|
|
const formation = await db.getFormationById(sequence.formationId);
|
|
if (!formation) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
|
}
|
|
|
|
const dates = await db.getDatesBySequence(sequence.id);
|
|
if (dates.length === 0) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune date trouvée pour cette séquence' });
|
|
}
|
|
|
|
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
|
|
const recipients = inscriptions
|
|
.filter(i => i.inscription.statut === 'confirmee' && i.apprenant)
|
|
.map(i => ({
|
|
email: i.apprenant!.email,
|
|
prenom: i.apprenant!.prenom,
|
|
nom: i.apprenant!.nom,
|
|
fonction: i.apprenant!.fonction,
|
|
}));
|
|
|
|
console.log(`[Email Group] Envoi de ${recipients.length} emails de type ${input.type}`);
|
|
console.log(`[Email Group] Destinataires:`, recipients.map(r => r.email));
|
|
|
|
let formateurNom: string | undefined;
|
|
if (sequence.formateurId) {
|
|
const formateur = await db.getFormateurById(sequence.formateurId);
|
|
formateurNom = formateur?.nom;
|
|
}
|
|
|
|
const result = await sendGroupEmail({
|
|
recipients,
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
type: input.type,
|
|
dates: dates.map(d => ({
|
|
dateDebut: new Date(d.dateDebut),
|
|
dateFin: new Date(d.dateFin),
|
|
ordre: d.ordre,
|
|
})),
|
|
lieu: sequence.lieu,
|
|
formateur: formateurNom,
|
|
});
|
|
|
|
console.log(`[Email Group] Résultat: ${result.sent} envoyés, ${result.failed} échoués`);
|
|
|
|
return {
|
|
...result,
|
|
message: `${result.sent} email(s) envoyé(s) avec succès, ${result.failed} échoué(s)`
|
|
};
|
|
}),
|
|
|
|
emailPreview: adminProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
type: z.enum(['teaser', 'rappel1', 'rappel2', 'rappel3', 'rappel4', 'rappel5', 'rappel6']),
|
|
apprenantId: z.number().optional(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { generateEmailPreview } = await import('./emailPreview');
|
|
return await generateEmailPreview(input);
|
|
}),
|
|
|
|
exportExcel: protectedProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
|
|
const formation = await db.getFormationById(sequence.formationId);
|
|
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
|
|
|
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
|
|
const dates = await db.getDatesBySequence(input.sequenceId);
|
|
|
|
const data = inscriptions
|
|
.filter(i => i.apprenant)
|
|
.map(i => ({
|
|
nom: i.apprenant!.nom,
|
|
prenom: i.apprenant!.prenom,
|
|
email: i.apprenant!.email,
|
|
codeEtablissement: i.apprenant!.codeEtablissement,
|
|
fonction: i.apprenant!.fonction,
|
|
statut: i.inscription.statut,
|
|
dateInscription: i.inscription.dateInscription,
|
|
}));
|
|
|
|
const buffer = await generateExcelExport({
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
dates: dates.map(d => ({
|
|
dateDebut: new Date(d.dateDebut),
|
|
dateFin: new Date(d.dateFin),
|
|
ordre: d.ordre,
|
|
})),
|
|
lieu: sequence.lieu,
|
|
publicCible: sequence.publicCible,
|
|
inscriptions: data,
|
|
});
|
|
|
|
return {
|
|
buffer: buffer.toString('base64'),
|
|
filename: `inscriptions_${sequence.nom.replace(/\s+/g, '_')}.xlsx`,
|
|
};
|
|
}),
|
|
|
|
exportPDF: protectedProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
|
|
const formation = await db.getFormationById(sequence.formationId);
|
|
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
|
|
|
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
|
|
const dates = await db.getDatesBySequence(input.sequenceId);
|
|
|
|
const data = inscriptions
|
|
.filter(i => i.apprenant)
|
|
.map(i => ({
|
|
nom: i.apprenant!.nom,
|
|
prenom: i.apprenant!.prenom,
|
|
email: i.apprenant!.email,
|
|
codeEtablissement: i.apprenant!.codeEtablissement,
|
|
fonction: i.apprenant!.fonction,
|
|
statut: i.inscription.statut,
|
|
dateInscription: i.inscription.dateInscription,
|
|
}));
|
|
|
|
const buffer = await generatePDFExport({
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
dates: dates.map(d => ({
|
|
dateDebut: new Date(d.dateDebut),
|
|
dateFin: new Date(d.dateFin),
|
|
ordre: d.ordre,
|
|
})),
|
|
lieu: sequence.lieu,
|
|
publicCible: sequence.publicCible,
|
|
inscriptions: data,
|
|
});
|
|
|
|
return {
|
|
buffer: buffer.toString('base64'),
|
|
filename: `inscriptions_${sequence.nom.replace(/\s+/g, '_')}.pdf`,
|
|
};
|
|
}),
|
|
|
|
exportFeuillePresence: protectedProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
|
|
const formation = await db.getFormationById(sequence.formationId);
|
|
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
|
|
|
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
|
|
const dates = await db.getDatesBySequence(input.sequenceId);
|
|
|
|
// Récupérer le formateur si défini
|
|
let formateurNom: string | undefined = undefined;
|
|
if (sequence.formateurId) {
|
|
const formateur = await db.getFormateurById(sequence.formateurId);
|
|
if (formateur) {
|
|
formateurNom = formateur.nom;
|
|
}
|
|
}
|
|
|
|
// Récupérer toutes les présences avec signatures pour cette séquence
|
|
const { getPresencesBySequence, getSignaturesFormateursBySequence } = await import("./presenceDb");
|
|
const presences = await getPresencesBySequence(input.sequenceId);
|
|
const signaturesFormateurs = await getSignaturesFormateursBySequence(input.sequenceId);
|
|
|
|
const data = inscriptions
|
|
.filter(i => i.inscription.statut === 'confirmee' && i.apprenant)
|
|
.map(i => {
|
|
// Récupérer les présences de cet apprenant
|
|
const apprenantPresences = presences
|
|
.filter(p => p.inscriptionId === i.inscription.id)
|
|
.map(p => ({
|
|
dateFormationId: p.dateFormationId,
|
|
periode: p.periode as 'matin' | 'apres_midi',
|
|
signatureUrl: p.signatureUrl,
|
|
}));
|
|
|
|
return {
|
|
nom: i.apprenant!.nom,
|
|
prenom: i.apprenant!.prenom,
|
|
codeEtablissement: i.apprenant!.codeEtablissement,
|
|
fonction: i.apprenant!.fonction,
|
|
presences: apprenantPresences,
|
|
};
|
|
});
|
|
|
|
const pdfsParJour = generateFeuillePresenceParJour({
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
dates: dates.map(d => ({
|
|
id: d.id,
|
|
dateDebut: new Date(d.dateDebut),
|
|
dateFin: new Date(d.dateFin),
|
|
ordre: d.ordre,
|
|
})),
|
|
lieu: sequence.lieu,
|
|
publicCible: sequence.publicCible,
|
|
formateur: formateurNom,
|
|
apprenants: data,
|
|
signaturesFormateurs: signaturesFormateurs.map(s => ({
|
|
dateFormationId: s.dateFormationId,
|
|
periode: s.periode as 'matin' | 'apres_midi',
|
|
signatureUrl: s.signatureUrl,
|
|
})),
|
|
});
|
|
|
|
// Retourner un tableau de PDFs (un par jour)
|
|
return pdfsParJour.map(({ ordre, filename, buffer }) => ({
|
|
ordre,
|
|
buffer: buffer.toString('base64'),
|
|
filename,
|
|
}));
|
|
}),
|
|
|
|
// Ajout manuel d'un inscrit par l'admin (sans vérifications de blocage/capacité)
|
|
create: adminProcedure.input(z.object({
|
|
apprenantId: z.number(),
|
|
sequenceId: z.number(),
|
|
statut: z.enum(["confirme", "liste_attente"]),
|
|
})).mutation(async ({ input }) => {
|
|
// Vérifier si l'apprenant existe
|
|
const apprenant = await db.getApprenantById(input.apprenantId);
|
|
if (!apprenant) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Apprenant introuvable' });
|
|
}
|
|
|
|
// Vérifier si la séquence existe
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
}
|
|
|
|
// Vérifier si l'apprenant est déjà inscrit
|
|
const existing = await db.checkExistingInscription(input.apprenantId, input.sequenceId);
|
|
if (existing) {
|
|
throw new TRPCError({
|
|
code: 'CONFLICT',
|
|
message: 'Cet apprenant est déjà inscrit à cette séquence'
|
|
});
|
|
}
|
|
|
|
// Créer l'inscription avec le statut choisi
|
|
await db.createInscription({
|
|
apprenantId: input.apprenantId,
|
|
sequenceId: input.sequenceId,
|
|
statut: input.statut === "confirme" ? "confirmee" : "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();
|
|
}),
|
|
|
|
|
|
}),
|
|
|
|
// ===== PRESENCES (ÉMARGEMENT NUMÉRIQUE) =====
|
|
presences: router({
|
|
// Générer un QR code pour une séquence
|
|
generateQRCode: protectedProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const { generateQRToken, generateQRCodeDataURL } = await import("./qrCodeGenerator");
|
|
const { getDb } = await import("./db");
|
|
const { sequences } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
|
|
const db = await getDb();
|
|
if (!db) throw new Error("Database not available");
|
|
|
|
// Vérifier que la séquence existe
|
|
const sequence = await db.select().from(sequences).where(eq(sequences.id, input.sequenceId)).limit(1);
|
|
if (sequence.length === 0) {
|
|
throw new Error("Séquence introuvable");
|
|
}
|
|
|
|
// Générer un nouveau token si nécessaire
|
|
let token = sequence[0].qrCodeToken;
|
|
if (!token) {
|
|
token = generateQRToken();
|
|
await db.update(sequences).set({ qrCodeToken: token }).where(eq(sequences.id, input.sequenceId));
|
|
}
|
|
|
|
// Générer le QR code
|
|
const qrCodeDataURL = await generateQRCodeDataURL(token);
|
|
|
|
// Construire l'URL complète
|
|
const { getParametres } = await import("./parametresDb");
|
|
const parametres = await getParametres();
|
|
const url = `${parametres.urlPublique}/emargement/${token}`;
|
|
|
|
return {
|
|
token,
|
|
qrCodeDataURL,
|
|
url,
|
|
};
|
|
}),
|
|
|
|
// Générer un QR code pour le formateur
|
|
generateQRCodeFormateur: protectedProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const { generateQRToken } = await import("./qrCodeGenerator");
|
|
const { getDb } = await import("./db");
|
|
const { sequences } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
const QRCode = (await import("qrcode")).default;
|
|
const { getParametres } = await import("./parametresDb");
|
|
|
|
const db = await getDb();
|
|
if (!db) throw new Error("Database not available");
|
|
|
|
// Vérifier que la séquence existe
|
|
const sequence = await db.select().from(sequences).where(eq(sequences.id, input.sequenceId)).limit(1);
|
|
if (sequence.length === 0) {
|
|
throw new Error("Séquence introuvable");
|
|
}
|
|
|
|
// Générer un nouveau token pour le formateur
|
|
let token = sequence[0].qrCodeFormateurToken;
|
|
if (!token) {
|
|
token = generateQRToken();
|
|
await db.update(sequences).set({ qrCodeFormateurToken: token }).where(eq(sequences.id, input.sequenceId));
|
|
}
|
|
|
|
// Générer le QR code avec URL spécifique formateur
|
|
const parametres = await getParametres();
|
|
const url = `${parametres.urlPublique}/emargement-formateur/${token}`;
|
|
|
|
const qrCodeDataURL = await QRCode.toDataURL(url, {
|
|
errorCorrectionLevel: "H",
|
|
type: "image/png",
|
|
width: 400,
|
|
margin: 2,
|
|
color: {
|
|
dark: "#000000",
|
|
light: "#FFFFFF",
|
|
},
|
|
});
|
|
|
|
return {
|
|
token,
|
|
qrCodeDataURL,
|
|
};
|
|
}),
|
|
|
|
// Valider une présence (scan QR code ou manuel)
|
|
valider: publicProcedure
|
|
.input(z.object({
|
|
token: z.string().optional(),
|
|
inscriptionId: z.number().optional(),
|
|
dateFormationId: z.number().optional(),
|
|
periode: z.enum(["matin", "apres_midi"]),
|
|
modeValidation: z.enum(["qrcode", "manuel"]),
|
|
validateurId: z.number().optional(),
|
|
commentaire: z.string().optional(),
|
|
signatureDataUrl: z.string().optional(),
|
|
email: z.string().optional(),
|
|
typeUtilisateur: z.enum(["apprenant", "formateur"]).optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { validerPresence } = await import("./presenceDb");
|
|
const { getDb } = await import("./db");
|
|
const { sequences, inscriptions } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
|
|
const db = await getDb();
|
|
if (!db) throw new Error("Database not available");
|
|
|
|
// Cas formateur : logique spécifique
|
|
if (input.typeUtilisateur === "formateur") {
|
|
const { formateurs, datesFormation } = await import("../drizzle/schema");
|
|
const { and, sql } = await import("drizzle-orm");
|
|
const { storagePut } = await import("./storage");
|
|
const crypto = await import("crypto");
|
|
|
|
// Vérifier le token formateur
|
|
if (input.modeValidation === "qrcode" && input.token) {
|
|
const sequence = await db.select().from(sequences).where(eq(sequences.qrCodeFormateurToken, input.token)).limit(1);
|
|
if (sequence.length === 0) {
|
|
throw new Error("QR code formateur invalide");
|
|
}
|
|
}
|
|
|
|
// Récupérer le formateur par email
|
|
if (!input.email) throw new Error("Email formateur requis");
|
|
const formateurData = await db.select().from(formateurs).where(eq(formateurs.email, input.email)).limit(1);
|
|
if (formateurData.length === 0) {
|
|
throw new Error("Formateur introuvable avec cet email");
|
|
}
|
|
|
|
// Trouver la séquence associée au token
|
|
const sequenceData = await db.select().from(sequences).where(eq(sequences.qrCodeFormateurToken, input.token || "")).limit(1);
|
|
if (sequenceData.length === 0) {
|
|
throw new Error("Séquence introuvable");
|
|
}
|
|
const finalSequenceId = sequenceData[0].id;
|
|
|
|
// Utiliser la date sélectionnée par le formateur
|
|
if (!input.dateFormationId) {
|
|
throw new Error("Date de formation requise");
|
|
}
|
|
const dateFormId = input.dateFormationId;
|
|
|
|
// Stocker la signature sur S3
|
|
let signatureUrl: string | undefined;
|
|
let signatureS3Key: string | undefined;
|
|
if (input.signatureDataUrl) {
|
|
const fs = await import("fs");
|
|
const path = await import("path");
|
|
|
|
const formationId = sequenceData[0].formationId;
|
|
const sequenceId = sequenceData[0].id;
|
|
const randomSuffix = crypto.randomBytes(8).toString("hex");
|
|
const fileName = `signature-formateur-${formateurData[0].id}-${dateFormId}-${randomSuffix}.png`;
|
|
const signatureS3Key = `signatures/${formationId}-${sequenceId}/${fileName}`;
|
|
const base64Data = input.signatureDataUrl.split(",")[1];
|
|
const buffer = Buffer.from(base64Data, "base64");
|
|
|
|
// Stockage local
|
|
const uploadsDir = path.join(process.cwd(), "uploads", "signatures");
|
|
if (!fs.existsSync(uploadsDir)) {
|
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
|
}
|
|
const filePath = path.join(uploadsDir, fileName);
|
|
fs.writeFileSync(filePath, buffer);
|
|
signatureUrl = `/uploads/signatures/${fileName}`;
|
|
}
|
|
|
|
// Valider la présence formateur
|
|
const { validerPresenceFormateur } = await import("./presenceDb");
|
|
const result = await validerPresenceFormateur({
|
|
formateurId: formateurData[0].id,
|
|
sequenceId: finalSequenceId,
|
|
dateFormationId: dateFormId,
|
|
periode: input.periode,
|
|
modeValidation: input.modeValidation,
|
|
commentaire: input.commentaire,
|
|
signatureUrl,
|
|
signatureS3Key,
|
|
});
|
|
|
|
return { success: true, presence: result };
|
|
}
|
|
|
|
// Cas apprenant : logique existante
|
|
// Si mode QR code, vérifier le token
|
|
if (input.modeValidation === "qrcode" && input.token) {
|
|
|
|
// Récupérer l'inscription pour vérifier la séquence
|
|
if (!input.inscriptionId) throw new Error("Inscription requise pour un apprenant");
|
|
const inscription = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
|
|
if (inscription.length === 0) {
|
|
throw new Error("Inscription introuvable");
|
|
}
|
|
|
|
// Vérifier que le token correspond à la séquence
|
|
const sequence = await db.select().from(sequences).where(eq(sequences.id, inscription[0].sequenceId)).limit(1);
|
|
if (sequence.length === 0 || sequence[0].qrCodeToken !== input.token) {
|
|
throw new Error("QR code invalide");
|
|
}
|
|
}
|
|
|
|
// Vérifier que inscriptionId et dateFormationId sont fournis pour un apprenant
|
|
if (!input.inscriptionId) throw new Error("Inscription requise pour un apprenant");
|
|
if (!input.dateFormationId) throw new Error("Date de formation requise pour un apprenant");
|
|
|
|
// Stocker la signature localement si fournie
|
|
let signatureUrl: string | undefined;
|
|
let signatureS3Key: string | undefined;
|
|
if (input.signatureDataUrl) {
|
|
const fs = await import("fs");
|
|
const path = await import("path");
|
|
const crypto = await import("crypto");
|
|
|
|
// Récupérer l'inscription pour obtenir la séquence
|
|
const inscriptionData = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
|
|
if (inscriptionData.length === 0) throw new Error("Inscription introuvable");
|
|
|
|
const sequenceData = await db.select().from(sequences).where(eq(sequences.id, inscriptionData[0].sequenceId)).limit(1);
|
|
if (sequenceData.length === 0) throw new Error("Séquence introuvable");
|
|
|
|
const formationId = sequenceData[0].formationId;
|
|
const sequenceId = sequenceData[0].id;
|
|
|
|
// Créer la structure de dossiers
|
|
const uploadsDir = path.resolve(process.cwd(), "uploads", "signatures", `${formationId}-${sequenceId}`);
|
|
if (!fs.existsSync(uploadsDir)) {
|
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
|
}
|
|
|
|
// Générer un nom de fichier unique
|
|
const randomSuffix = crypto.randomBytes(8).toString("hex");
|
|
const fileName = `signature-${input.inscriptionId}-${input.dateFormationId}-${randomSuffix}.png`;
|
|
const filePath = path.join(uploadsDir, fileName);
|
|
|
|
// Convertir le data URL en buffer et sauvegarder
|
|
const base64Data = input.signatureDataUrl.split(",")[1];
|
|
const buffer = Buffer.from(base64Data, "base64");
|
|
fs.writeFileSync(filePath, buffer);
|
|
|
|
// Générer l'URL publique
|
|
signatureUrl = `/uploads/signatures/${formationId}-${sequenceId}/${fileName}`;
|
|
signatureS3Key = `signatures/${formationId}-${sequenceId}/${fileName}`;
|
|
}
|
|
|
|
const result = await validerPresence({
|
|
inscriptionId: input.inscriptionId,
|
|
dateFormationId: input.dateFormationId,
|
|
periode: input.periode,
|
|
modeValidation: input.modeValidation,
|
|
validateurId: input.validateurId,
|
|
commentaire: input.commentaire,
|
|
signatureUrl,
|
|
signatureS3Key,
|
|
});
|
|
|
|
// Vérifier si toutes les présences sont validées
|
|
const { checkAllPresencesValidated } = await import("./presenceDb");
|
|
const allValidated = await checkAllPresencesValidated(input.inscriptionId);
|
|
|
|
// Si toutes les présences sont validées, générer l'attestation automatiquement
|
|
if (allValidated) {
|
|
try {
|
|
const { genererAttestationPDF, enregistrerAttestation } = await import("./attestationService");
|
|
|
|
// Générer le PDF
|
|
const { s3Key, pdfUrl } = await genererAttestationPDF(input.inscriptionId);
|
|
|
|
// Récupérer les données de l'inscription
|
|
const db = await getDb();
|
|
if (db) {
|
|
const inscriptionData = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
|
|
if (inscriptionData.length > 0) {
|
|
// Enregistrer l'attestation en base
|
|
await enregistrerAttestation(
|
|
input.inscriptionId,
|
|
inscriptionData[0].apprenantId,
|
|
inscriptionData[0].sequenceId,
|
|
s3Key,
|
|
pdfUrl
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
...result,
|
|
attestationGenerated: true,
|
|
attestationUrl: pdfUrl,
|
|
};
|
|
} catch (error) {
|
|
console.error("Erreur lors de la génération de l'attestation:", error);
|
|
// Ne pas bloquer la validation de présence si l'attestation échoue
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}),
|
|
|
|
// Lister les présences pour une séquence
|
|
listBySequence: protectedProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { getPresencesBySequence } = await import("./presenceDb");
|
|
return await getPresencesBySequence(input.sequenceId);
|
|
}),
|
|
|
|
// Lister les signatures formateurs pour une séquence
|
|
listSignaturesFormateurs: protectedProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { getSignaturesFormateursBySequence } = await import("./presenceDb");
|
|
return await getSignaturesFormateursBySequence(input.sequenceId);
|
|
}),
|
|
|
|
// Lister les présences pour une inscription
|
|
listByInscription: publicProcedure
|
|
.input(z.object({
|
|
inscriptionId: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { getPresencesByInscription } = await import("./presenceDb");
|
|
return getPresencesByInscription(input.inscriptionId);
|
|
}),
|
|
|
|
// Supprimer une présence
|
|
delete: protectedProcedure
|
|
.input(z.object({
|
|
presenceId: z.number(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { supprimerPresence } = await import("./presenceDb");
|
|
return supprimerPresence(input.presenceId);
|
|
}),
|
|
|
|
// Vérifier si toutes les présences sont validées pour une inscription
|
|
checkAllValidated: publicProcedure
|
|
.input(z.object({
|
|
inscriptionId: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { checkAllPresencesValidated } = await import("./presenceDb");
|
|
return checkAllPresencesValidated(input.inscriptionId);
|
|
}),
|
|
}),
|
|
|
|
// ===== GESTION DES ATTESTATIONS =====
|
|
gestionAttestations: router({
|
|
// Récupérer la configuration d'une formation
|
|
getFormationConfig: adminProcedure
|
|
.input(z.object({ formationId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const { getFormationConfig } = await import("./gestionAttestationsDb");
|
|
return getFormationConfig(input.formationId);
|
|
}),
|
|
|
|
// Mettre à jour la configuration d'une formation
|
|
updateFormationConfig: adminProcedure
|
|
.input(z.object({
|
|
formationId: z.number(),
|
|
modeAttestation: z.enum(["auto", "manuel"]),
|
|
modeEnvoi: z.enum(["auto", "manuel"]),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { updateFormationConfig } = await import("./gestionAttestationsDb");
|
|
return updateFormationConfig(
|
|
input.formationId,
|
|
input.modeAttestation,
|
|
input.modeEnvoi
|
|
);
|
|
}),
|
|
|
|
// Récupérer les apprenants avec leur statut d'attestation
|
|
getApprenantsWithStatus: protectedProcedure
|
|
.input(z.object({ formationId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
try {
|
|
const { getApprenantsWithAttestationStatus } = await import("./gestionAttestationsDb");
|
|
const results = await getApprenantsWithAttestationStatus(input.formationId);
|
|
console.log("[gestionAttestations] Found", results.length, "apprenants for formation", input.formationId);
|
|
return results;
|
|
} catch (error) {
|
|
console.error("[gestionAttestations] Error fetching apprenants:", error);
|
|
throw error;
|
|
}
|
|
}),
|
|
|
|
// Uploader un document d'attestation
|
|
uploadDocument: formateurProcedure
|
|
.input(z.object({
|
|
inscriptionId: z.number(),
|
|
documentUrl: z.string(),
|
|
documentS3Key: z.string(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const { uploadAttestationDocument, canAccessSequence } = await import("./gestionAttestationsDb");
|
|
const { getDb } = await import("./db");
|
|
const { inscriptions } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
|
|
// Récupérer la séquence de l'inscription
|
|
const db = await getDb();
|
|
if (!db) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Database not available' });
|
|
const inscription = await db.select({ sequenceId: inscriptions.sequenceId }).from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
|
|
if (inscription.length === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Inscription not found' });
|
|
|
|
// Vérifier l'accès
|
|
const hasAccess = await canAccessSequence(ctx.user.id, ctx.user.role, inscription[0].sequenceId);
|
|
if (!hasAccess) throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès refusé à cette séquence' });
|
|
|
|
return uploadAttestationDocument(
|
|
input.inscriptionId,
|
|
input.documentUrl,
|
|
input.documentS3Key,
|
|
ctx.user.id
|
|
);
|
|
}),
|
|
|
|
// Supprimer un document d'attestation
|
|
deleteDocument: formateurProcedure
|
|
.input(z.object({ attestationId: z.number() }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const { deleteAttestationDocument, canAccessSequence } = await import("./gestionAttestationsDb");
|
|
const { getDb } = await import("./db");
|
|
const { attestations } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
|
|
// Récupérer la séquence de l'attestation
|
|
const db = await getDb();
|
|
if (!db) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Database not available' });
|
|
const attestation = await db.select({ sequenceId: attestations.sequenceId }).from(attestations).where(eq(attestations.id, input.attestationId)).limit(1);
|
|
if (attestation.length === 0) throw new TRPCError({ code: 'NOT_FOUND', message: 'Attestation not found' });
|
|
|
|
// Vérifier l'accès
|
|
const hasAccess = await canAccessSequence(ctx.user.id, ctx.user.role, attestation[0].sequenceId);
|
|
if (!hasAccess) throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès refusé à cette séquence' });
|
|
|
|
return deleteAttestationDocument(input.attestationId);
|
|
}),
|
|
|
|
// Envoyer une attestation par email
|
|
sendAttestation: formateurProcedure
|
|
.input(z.object({
|
|
attestationId: z.number(),
|
|
sequenceId: z.number(),
|
|
apprenantId: z.number(),
|
|
apprenantEmail: z.string(),
|
|
apprenantNom: z.string(),
|
|
apprenantPrenom: z.string(),
|
|
formationNom: z.string(),
|
|
pdfUrl: z.string(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const { sendAttestationEmail } = await import("./emailService");
|
|
const { markAttestationAsSent, canAccessSequence } = await import("./gestionAttestationsDb");
|
|
const { enregistrerEnvoiAttestation } = await import("./attestationsDb");
|
|
|
|
// Vérifier l'accès à la séquence
|
|
const hasAccess = await canAccessSequence(ctx.user.id, ctx.user.role, input.sequenceId);
|
|
if (!hasAccess) throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès refusé à cette séquence' });
|
|
|
|
try {
|
|
// Envoyer l'email
|
|
await sendAttestationEmail({
|
|
to: input.apprenantEmail,
|
|
apprenantNom: input.apprenantNom,
|
|
apprenantPrenom: input.apprenantPrenom,
|
|
formationNom: input.formationNom,
|
|
pdfUrl: input.pdfUrl,
|
|
});
|
|
|
|
// Marquer comme envoyé
|
|
await markAttestationAsSent(input.attestationId);
|
|
|
|
// Enregistrer dans l'historique
|
|
await enregistrerEnvoiAttestation({
|
|
sequenceId: input.sequenceId,
|
|
apprenantId: input.apprenantId,
|
|
statut: "envoye",
|
|
urlAttestation: input.pdfUrl,
|
|
});
|
|
|
|
return { success: true };
|
|
} catch (error: any) {
|
|
// Enregistrer l'échec dans l'historique
|
|
await enregistrerEnvoiAttestation({
|
|
sequenceId: input.sequenceId,
|
|
apprenantId: input.apprenantId,
|
|
statut: "erreur",
|
|
messageErreur: error.message,
|
|
urlAttestation: input.pdfUrl,
|
|
});
|
|
throw error;
|
|
}
|
|
}),
|
|
|
|
// Envoyer toutes les attestations d'une formation en masse
|
|
sendAllAttestations: formateurProcedure
|
|
.input(z.object({ formationId: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
const { getApprenantsWithAttestationStatus } = await import("./gestionAttestationsDb");
|
|
const { sendAttestationEmail } = await import("./emailService");
|
|
const { markAttestationAsSent } = await import("./gestionAttestationsDb");
|
|
const { enregistrerEnvoiAttestation } = await import("./attestationsDb");
|
|
const { getDb } = await import("./db");
|
|
const { formations } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
|
|
try {
|
|
// Récupérer tous les apprenants avec attestations
|
|
const apprenants = await getApprenantsWithAttestationStatus(input.formationId);
|
|
|
|
// Récupérer le nom de la formation
|
|
const db = await getDb();
|
|
if (!db) throw new Error("Database not available");
|
|
const formation = await db.select().from(formations).where(eq(formations.id, input.formationId)).limit(1);
|
|
const formationNom = formation[0]?.nom || "";
|
|
|
|
let succes = 0;
|
|
let echecs = 0;
|
|
const erreurs: string[] = [];
|
|
|
|
// Filtrer les apprenants qui ont une attestation prête
|
|
const apprenantsAvecAttestation = apprenants.filter(item => {
|
|
const hasDocument = item.attestation && item.attestation.documentUrl;
|
|
return hasDocument;
|
|
});
|
|
|
|
console.log(`[sendAllAttestations] Envoi de ${apprenantsAvecAttestation.length} attestation(s) pour la formation ${formationNom}`);
|
|
|
|
// Envoyer chaque attestation
|
|
for (const item of apprenantsAvecAttestation) {
|
|
if (!item.attestation || !item.attestation.documentUrl) continue;
|
|
const pdfUrl = item.attestation.documentUrl;
|
|
|
|
try {
|
|
// Envoyer l'email
|
|
await sendAttestationEmail({
|
|
to: item.apprenant.email,
|
|
apprenantNom: item.apprenant.nom,
|
|
apprenantPrenom: item.apprenant.prenom,
|
|
formationNom: formationNom,
|
|
pdfUrl: pdfUrl,
|
|
});
|
|
|
|
// Marquer comme envoyé
|
|
await markAttestationAsSent(item.attestation.id);
|
|
|
|
// Enregistrer dans l'historique
|
|
await enregistrerEnvoiAttestation({
|
|
sequenceId: item.sequence.id,
|
|
apprenantId: item.apprenant.id,
|
|
statut: "envoye",
|
|
urlAttestation: pdfUrl,
|
|
});
|
|
|
|
succes++;
|
|
console.log(`[sendAllAttestations] Envoi réussi pour ${item.apprenant.email}`);
|
|
} catch (error: any) {
|
|
echecs++;
|
|
const messageErreur = error?.message || String(error);
|
|
erreurs.push(`${item.apprenant.email}: ${messageErreur}`);
|
|
console.error(`[sendAllAttestations] Erreur pour ${item.apprenant.email}:`, error);
|
|
|
|
// Enregistrer l'échec dans l'historique
|
|
await enregistrerEnvoiAttestation({
|
|
sequenceId: item.sequence.id,
|
|
apprenantId: item.apprenant.id,
|
|
statut: "erreur",
|
|
messageErreur: messageErreur,
|
|
urlAttestation: pdfUrl,
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log(`[sendAllAttestations] Terminé: ${succes} succès, ${echecs} échecs`);
|
|
|
|
return {
|
|
succes,
|
|
echecs,
|
|
erreurs,
|
|
total: apprenantsAvecAttestation.length,
|
|
};
|
|
} catch (error: any) {
|
|
console.error("[sendAllAttestations] Erreur globale:", error);
|
|
throw error;
|
|
}
|
|
}),
|
|
|
|
}),
|
|
|
|
// Monitoring Fail2Ban
|
|
fail2ban: router({
|
|
// Récupérer le statut et les statistiques Fail2Ban
|
|
getStatus: adminProcedure.query(async () => {
|
|
return await fail2banDb.getFail2BanStatus();
|
|
}),
|
|
|
|
// Récupérer l'historique des bannissements
|
|
getHistory: adminProcedure
|
|
.input(z.object({
|
|
limit: z.number().optional().default(50),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
return await fail2banDb.getBanHistory(input.limit);
|
|
}),
|
|
|
|
// Débannir une IP
|
|
unbanIP: adminProcedure
|
|
.input(z.object({
|
|
ip: z.string(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const success = await fail2banDb.unbanIP(input.ip);
|
|
if (!success) {
|
|
throw new TRPCError({
|
|
code: 'INTERNAL_SERVER_ERROR',
|
|
message: `Impossible de débannir l'IP ${input.ip}`,
|
|
});
|
|
}
|
|
return { success: true };
|
|
}),
|
|
|
|
// Compter les bannissements récents
|
|
countRecentBans: adminProcedure
|
|
.input(z.object({
|
|
hours: z.number().optional().default(1),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
return await fail2banDb.countRecentBans(input.hours);
|
|
}),
|
|
}),
|
|
});
|
|
|
|
export type AppRouter = typeof appRouter;
|