Files
formation-manager-itinova/server/routers.ts
Manus 023711df92 Checkpoint: Système d'attestations de formation implémenté
 Infrastructure backend :
- Tables attestations et configAttestation en base de données
- Service de génération PDF avec PDFKit (attestationService.ts)
- Génération automatique de PDF avec template personnalisable
- Upload automatique vers S3 avec URLs publiques
- Procédures tRPC pour générer, récupérer et configurer

 Interface utilisateur :
- Page de configuration des attestations (/admin/attestations)
- Personnalisation du texte avec variables dynamiques (nomComplet, nomFormation, dateDebut, dateFin)
- Configuration du signataire (nom et fonction)
- Bouton "Générer" dans la page de validation des présences formateur
- Génération automatique avec ouverture du PDF dans un nouvel onglet

 Fonctionnalités :
- Vérification des doublons (une seule attestation par apprenant/séquence)
- Génération uniquement pour les apprenants marqués comme présents
- PDF professionnel avec détails de la formation et dates
- Stockage permanent sur S3

📋 Améliorations futures possibles :
- Upload de logo et signature personnalisés
- Envoi automatique par email aux apprenants
- Espace apprenant pour télécharger les attestations
2026-01-07 10:06:29 -05:00

1834 lines
66 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 { generateAnalyticsExcel, generateAnalyticsPDF } from "./analyticsExportService";
import { TRPCError } from "@trpc/server";
import { sendInscriptionConfirmation, sendGroupEmail, sendNotificationFormateurNouvelleInscription, sendNotificationFormateurAnnulation, sendAlerteCapaciteAtteinte, sendNotificationPlaceDisponible, sendRemerciementPostFormation } from "./emailService";
import { logNotification } from "./notificationLogsDb";
import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService";
import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel";
// Procédure admin uniquement
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.role !== 'admin') {
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux administrateurs' });
}
return next({ ctx });
});
export const appRouter = router({
system: systemRouter,
auth: router({
me: publicProcedure.query(opts => opts.ctx.user),
logout: publicProcedure.mutation(({ ctx }) => {
const cookieOptions = getSessionCookieOptions(ctx.req);
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
return {
success: true,
} as const;
}),
}),
// ===== FORMATIONS =====
formations: router({
list: adminProcedure.query(async () => {
return db.getFormations();
}),
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: adminProcedure.query(async () => {
return db.getApprenants();
}),
getById: adminProcedure.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: adminProcedure.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();
}),
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().nullable().optional(),
})).mutation(async ({ input }) => {
const { id, ...data } = input;
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: adminProcedure.query(async () => {
const seqs = await db.getSequencesWithFormation();
// 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;
}),
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;
// Créer la séquence
const result = await db.createSequence({
...sequenceData,
dateBlocage: parseLocalDateTime(sequenceData.dateBlocage),
});
// Récupérer l'ID de la séquence créée
const sequenceId = Number(result[0].insertId);
// Créer les dates de formation
for (const date of dates) {
await db.createDateFormation({
sequenceId,
dateDebut: parseLocalDateTime(date.dateDebut),
dateFin: parseLocalDateTime(date.dateFin),
ordre: date.ordre,
});
}
return { success: true, sequenceId };
}),
update: adminProcedure.input(z.object({
id: z.number(),
formationId: z.number(),
nom: z.string().min(1),
lieu: z.string().min(1),
publicCible: z.enum(["directeur", "chef_service", "tous", "autre"]),
formateurId: z.number().nullable().optional(),
capaciteMax: z.number(),
dateBlocage: z.string(),
statut: z.enum(["ouverte", "bloquee", "terminee"]),
dates: z.array(z.object({
dateDebut: z.string(),
dateFin: z.string(),
ordre: z.number(),
})).min(1).max(4),
})).mutation(async ({ input }) => {
const { id, dates, ...sequenceData } = input;
console.log('[UPDATE SEQUENCE] Input reçu:', JSON.stringify(input, null, 2));
// Mettre à jour la séquence
const updateData: any = {
...sequenceData,
dateBlocage: parseLocalDateTime(sequenceData.dateBlocage),
};
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');
// Supprimer les anciennes dates
await db.deleteDatesBySequence(id);
console.log('[UPDATE SEQUENCE] Anciennes dates supprimées');
// Créer les nouvelles dates
for (const date of dates) {
console.log('[UPDATE SEQUENCE] Création date:', date);
await db.createDateFormation({
sequenceId: id,
dateDebut: parseLocalDateTime(date.dateDebut),
dateFin: parseLocalDateTime(date.dateFin),
ordre: date.ordre,
});
}
console.log('[UPDATE SEQUENCE] Toutes les dates ont été créées');
return { success: true };
}),
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
// Supprimer d'abord les dates associées
await db.deleteDatesBySequence(input.id);
// Puis supprimer la séquence
await db.deleteSequence(input.id);
return { success: true };
}),
}),
// ===== INSCRIPTIONS =====
inscriptions: router({
listAll: adminProcedure.query(async () => {
return db.getAllInscriptions();
}),
listBySequence: adminProcedure.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);
}),
checkExisting: publicProcedure.input(z.object({
apprenantId: z.number(),
sequenceId: z.number(),
})).query(async ({ input }) => {
return db.checkExistingInscription(input.apprenantId, input.sequenceId);
}),
inscrire: publicProcedure.input(z.object({
apprenantId: z.number(),
sequenceId: z.number(),
})).mutation(async ({ input }) => {
// Vérifier si la séquence existe
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
}
// Vérifier si la séquence est bloquée
const now = new Date();
if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Les inscriptions sont fermées pour cette séquence (J-15 dépassé)'
});
}
// Vérifier si l'apprenant a déjà une inscription
const existing = await db.checkExistingInscription(input.apprenantId, input.sequenceId);
if (existing) {
throw new TRPCError({
code: 'CONFLICT',
message: 'Vous êtes déjà inscrit à cette séquence'
});
}
// Vérifier la capacité
const nbInscrits = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
const statut = nbInscrits >= sequence.capaciteMax ? '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) {
const nbInscritsActuel = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
try {
await sendNotificationFormateurNouvelleInscription({
formateurEmail: formateur.email,
formateurNom: formateur.nom,
apprenantNom: inscriptionApprenant.nom,
apprenantPrenom: inscriptionApprenant.prenom,
apprenantFonction: inscriptionApprenant.fonction,
apprenantEtablissement: inscriptionApprenant.codeEtablissement,
formationNom: inscriptionFormation.nom,
sequenceNom: inscriptionSequence.nom,
nbInscrits: nbInscritsActuel,
capaciteMax: inscriptionSequence.capaciteMax,
dates: dates.map(d => ({
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
});
// Logger la notification
await logNotification({
type: "notification_formateur_inscription",
sequenceId: input.sequenceId,
apprenantId: inscriptionApprenant.id,
formateurId: formateur.id,
emailDestinataire: formateur.email,
sujet: `Nouvelle inscription - ${inscriptionFormation.nom}`,
statut: "success",
});
console.log(`[Notification] Email envoyé au formateur ${formateur.email} pour nouvelle inscription`);
} catch (e: any) {
// Logger l'échec
await logNotification({
type: "notification_formateur_inscription",
sequenceId: input.sequenceId,
apprenantId: inscriptionApprenant.id,
formateurId: formateur.id,
emailDestinataire: formateur.email,
sujet: `Nouvelle inscription - ${inscriptionFormation.nom}`,
statut: "failed",
messageErreur: e.message,
});
console.error(`[Notification] Erreur envoi email formateur:`, e);
}
}
}
// Alerte capacité atteinte pour les admins
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)`);
}
} catch (e) {
console.error(`[Notification] Erreur envoi alerte capacité:`, e);
}
}
}
return { success: true, statut };
}),
desinscrire: publicProcedure.input(z.object({
apprenantId: z.number(),
sequenceId: z.number(),
})).mutation(async ({ input }) => {
// Vérifier si la séquence existe
const sequence = await db.getSequenceById(input.sequenceId);
if (!sequence) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
}
// Vérifier si la séquence est bloquée
const now = new Date();
if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Les désinscriptions sont fermées pour cette séquence (J-15 dépassé)'
});
}
// Trouver l'inscription
const inscription = await db.checkExistingInscription(input.apprenantId, input.sequenceId);
if (!inscription) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Inscription introuvable' });
}
// Récupérer les infos avant la mise à jour pour les notifications
const apprenant = await db.getApprenantById(input.apprenantId);
const formation = await db.getFormationById(sequence.formationId);
await db.updateInscription(inscription.id, { statut: 'annulee' });
// Notifications après désinscription
if (apprenant && formation) {
// Notification au formateur
if (sequence.formateurId) {
const formateur = await db.getFormateurById(sequence.formateurId);
if (formateur && formateur.email) {
const nbInscritsApres = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
try {
await sendNotificationFormateurAnnulation({
formateurEmail: formateur.email,
formateurNom: formateur.nom,
apprenantNom: apprenant.nom,
apprenantPrenom: apprenant.prenom,
apprenantFonction: apprenant.fonction,
formationNom: formation.nom,
sequenceNom: sequence.nom,
nbInscrits: nbInscritsApres,
capaciteMax: sequence.capaciteMax,
});
// Logger la notification
await logNotification({
type: "notification_formateur_annulation",
sequenceId: input.sequenceId,
apprenantId: apprenant.id,
formateurId: formateur.id,
emailDestinataire: formateur.email,
sujet: `Annulation d'inscription - ${formation.nom}`,
statut: "success",
});
console.log(`[Notification] Email d'annulation envoyé au formateur ${formateur.email}`);
} catch (e: any) {
// Logger l'échec
await logNotification({
type: "notification_formateur_annulation",
sequenceId: input.sequenceId,
apprenantId: apprenant.id,
formateurId: formateur.id,
emailDestinataire: formateur.email,
sujet: `Annulation d'inscription - ${formation.nom}`,
statut: "failed",
messageErreur: e.message,
});
console.error(`[Notification] Erreur envoi email formateur:`, e);
}
}
}
// Notifier le premier en liste d'attente qu'une place s'est libérée
const inscriptionsListeAttente = await db.getInscriptionsBySequence(input.sequenceId);
const premierEnAttente = inscriptionsListeAttente
.filter(i => i.inscription.statut === '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 }) => {
await db.updateInscription(input.id, { statut: input.statut });
return { success: true };
}),
sendGroupEmail: adminProcedure.input(z.object({ sequenceId: z.number(), type: z.enum(['teaser', 'rappel', 'rappel_j1']) })).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', 'rappel', 'rappel_j1']),
apprenantId: z.number().optional(),
}))
.query(async ({ input }) => {
const { generateEmailPreview } = await import('./emailPreview');
return await generateEmailPreview(input);
}),
exportExcel: adminProcedure.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: adminProcedure.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: adminProcedure.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;
}
}
const data = inscriptions
.filter(i => i.inscription.statut === 'confirmee' && i.apprenant)
.map(i => ({
nom: i.apprenant!.nom,
prenom: i.apprenant!.prenom,
codeEtablissement: i.apprenant!.codeEtablissement,
fonction: i.apprenant!.fonction,
}));
const buffer = await generateFeuillePresence({
formationNom: formation.nom,
sequenceNom: sequence.nom,
dates: dates.map(d => ({
dateDebut: new Date(d.dateDebut),
dateFin: new Date(d.dateFin),
ordre: d.ordre,
})),
lieu: sequence.lieu,
publicCible: sequence.publicCible,
formateur: formateurNom,
apprenants: data,
});
return {
buffer: buffer.toString('base64'),
filename: `feuille_presence_${sequence.nom.replace(/\s+/g, '_')}.pdf`,
};
}),
}),
// ===== GESTION DES UTILISATEURS =====
users: router({
list: adminProcedure.query(async () => {
return db.getAllUsers();
}),
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"]).default("user"),
isActive: z.boolean().default(true),
})).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"]).optional(),
isActive: z.boolean().optional(),
})).mutation(async ({ input }) => {
const { id, ...data } = input;
await db.updateUser(id, data);
return { success: true };
}),
toggleStatus: adminProcedure.input(z.object({
id: z.number(),
isActive: z.boolean(),
})).mutation(async ({ input }) => {
await db.toggleUserStatus(input.id, input.isActive);
return { success: true };
}),
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
await db.deleteUser(input.id);
return { success: true };
}),
requestPasswordReset: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
const user = await db.getUserById(input.id);
if (!user) {
throw new Error("Utilisateur introuvable");
}
if (!user.email) {
throw new Error("Cet utilisateur n'a pas d'adresse email");
}
// Générer un token unique
const crypto = await import('crypto');
const token = crypto.randomBytes(32).toString('hex');
// Définir l'expiration à 24h
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
// Sauvegarder le token en base
await db.createPasswordResetToken(user.id, token, expiresAt);
// Générer le lien de réinitialisation
// TODO: Remplacer par l'URL réelle de votre application en production
const resetLink = `https://votre-domaine.com/reset-password?token=${token}`;
// Envoyer l'email
const emailService = await import('./emailService');
const emailSent = await emailService.sendPasswordResetEmail({
apprenantEmail: user.email,
apprenantNom: user.name || 'Utilisateur',
apprenantPrenom: '',
resetLink,
});
if (!emailSent) {
throw new Error("Échec de l'envoi de l'email");
}
return { success: true, message: "Email de réinitialisation envoyé" };
}),
}),
// ===== GESTION DES TEMPLATES D'EMAILS =====
emailTemplates: router({
list: adminProcedure.query(async () => {
return db.getAllEmailTemplates();
}),
getByType: adminProcedure
.input(z.object({ type: z.string() }))
.query(async ({ input }) => {
return db.getEmailTemplateByType(input.type);
}),
upsert: adminProcedure
.input(z.object({
type: z.string(),
name: z.string(),
logoUrl: z.string().nullable(),
primaryColor: z.string(),
headerBgColor: z.string(),
headerTextColor: z.string(),
headerTitle: z.string(),
bodyContent: z.string().nullable(),
footerText: z.string().nullable(),
active: z.boolean(),
}))
.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.string(),
joursAvant: z.number(),
heureEnvoi: z.string().default("09:00"),
actif: z.boolean(),
}))
.mutation(async ({ input }) => {
return db.createRappel(input);
}),
update: adminProcedure
.input(z.object({
id: z.number(),
nom: z.string().optional(),
templateType: z.string().optional(),
joursAvant: z.number().optional(),
heureEnvoi: z.string().optional(),
actif: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
const { id, ...data } = input;
await db.updateRappel(id, data);
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(),
typeRappel: z.enum(['rappel', 'rappelJ1']),
}))
.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 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 au premier inscrit uniquement
const premierInscrit = inscritsConfirmes[0];
const apprenant = premierInscrit.apprenant;
if (!apprenant || !apprenant.email) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Apprenant introuvable ou sans email' });
}
const { sendRappelJ7Email, sendRappelJ1Email } = await import('./emailService');
try {
if (input.typeRappel === 'rappel') {
await sendRappelJ7Email({
apprenantEmail: apprenant.email,
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,
});
} else {
await sendRappelJ1Email({
apprenantEmail: apprenant.email,
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,
});
}
return {
success: true,
message: `Rappel de test envoyé à ${apprenant.email}`,
destinataire: apprenant.email,
};
} catch (error: any) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: `Erreur lors de l'envoi: ${error.message}`,
});
}
}),
historique: adminProcedure
.input(z.object({
dateDebut: z.string().optional(),
dateFin: z.string().optional(),
sequenceId: z.number().optional(),
statut: z.enum(['success', 'failed']).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();
}),
}),
emailConfig: router({
get: adminProcedure.query(async () => {
return db.getActiveEmailConfig();
}),
upsert: adminProcedure
.input(z.object({
provider: z.string(),
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 };
}
}),
}),
// ===== 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({
nom: 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(),
nom: z.string().optional(),
type: z.enum(["satisfaction", "evaluation_pre", "evaluation_post"]).optional(),
description: z.string().optional(),
formationId: z.number().nullable().optional(),
actif: z.boolean().optional(),
envoiAutomatique: z.boolean().optional(),
delaiEnvoiJours: z.number().optional(),
}))
.mutation(async ({ input }) => {
const { id, ...data } = input;
const questionnaireDb = await import("./questionnaireDb");
await questionnaireDb.updateQuestionnaire(id, data);
return { success: true };
}),
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
await questionnaireDb.deleteQuestionnaire(input.id);
return { success: true };
}),
getStats: adminProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
return questionnaireDb.getQuestionnaireStats(input.id);
}),
getReponsesDetaillees: adminProcedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
return questionnaireDb.getReponsesDetailleesQuestionnaire(input.id);
}),
exportExcel: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const { exportQuestionnaireToExcel } = await import("./questionnaireExport");
const buffer = await exportQuestionnaireToExcel(input.id);
return {
data: buffer.toString('base64'),
filename: `questionnaire-${input.id}-${new Date().toISOString().split('T')[0]}.xlsx`,
};
}),
exportPDF: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const { exportQuestionnaireToPDF } = await import("./questionnaireExport");
const buffer = await exportQuestionnaireToPDF(input.id);
return {
data: buffer.toString('base64'),
filename: `questionnaire-${input.id}-${new Date().toISOString().split('T')[0]}.pdf`,
};
}),
}),
// ===== QUESTIONS =====
questions: router({
list: adminProcedure
.input(z.object({ questionnaireId: z.number() }))
.query(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
return questionnaireDb.getQuestionsByQuestionnaireId(input.questionnaireId);
}),
create: adminProcedure
.input(z.object({
questionnaireId: z.number(),
ordre: z.number(),
texte: z.string(),
typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]),
options: z.string().optional(),
echelleMin: z.number().optional(),
echelleMax: z.number().optional(),
echelleLabelMin: z.string().optional(),
echelleLabelMax: z.string().optional(),
obligatoire: z.boolean().default(false),
}))
.mutation(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
const id = await questionnaireDb.createQuestion(input);
return { id, success: true };
}),
update: adminProcedure
.input(z.object({
id: z.number(),
ordre: z.number().optional(),
texte: z.string().optional(),
typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]).optional(),
options: z.string().optional(),
echelleMin: z.number().optional(),
echelleMax: z.number().optional(),
echelleLabelMin: z.string().optional(),
echelleLabelMax: z.string().optional(),
obligatoire: z.boolean().optional(),
}))
.mutation(async ({ input }) => {
const { id, ...data } = input;
const questionnaireDb = await import("./questionnaireDb");
await questionnaireDb.updateQuestion(id, data);
return { success: true };
}),
delete: adminProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
await questionnaireDb.deleteQuestion(input.id);
return { success: true };
}),
}),
// ===== RÉPONSES QUESTIONNAIRES (PUBLIC) =====
reponses: router({
getByToken: publicProcedure
.input(z.object({ token: z.string() }))
.query(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
const envoi = await questionnaireDb.getEnvoiByToken(input.token);
if (!envoi) return null;
const questionnaire = await questionnaireDb.getQuestionnaireById(envoi.questionnaireId);
const questions = await questionnaireDb.getQuestionsByQuestionnaireId(envoi.questionnaireId);
return {
envoi,
questionnaire,
questions,
};
}),
submit: publicProcedure
.input(z.object({
token: z.string(),
reponses: z.array(z.object({
questionId: z.number(),
reponseNumerique: z.number().optional(),
reponseTexte: z.string().optional(),
})),
}))
.mutation(async ({ input }) => {
const questionnaireDb = await import("./questionnaireDb");
// Récupérer l'envoi
const envoi = await questionnaireDb.getEnvoiByToken(input.token);
if (!envoi) throw new Error("Token invalide");
if (envoi.dateReponse) throw new Error("Questionnaire déjà répondu");
// Créer la réponse au questionnaire
const reponseQuestionnaireId = await questionnaireDb.createReponseQuestionnaire({
questionnaireId: envoi.questionnaireId,
apprenantId: envoi.apprenantId,
sequenceId: envoi.sequenceId,
complete: true,
});
// 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?.nom || '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(),
}))
.mutation(async ({ input }) => {
const { updateConfigAttestation } = await import("./attestationService");
await updateConfigAttestation(input);
return { success: true };
}),
}),
});
export type AppRouter = typeof appRouter;