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