## Modifications effectuées : ### Backend (server/routers.ts) - Ajout de la procédure `users.listWithFormateurs` qui retourne : * Tous les utilisateurs * Tous les formateurs avec leur statut de compte (hasAccount, user) - Cette procédure combine les données des tables `users` et `formateurs` ### Frontend (client/src/pages/AdminUsers.tsx) - Remplacement de `users.list` par `users.listWithFormateurs` - Ajout d'une section dédiée "Formateurs sans compte utilisateur" affichée en haut de la page - Cette section liste tous les formateurs qui n'ont pas encore de compte utilisateur - Bouton "Créer un compte" pour chaque formateur qui : * Pré-remplit le formulaire avec les données du formateur (nom, email) * Lie automatiquement le `formateurId` * Définit le rôle sur "formateur" ## Résultat Les formateurs existants dans la table `formateurs` sont maintenant visibles dans la page Utilisateurs. L'administrateur peut facilement créer un compte pour chaque formateur en un clic, ce qui lie automatiquement le compte au formateur existant.
1872 lines
67 KiB
TypeScript
1872 lines
67 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();
|
|
}),
|
|
|
|
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"]).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"]).optional(),
|
|
isActive: z.boolean().optional(),
|
|
formateurId: z.number().optional(),
|
|
})).mutation(async ({ input }) => {
|
|
const { id, ...data } = input;
|
|
await db.updateUser(id, data);
|
|
return { success: true };
|
|
}),
|
|
|
|
toggleStatus: adminProcedure.input(z.object({
|
|
id: z.number(),
|
|
isActive: z.boolean(),
|
|
})).mutation(async ({ input }) => {
|
|
await db.toggleUserStatus(input.id, input.isActive);
|
|
return { success: true };
|
|
}),
|
|
|
|
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
|
await db.deleteUser(input.id);
|
|
return { success: true };
|
|
}),
|
|
|
|
requestPasswordReset: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
|
const user = await db.getUserById(input.id);
|
|
if (!user) {
|
|
throw new Error("Utilisateur introuvable");
|
|
}
|
|
|
|
if (!user.email) {
|
|
throw new Error("Cet utilisateur n'a pas d'adresse email");
|
|
}
|
|
|
|
// Générer un token unique
|
|
const crypto = await import('crypto');
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
|
|
// 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(),
|
|
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 };
|
|
}),
|
|
}),
|
|
});
|
|
|
|
export type AppRouter = typeof appRouter;
|