- Ajout du champ typeEnvoi (enum 'automatique', 'test') dans le schéma Drizzle de la table logsRappels - Ajout de la fonction createLogRappel dans server/db.ts pour enregistrer les logs - Modification de la procédure testerRappel pour enregistrer chaque envoi de test avec typeEnvoi='test' - Enregistrement des succès et des échecs dans l'historique - Les tests n'interfèrent pas avec les rappels automatiques (typeEnvoi='automatique') - Migration SQL appliquée sur le VPS : ALTER TABLE logsRappels ADD COLUMN typeEnvoi - Déployé sur le VPS de production (https://formations.itinova.org) - Mise à jour du fichier todo.md Les envois de test apparaissent maintenant dans l'historique des rappels et sont distingués des envois automatiques programmés.
2515 lines
92 KiB
TypeScript
2515 lines
92 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, sendResetPasswordEmail } from "./emailService";
|
|
import { logNotification } from "./notificationLogsDb";
|
|
import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService";
|
|
import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel";
|
|
import crypto from "crypto";
|
|
import bcrypt from "bcryptjs";
|
|
|
|
// 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;
|
|
}),
|
|
|
|
// Demander une réinitialisation de mot de passe
|
|
requestPasswordReset: publicProcedure
|
|
.input(z.object({
|
|
email: z.string().email(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { email } = input;
|
|
|
|
// Vérifier si l'utilisateur existe
|
|
const utilisateur = await db.getUtilisateurByEmail(email);
|
|
if (!utilisateur) {
|
|
// Ne pas révéler si l'email existe ou non pour des raisons de sécurité
|
|
return { success: true };
|
|
}
|
|
|
|
// Générer un token unique
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
|
|
// Sauvegarder le token dans la base de données
|
|
await db.createPasswordResetToken(email, token);
|
|
|
|
// Envoyer l'email avec le template reset_password
|
|
const resetUrl = `${process.env.VITE_OAUTH_PORTAL_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
|
|
|
|
await sendResetPasswordEmail({
|
|
email,
|
|
prenom: utilisateur.prenom || '',
|
|
nom: utilisateur.nom || '',
|
|
lienReinitialisation: resetUrl,
|
|
});
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
// Valider le token et réinitialiser le mot de passe
|
|
resetPassword: publicProcedure
|
|
.input(z.object({
|
|
token: z.string(),
|
|
newPassword: z.string().min(8),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { token, newPassword } = input;
|
|
|
|
// Récupérer le token
|
|
const resetToken = await db.getPasswordResetToken(token);
|
|
|
|
if (!resetToken) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Token invalide ou expiré',
|
|
});
|
|
}
|
|
|
|
// Vérifier si le token est expiré
|
|
if (new Date() > new Date(resetToken.expiresAt)) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Token expiré',
|
|
});
|
|
}
|
|
|
|
// Vérifier si le token a déjà été utilisé
|
|
if (resetToken.used) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: 'Token déjà utilisé',
|
|
});
|
|
}
|
|
|
|
// Récupérer l'utilisateur
|
|
const utilisateur = await db.getUtilisateurByEmail(resetToken.email);
|
|
if (!utilisateur) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: 'Utilisateur introuvable',
|
|
});
|
|
}
|
|
|
|
// Mettre à jour le mot de passe
|
|
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
|
await db.updateUtilisateurPassword(utilisateur.id, hashedPassword);
|
|
|
|
// Marquer le token comme utilisé
|
|
await db.markTokenAsUsed(resetToken.id);
|
|
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ===== FORMATIONS =====
|
|
formations: router({
|
|
list: protectedProcedure.query(async ({ ctx }) => {
|
|
// Si l'utilisateur est formateur, ne montrer que les formations pour lesquelles il a des séquences
|
|
if (ctx.user.role === 'formateur' && ctx.user.formateurId) {
|
|
return db.getFormationsByFormateur(ctx.user.formateurId);
|
|
} else if (ctx.user.role === 'admin') {
|
|
return db.getFormations();
|
|
} else {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès non autorisé' });
|
|
}
|
|
}),
|
|
|
|
getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
|
|
return db.getFormationById(input.id);
|
|
}),
|
|
|
|
getByLien: publicProcedure.input(z.object({ lien: z.string() })).query(async ({ input }) => {
|
|
return db.getFormationByLien(input.lien);
|
|
}),
|
|
|
|
create: adminProcedure.input(z.object({
|
|
nom: z.string().min(1),
|
|
description: z.string().optional(),
|
|
lienUnique: z.string().min(1),
|
|
actif: z.boolean().optional(),
|
|
})).mutation(async ({ input }) => {
|
|
await db.createFormation(input);
|
|
return { success: true };
|
|
}),
|
|
|
|
update: adminProcedure.input(z.object({
|
|
id: z.number(),
|
|
nom: z.string().min(1).optional(),
|
|
description: z.string().optional(),
|
|
lienUnique: z.string().min(1).optional(),
|
|
actif: z.boolean().optional(),
|
|
})).mutation(async ({ input }) => {
|
|
const { id, ...data } = input;
|
|
await db.updateFormation(id, data);
|
|
return { success: true };
|
|
}),
|
|
|
|
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
|
await db.deleteFormation(input.id);
|
|
return { success: true };
|
|
}),
|
|
|
|
importExcel: adminProcedure.input(z.object({
|
|
fileBase64: z.string(),
|
|
})).mutation(async ({ input }) => {
|
|
try {
|
|
// Décoder le fichier base64
|
|
const buffer = Buffer.from(input.fileBase64, 'base64');
|
|
|
|
// Parser le fichier Excel
|
|
const { formations, sequences } = parseExcelFile(buffer);
|
|
|
|
// Valider les données
|
|
const validation = validateImportData(formations, sequences);
|
|
|
|
if (!validation.valid) {
|
|
return {
|
|
success: false,
|
|
formationsCreated: 0,
|
|
sequencesCreated: 0,
|
|
errors: validation.errors,
|
|
warnings: validation.warnings,
|
|
};
|
|
}
|
|
|
|
// Importer dans la base de données
|
|
const result = await importToDatabase(formations, sequences);
|
|
|
|
return {
|
|
...result,
|
|
warnings: [...validation.warnings, ...result.warnings],
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
success: false,
|
|
formationsCreated: 0,
|
|
sequencesCreated: 0,
|
|
errors: [`Erreur lors du traitement du fichier: ${error.message}`],
|
|
warnings: [],
|
|
};
|
|
}
|
|
}),
|
|
|
|
previewExcel: adminProcedure.input(z.object({
|
|
fileBase64: z.string(),
|
|
})).mutation(async ({ input }) => {
|
|
try {
|
|
const buffer = Buffer.from(input.fileBase64, 'base64');
|
|
const { formations, sequences } = parseExcelFile(buffer);
|
|
const validation = validateImportData(formations, sequences);
|
|
|
|
return {
|
|
formations,
|
|
sequences,
|
|
validation,
|
|
};
|
|
} catch (error: any) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: `Erreur lors de la lecture du fichier: ${error.message}`,
|
|
});
|
|
}
|
|
}),
|
|
}),
|
|
|
|
// ===== APPRENANTS =====
|
|
apprenants: router({
|
|
list: protectedProcedure.query(async ({ ctx }) => {
|
|
// Si l'utilisateur est formateur, ne montrer que les apprenants inscrits à ses séquences
|
|
if (ctx.user.role === 'formateur' && ctx.user.formateurId) {
|
|
return db.getApprenantsByFormateur(ctx.user.formateurId);
|
|
} else if (ctx.user.role === 'admin') {
|
|
return db.getApprenants();
|
|
} else {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès non autorisé' });
|
|
}
|
|
}),
|
|
|
|
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();
|
|
}),
|
|
|
|
// Tableau de bord formateur
|
|
dashboardStats: protectedProcedure.query(async ({ ctx }) => {
|
|
if (ctx.user.role !== 'formateur' || !ctx.user.formateurId) {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux formateurs' });
|
|
}
|
|
const formateurDb = await import('./formateurDb');
|
|
return formateurDb.getFormateurDashboardStats(ctx.user.formateurId);
|
|
}),
|
|
|
|
prochainesSequences: protectedProcedure
|
|
.input(z.object({ limit: z.number().optional().default(10) }))
|
|
.query(async ({ ctx, input }) => {
|
|
if (ctx.user.role !== 'formateur' || !ctx.user.formateurId) {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès réservé aux formateurs' });
|
|
}
|
|
const formateurDb = await import('./formateurDb');
|
|
return formateurDb.getFormateurProchainesSequences(ctx.user.formateurId, input.limit);
|
|
}),
|
|
|
|
getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
|
|
return db.getFormateurById(input.id);
|
|
}),
|
|
|
|
create: adminProcedure.input(z.object({
|
|
nom: z.string().min(1),
|
|
email: z.string().email().optional(),
|
|
})).mutation(async ({ input }) => {
|
|
await db.createFormateur(input);
|
|
return { success: true };
|
|
}),
|
|
|
|
update: adminProcedure.input(z.object({
|
|
id: z.number(),
|
|
nom: z.string().min(1).optional(),
|
|
email: z.string().email().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: protectedProcedure.query(async ({ ctx }) => {
|
|
let seqs;
|
|
|
|
// Si l'utilisateur est formateur, ne montrer que ses séquences
|
|
if (ctx.user.role === 'formateur' && ctx.user.formateurId) {
|
|
seqs = await db.getSequencesByFormateur(ctx.user.formateurId);
|
|
} else if (ctx.user.role === 'admin') {
|
|
seqs = await db.getSequencesWithFormation();
|
|
} else {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Accès non autorisé' });
|
|
}
|
|
// Récupérer les dates et le nombre d'inscrits pour chaque séquence
|
|
const sequencesAvecDates = await Promise.all(
|
|
seqs.map(async (seq) => {
|
|
const dates = await db.getDatesBySequence(seq.id);
|
|
const nbInscrits = await db.countInscriptionsBySequence(seq.id, 'confirmee');
|
|
|
|
// Récupérer les rappels associés à chaque date
|
|
const datesAvecRappels = await Promise.all(
|
|
dates.map(async (date) => {
|
|
const rappels = await db.getRappelsByDateFormation(date.id);
|
|
return { ...date, rappels };
|
|
})
|
|
);
|
|
|
|
return { ...seq, dates: datesAvecRappels, 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: sequenceData.dateBlocage && sequenceData.dateBlocage.trim() !== ''
|
|
? parseLocalDateTime(sequenceData.dateBlocage)
|
|
: null,
|
|
};
|
|
|
|
console.log('[UPDATE SEQUENCE] UpdateData:', JSON.stringify(updateData, null, 2));
|
|
await db.updateSequence(id, updateData);
|
|
console.log('[UPDATE SEQUENCE] Séquence mise à jour avec succès');
|
|
|
|
// 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({
|
|
// Liste des inscriptions avec dates de formation (pour formateurs)
|
|
listWithDates: protectedProcedure
|
|
.input(z.object({ sequenceId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const { getInscriptionsWithDates } = await import("./inscriptionWithDates");
|
|
return getInscriptionsWithDates(input.sequenceId);
|
|
}),
|
|
|
|
listAll: adminProcedure.query(async () => {
|
|
return db.getAllInscriptions();
|
|
}),
|
|
|
|
listBySequence: 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);
|
|
}),
|
|
|
|
listByApprenantWithDates: publicProcedure
|
|
.input(z.object({ apprenantId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const { getInscriptionsByApprenantWithDates } = await import("./inscriptionWithDates");
|
|
return getInscriptionsByApprenantWithDates(input.apprenantId);
|
|
}),
|
|
|
|
checkExisting: publicProcedure.input(z.object({
|
|
apprenantId: z.number(),
|
|
sequenceId: z.number(),
|
|
})).query(async ({ input }) => {
|
|
return db.checkExistingInscription(input.apprenantId, input.sequenceId);
|
|
}),
|
|
|
|
inscrire: publicProcedure.input(z.object({
|
|
apprenantId: z.number(),
|
|
sequenceId: z.number(),
|
|
})).mutation(async ({ input }) => {
|
|
// Vérifier si la séquence existe
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
}
|
|
|
|
// Vérifier si la séquence est bloquée
|
|
const now = new Date();
|
|
if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'Les inscriptions sont fermées pour cette séquence (J-15 dépassé)'
|
|
});
|
|
}
|
|
|
|
// Vérifier si l'apprenant a déjà une inscription
|
|
const existing = await db.checkExistingInscription(input.apprenantId, input.sequenceId);
|
|
if (existing) {
|
|
throw new TRPCError({
|
|
code: 'CONFLICT',
|
|
message: 'Vous êtes déjà inscrit à cette séquence'
|
|
});
|
|
}
|
|
|
|
// Vérifier la capacité
|
|
const nbInscrits = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
|
|
const statut = nbInscrits >= sequence.capaciteMax ? '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 ET le formateur
|
|
const nbInscritsApres = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
|
|
if (nbInscritsApres >= inscriptionSequence.capaciteMax) {
|
|
try {
|
|
const admins = await db.getAdminUsers();
|
|
const adminEmails = admins.filter(a => a.email).map(a => a.email!);
|
|
if (adminEmails.length > 0) {
|
|
const nbListeAttente = await db.countInscriptionsBySequence(input.sequenceId, 'liste_attente');
|
|
let formateurNom: string | undefined;
|
|
if (inscriptionSequence.formateurId) {
|
|
const formateur = await db.getFormateurById(inscriptionSequence.formateurId);
|
|
formateurNom = formateur?.nom;
|
|
}
|
|
await sendAlerteCapaciteAtteinte({
|
|
adminEmails,
|
|
formationNom: inscriptionFormation.nom,
|
|
sequenceNom: inscriptionSequence.nom,
|
|
capaciteMax: inscriptionSequence.capaciteMax,
|
|
nbInscrits: nbInscritsApres,
|
|
nbListeAttente,
|
|
formateurNom,
|
|
dates: dates.map(d => ({
|
|
dateDebut: new Date(d.dateDebut),
|
|
dateFin: new Date(d.dateFin),
|
|
ordre: d.ordre,
|
|
})),
|
|
});
|
|
console.log(`[Notification] Alerte capacité atteinte envoyée à ${adminEmails.length} admin(s)`);
|
|
}
|
|
|
|
// Notification au formateur pour capacité atteinte
|
|
if (inscriptionSequence.formateurId) {
|
|
const formateur = await db.getFormateurById(inscriptionSequence.formateurId);
|
|
if (formateur && formateur.email) {
|
|
const { sendNotificationFormateurCapaciteAtteinte } = await import('./emailService');
|
|
await sendNotificationFormateurCapaciteAtteinte({
|
|
formateurEmail: formateur.email,
|
|
formateurNom: formateur.nom,
|
|
formationNom: inscriptionFormation.nom,
|
|
sequenceNom: inscriptionSequence.nom,
|
|
capaciteMax: inscriptionSequence.capaciteMax,
|
|
nbInscrits: nbInscritsApres,
|
|
dates: dates.map(d => ({
|
|
dateDebut: new Date(d.dateDebut),
|
|
dateFin: new Date(d.dateFin),
|
|
ordre: d.ordre,
|
|
})),
|
|
});
|
|
console.log(`[Notification] Alerte capacité atteinte envoyée au formateur ${formateur.email}`);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error(`[Notification] Erreur envoi alerte capacité:`, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: true, statut };
|
|
}),
|
|
|
|
desinscrire: publicProcedure.input(z.object({
|
|
apprenantId: z.number(),
|
|
sequenceId: z.number(),
|
|
})).mutation(async ({ input }) => {
|
|
// Vérifier si la séquence existe
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
}
|
|
|
|
// Vérifier si la séquence est bloquée
|
|
const now = new Date();
|
|
if (sequence.statut === 'bloquee' || 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 }) => {
|
|
// Récupérer l'inscription avant modification
|
|
const inscriptionAvant = await db.getInscriptionById(input.id);
|
|
const ancienStatut = inscriptionAvant?.statut;
|
|
|
|
await db.updateInscription(input.id, { statut: input.statut });
|
|
|
|
// Si passage à annulée, notifier le formateur
|
|
if (input.statut === 'annulee' && ancienStatut !== 'annulee' && inscriptionAvant) {
|
|
const sequence = await db.getSequenceById(inscriptionAvant.sequenceId);
|
|
const apprenant = await db.getApprenantById(inscriptionAvant.apprenantId);
|
|
|
|
if (sequence && apprenant && sequence.formateurId) {
|
|
const formateur = await db.getFormateurById(sequence.formateurId);
|
|
const formation = await db.getFormationById(sequence.formationId);
|
|
|
|
if (formateur && formateur.email && formation) {
|
|
const nbInscritsApres = await db.countInscriptionsBySequence(sequence.id, 'confirmee');
|
|
|
|
try {
|
|
await sendNotificationFormateurAnnulation({
|
|
formateurEmail: formateur.email,
|
|
formateurNom: formateur.nom,
|
|
apprenantNom: apprenant.nom,
|
|
apprenantPrenom: apprenant.prenom,
|
|
apprenantFonction: apprenant.fonction || '',
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
nbInscrits: nbInscritsApres,
|
|
capaciteMax: sequence.capaciteMax,
|
|
});
|
|
console.log(`[Notification] Annulation envoyée au formateur ${formateur.email}`);
|
|
|
|
// Si une place se libère (passage de capacité max à disponible)
|
|
if (nbInscritsApres < sequence.capaciteMax && (nbInscritsApres + 1) >= sequence.capaciteMax) {
|
|
const { sendNotificationFormateurPlaceDisponible } = await import('./emailService');
|
|
await sendNotificationFormateurPlaceDisponible({
|
|
formateurEmail: formateur.email,
|
|
formateurNom: formateur.nom,
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
capaciteMax: sequence.capaciteMax,
|
|
nbInscrits: nbInscritsApres,
|
|
placesDisponibles: sequence.capaciteMax - nbInscritsApres,
|
|
});
|
|
console.log(`[Notification] Place disponible envoyée au formateur ${formateur.email}`);
|
|
}
|
|
} catch (e) {
|
|
console.error(`[Notification] Erreur envoi notification formateur:`, e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
sendGroupEmail: adminProcedure.input(z.object({ sequenceId: z.number(), type: z.enum(['teaser', '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.enum(["inscription", "teaser", "rappel", "rappelJ1", "rappel3", "rappel4", "rappel5", "rappel6", "reset_password"]),
|
|
titre: z.string(),
|
|
bodyContent: z.string(),
|
|
couleurPrincipale: z.string(),
|
|
couleurSecondaire: z.string(),
|
|
piedDePage: z.string().nullable(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
return db.upsertEmailTemplate(input);
|
|
}),
|
|
|
|
delete: adminProcedure
|
|
.input(z.object({ type: z.string() }))
|
|
.mutation(async ({ input }) => {
|
|
await db.deleteEmailTemplate(input.type);
|
|
return { success: true };
|
|
}),
|
|
|
|
initializeDefaults: adminProcedure.mutation(async () => {
|
|
await db.initializeDefaultEmailTemplates();
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ===== GESTION DES RAPPELS =====
|
|
rappels: router({
|
|
list: adminProcedure.query(async () => {
|
|
return db.getRappels();
|
|
}),
|
|
|
|
create: adminProcedure
|
|
.input(z.object({
|
|
nom: z.string(),
|
|
templateType: z.enum(["rappel", "rappelJ1", "rappel3", "rappel4", "rappel5", "rappel6"]),
|
|
timing: z.enum(["pre_formation", "post_formation"]).default("pre_formation"),
|
|
joursAvant: z.number(),
|
|
heureEnvoi: z.string().default("09:00"),
|
|
actif: z.boolean(),
|
|
dateFormationIds: z.array(z.number()).optional(),
|
|
fichier: z.object({
|
|
nomFichier: z.string(),
|
|
urlFichier: z.string(),
|
|
s3Key: z.string(),
|
|
typeFichier: z.string(),
|
|
tailleFichier: z.number(),
|
|
}).nullable().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { dateFormationIds, ...rappelData } = input;
|
|
const result: any = await db.createRappel(rappelData);
|
|
|
|
// Créer les associations rappel-date si des dates sont spécifiées
|
|
if (dateFormationIds && dateFormationIds.length > 0) {
|
|
const rappelId = result.insertId || result[0]?.insertId;
|
|
if (rappelId) {
|
|
await db.createRappelDates(Number(rappelId), dateFormationIds);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}),
|
|
|
|
update: adminProcedure
|
|
.input(z.object({
|
|
id: z.number(),
|
|
nom: z.string().optional(),
|
|
templateType: z.enum(["rappel", "rappelJ1", "rappel3", "rappel4", "rappel5", "rappel6"]).optional(),
|
|
timing: z.enum(["pre_formation", "post_formation"]).optional(),
|
|
joursAvant: z.number().optional(),
|
|
heureEnvoi: z.string().optional(),
|
|
actif: z.boolean().optional(),
|
|
dateFormationIds: z.array(z.number()).optional(),
|
|
fichier: z.object({
|
|
nomFichier: z.string(),
|
|
urlFichier: z.string(),
|
|
s3Key: z.string(),
|
|
typeFichier: z.string(),
|
|
tailleFichier: z.number(),
|
|
}).nullable().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { id, dateFormationIds, ...data } = input;
|
|
await db.updateRappel(id, data);
|
|
|
|
// Mettre à jour les associations rappel-date si spécifiées
|
|
if (dateFormationIds !== undefined) {
|
|
await db.deleteRappelDates(id);
|
|
if (dateFormationIds.length > 0) {
|
|
await db.createRappelDates(id, dateFormationIds);
|
|
}
|
|
}
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
delete: adminProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
await db.deleteRappel(input.id);
|
|
return { success: true };
|
|
}),
|
|
|
|
toggleActif: adminProcedure
|
|
.input(z.object({ id: z.number(), actif: z.boolean() }))
|
|
.mutation(async ({ input }) => {
|
|
await db.updateRappel(input.id, { actif: input.actif });
|
|
return { success: true };
|
|
}),
|
|
|
|
envoyerAutomatiques: publicProcedure
|
|
.mutation(async () => {
|
|
const { processRappelsAutomatiques } = await import('./rappelScheduler');
|
|
const result = await processRappelsAutomatiques();
|
|
return result;
|
|
}),
|
|
|
|
testerRappel: adminProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
}
|
|
|
|
const formation = await db.getFormationById(sequence.formationId);
|
|
if (!formation) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
|
}
|
|
|
|
// Récupérer le formateur si présent
|
|
let formateurNom: string | undefined;
|
|
if (sequence.formateurId) {
|
|
const formateur = await db.getFormateurById(sequence.formateurId);
|
|
formateurNom = formateur?.nom;
|
|
}
|
|
|
|
// Récupérer les dates de la séquence
|
|
const dates = await db.getDatesBySequence(input.sequenceId);
|
|
|
|
// Récupérer les rappels configurés pour la première date de la séquence
|
|
if (dates.length === 0) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Cette séquence n\'a pas de dates configurées' });
|
|
}
|
|
|
|
const rappelsConfigures = await db.getRappelsByDateFormation(dates[0].id);
|
|
const rappelsActifs = rappelsConfigures.filter((r: any) => r.actif);
|
|
|
|
if (rappelsActifs.length === 0) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Aucun rappel actif configuré pour cette séquence' });
|
|
}
|
|
|
|
// Utiliser le premier rappel actif trouvé
|
|
const rappelAUtiliser = rappelsActifs[0];
|
|
|
|
// Récupérer les inscrits
|
|
const inscrits = await db.getInscriptionsBySequence(input.sequenceId);
|
|
const inscritsConfirmes = inscrits.filter(i => i.inscription.statut === 'confirmee');
|
|
|
|
if (inscritsConfirmes.length === 0) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Aucun apprenant inscrit confirmé pour cette séquence' });
|
|
}
|
|
|
|
// Envoyer le rappel de test à l'administrateur connecté
|
|
// Envoyer le rappel de test à tous les inscrits confirmés
|
|
const { sendRappelEmail } = await import('./emailService');
|
|
|
|
let nbEnvoyes = 0;
|
|
let nbEchecs = 0;
|
|
const erreurs: string[] = [];
|
|
|
|
for (const inscrit of inscritsConfirmes) {
|
|
const apprenant = inscrit.apprenant;
|
|
|
|
if (!apprenant || !apprenant.email) {
|
|
nbEchecs++;
|
|
erreurs.push(`Apprenant ${apprenant?.nom || 'inconnu'}: email manquant`);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
await sendRappelEmail({
|
|
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,
|
|
templateType: rappelAUtiliser.templateType,
|
|
attachmentUrl: rappelAUtiliser.urlFichier || undefined,
|
|
attachmentFilename: rappelAUtiliser.nomFichier || undefined,
|
|
attachmentMimeType: rappelAUtiliser.typeFichier || undefined,
|
|
});
|
|
|
|
// Enregistrer dans l'historique avec typeEnvoi = 'test'
|
|
await db.createLogRappel({
|
|
rappelId: rappelAUtiliser.id,
|
|
sequenceId: input.sequenceId,
|
|
apprenantId: apprenant.id,
|
|
email: apprenant.email,
|
|
type: rappelAUtiliser.templateType,
|
|
typeEnvoi: 'test',
|
|
statut: 'succes',
|
|
dateEnvoi: new Date(),
|
|
});
|
|
|
|
nbEnvoyes++;
|
|
} catch (error: any) {
|
|
nbEchecs++;
|
|
erreurs.push(`${apprenant.email}: ${error.message}`);
|
|
|
|
// Enregistrer l'échec dans l'historique
|
|
try {
|
|
await db.createLogRappel({
|
|
rappelId: rappelAUtiliser.id,
|
|
sequenceId: input.sequenceId,
|
|
apprenantId: apprenant.id,
|
|
email: apprenant.email,
|
|
type: rappelAUtiliser.templateType,
|
|
typeEnvoi: 'test',
|
|
statut: 'echec',
|
|
messageErreur: error.message,
|
|
dateEnvoi: new Date(),
|
|
});
|
|
} catch (logError) {
|
|
console.error('[LogRappel] Erreur lors de l\'enregistrement du log:', logError);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (nbEnvoyes === 0) {
|
|
throw new TRPCError({
|
|
code: 'INTERNAL_SERVER_ERROR',
|
|
message: `Aucun email envoyé. Erreurs: ${erreurs.join(', ')}`,
|
|
});
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: `Rappel de test envoyé à ${nbEnvoyes} apprenant(s)${nbEchecs > 0 ? ` (${nbEchecs} échec(s))` : ''}`,
|
|
nbEnvoyes,
|
|
nbEchecs,
|
|
erreurs: nbEchecs > 0 ? erreurs : undefined,
|
|
};
|
|
}),
|
|
|
|
historique: adminProcedure
|
|
.input(z.object({
|
|
dateDebut: z.string().optional(),
|
|
dateFin: z.string().optional(),
|
|
sequenceId: z.number().optional(),
|
|
statut: z.enum(['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.enum(["resend", "smtp", "simulation"]),
|
|
apiKey: z.string().nullable(),
|
|
fromEmail: z.string().email(),
|
|
fromName: z.string(),
|
|
mode: z.enum(["simulation", "production"]),
|
|
domainVerified: z.boolean(),
|
|
// Champs SMTP
|
|
smtpHost: z.string().nullable().optional(),
|
|
smtpPort: z.number().nullable().optional(),
|
|
smtpSecure: z.enum(["none", "tls", "ssl"]).nullable().optional(),
|
|
smtpUser: z.string().nullable().optional(),
|
|
smtpPassword: z.string().nullable().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
await db.upsertEmailConfig(input);
|
|
return { success: true };
|
|
}),
|
|
|
|
testEmail: adminProcedure
|
|
.input(z.object({
|
|
toEmail: z.string().email(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
// Importer le service d'envoi
|
|
const { sendEmail } = await import("./_core/emailSender");
|
|
|
|
try {
|
|
const success = await sendEmail({
|
|
to: input.toEmail,
|
|
subject: "Test d'envoi d'email - Formation Manager Itinova",
|
|
html: `
|
|
<h1>Test réussi !</h1>
|
|
<p>Cet email de test a été envoyé avec succès depuis votre configuration SMTP.</p>
|
|
<p>Votre configuration d'envoi d'emails fonctionne correctement.</p>
|
|
`,
|
|
});
|
|
|
|
return { success, message: success ? "Email envoyé avec succès" : "Échec de l'envoi" };
|
|
} catch (error: any) {
|
|
return { success: false, message: error.message };
|
|
}
|
|
}),
|
|
}),
|
|
|
|
// ===== PARAMÈTRES DE L'APPLICATION =====
|
|
parametres: router({
|
|
get: adminProcedure.query(async () => {
|
|
const { getParametres } = await import("./parametresDb");
|
|
return getParametres();
|
|
}),
|
|
|
|
update: adminProcedure
|
|
.input(z.object({
|
|
urlPublique: z.string().url().optional(),
|
|
delaiExpirationQR: z.number().int().positive().optional(),
|
|
dureeValiditeToken: z.number().int().positive().optional(),
|
|
notificationsActives: z.boolean().optional(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const { updateParametres } = await import("./parametresDb");
|
|
await updateParametres(input, ctx.user.id, ctx.user.name || "Administrateur");
|
|
return { success: true };
|
|
}),
|
|
|
|
updateUrlPublique: adminProcedure
|
|
.input(z.object({
|
|
urlPublique: z.string().url(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const { updateUrlPublique } = await import("./parametresDb");
|
|
await updateUrlPublique(input.urlPublique, ctx.user.id, ctx.user.name || "Administrateur");
|
|
return { success: true };
|
|
}),
|
|
|
|
testerUrl: adminProcedure
|
|
.input(z.object({
|
|
url: z.string(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { testerUrl } = await import("./parametresDb");
|
|
return testerUrl(input.url);
|
|
}),
|
|
|
|
getHistorique: adminProcedure
|
|
.input(z.object({
|
|
limit: z.number().int().positive().default(50),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { getHistoriqueParametres } = await import("./parametresDb");
|
|
return getHistoriqueParametres(input.limit);
|
|
}),
|
|
}),
|
|
|
|
// ===== ANALYTICS =====
|
|
analytics: router({
|
|
globalStats: adminProcedure.query(async () => {
|
|
return analyticsDb.getGlobalStats();
|
|
}),
|
|
|
|
inscriptionsByMonth: adminProcedure
|
|
.input(z.object({
|
|
startDate: z.string().optional(),
|
|
endDate: z.string().optional(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const startDate = input.startDate ? new Date(input.startDate) : undefined;
|
|
const endDate = input.endDate ? new Date(input.endDate) : undefined;
|
|
return analyticsDb.getInscriptionsByMonth(startDate, endDate);
|
|
}),
|
|
|
|
tauxRemplissageByMonth: adminProcedure.query(async () => {
|
|
return analyticsDb.getTauxRemplissageByMonth();
|
|
}),
|
|
|
|
participationByEtablissement: adminProcedure.query(async () => {
|
|
return analyticsDb.getParticipationByEtablissement();
|
|
}),
|
|
|
|
participationByFonction: adminProcedure.query(async () => {
|
|
return analyticsDb.getParticipationByFonction();
|
|
}),
|
|
|
|
exportExcel: adminProcedure
|
|
.input(z.object({
|
|
startDate: z.string().optional(),
|
|
endDate: z.string().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const startDate = input.startDate ? new Date(input.startDate) : undefined;
|
|
const endDate = input.endDate ? new Date(input.endDate) : undefined;
|
|
const buffer = await generateAnalyticsExcel(startDate, endDate);
|
|
return {
|
|
data: buffer.toString('base64'),
|
|
filename: `rapport-analytique-${new Date().toISOString().split('T')[0]}.xlsx`,
|
|
};
|
|
}),
|
|
|
|
exportPDF: adminProcedure
|
|
.input(z.object({
|
|
startDate: z.string().optional(),
|
|
endDate: z.string().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const startDate = input.startDate ? new Date(input.startDate) : undefined;
|
|
const endDate = input.endDate ? new Date(input.endDate) : undefined;
|
|
const buffer = await generateAnalyticsPDF(startDate, endDate);
|
|
return {
|
|
data: buffer.toString('base64'),
|
|
filename: `rapport-analytique-${new Date().toISOString().split('T')[0]}.pdf`,
|
|
};
|
|
}),
|
|
}),
|
|
|
|
// ===== QUESTIONNAIRES =====
|
|
questionnaires: router({
|
|
list: adminProcedure.query(async () => {
|
|
const questionnaireDb = await import("./questionnaireDb");
|
|
return questionnaireDb.getAllQuestionnaires();
|
|
}),
|
|
|
|
getById: adminProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const questionnaireDb = await import("./questionnaireDb");
|
|
return questionnaireDb.getQuestionnaireById(input.id);
|
|
}),
|
|
|
|
create: adminProcedure
|
|
.input(z.object({
|
|
titre: z.string(),
|
|
type: z.enum(["satisfaction", "evaluation_pre", "evaluation_post"]),
|
|
description: z.string().optional(),
|
|
formationId: z.number().nullable().optional(),
|
|
actif: z.boolean().default(true),
|
|
envoiAutomatique: z.boolean().default(false),
|
|
delaiEnvoiJours: z.number().default(1),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const questionnaireDb = await import("./questionnaireDb");
|
|
const id = await questionnaireDb.createQuestionnaire(input);
|
|
return { id, success: true };
|
|
}),
|
|
|
|
update: adminProcedure
|
|
.input(z.object({
|
|
id: z.number(),
|
|
titre: z.string().optional(),
|
|
type: z.enum(["satisfaction", "evaluation_pre", "evaluation_post"]).optional(),
|
|
description: z.string().optional(),
|
|
formationId: z.number().nullable().optional(),
|
|
actif: z.boolean().optional(),
|
|
envoiAutomatique: z.boolean().optional(),
|
|
delaiEnvoiJours: z.number().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { id, ...data } = input;
|
|
const questionnaireDb = await import("./questionnaireDb");
|
|
await questionnaireDb.updateQuestionnaire(id, data);
|
|
return { success: true };
|
|
}),
|
|
|
|
delete: adminProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
const questionnaireDb = await import("./questionnaireDb");
|
|
await questionnaireDb.deleteQuestionnaire(input.id);
|
|
return { success: true };
|
|
}),
|
|
|
|
getStats: adminProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const questionnaireDb = await import("./questionnaireDb");
|
|
return questionnaireDb.getQuestionnaireStats(input.id);
|
|
}),
|
|
|
|
getReponsesDetaillees: adminProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const questionnaireDb = await import("./questionnaireDb");
|
|
return questionnaireDb.getReponsesDetailleesQuestionnaire(input.id);
|
|
}),
|
|
|
|
exportExcel: adminProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
const { exportQuestionnaireToExcel } = await import("./questionnaireExport");
|
|
const buffer = await exportQuestionnaireToExcel(input.id);
|
|
return {
|
|
data: buffer.toString('base64'),
|
|
filename: `questionnaire-${input.id}-${new Date().toISOString().split('T')[0]}.xlsx`,
|
|
};
|
|
}),
|
|
|
|
exportPDF: adminProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
const { exportQuestionnaireToPDF } = await import("./questionnaireExport");
|
|
const buffer = await exportQuestionnaireToPDF(input.id);
|
|
return {
|
|
data: buffer.toString('base64'),
|
|
filename: `questionnaire-${input.id}-${new Date().toISOString().split('T')[0]}.pdf`,
|
|
};
|
|
}),
|
|
}),
|
|
|
|
// ===== QUESTIONS =====
|
|
questions: router({
|
|
list: adminProcedure
|
|
.input(z.object({ questionnaireId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const questionnaireDb = await import("./questionnaireDb");
|
|
return questionnaireDb.getQuestionsByQuestionnaireId(input.questionnaireId);
|
|
}),
|
|
|
|
create: adminProcedure
|
|
.input(z.object({
|
|
questionnaireId: z.number(),
|
|
ordre: z.number(),
|
|
texte: z.string(),
|
|
typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]),
|
|
options: z.string().optional(),
|
|
echelleMin: z.number().optional(),
|
|
echelleMax: z.number().optional(),
|
|
echelleLabelMin: z.string().optional(),
|
|
echelleLabelMax: z.string().optional(),
|
|
obligatoire: z.boolean().default(false),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const questionnaireDb = await import("./questionnaireDb");
|
|
const id = await questionnaireDb.createQuestion(input);
|
|
return { id, success: true };
|
|
}),
|
|
|
|
update: adminProcedure
|
|
.input(z.object({
|
|
id: z.number(),
|
|
ordre: z.number().optional(),
|
|
texte: z.string().optional(),
|
|
typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]).optional(),
|
|
options: z.string().optional(),
|
|
echelleMin: z.number().optional(),
|
|
echelleMax: z.number().optional(),
|
|
echelleLabelMin: z.string().optional(),
|
|
echelleLabelMax: z.string().optional(),
|
|
obligatoire: z.boolean().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { id, ...data } = input;
|
|
const questionnaireDb = await import("./questionnaireDb");
|
|
await questionnaireDb.updateQuestion(id, data);
|
|
return { success: true };
|
|
}),
|
|
|
|
delete: adminProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
const questionnaireDb = await import("./questionnaireDb");
|
|
await questionnaireDb.deleteQuestion(input.id);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ===== RÉPONSES QUESTIONNAIRES (PUBLIC) =====
|
|
reponses: router({
|
|
getByToken: publicProcedure
|
|
.input(z.object({ token: z.string() }))
|
|
.query(async ({ input }) => {
|
|
const questionnaireDb = await import("./questionnaireDb");
|
|
const envoi = await questionnaireDb.getEnvoiByToken(input.token);
|
|
if (!envoi) return null;
|
|
|
|
const questionnaire = await questionnaireDb.getQuestionnaireById(envoi.questionnaireId);
|
|
const questions = await questionnaireDb.getQuestionsByQuestionnaireId(envoi.questionnaireId);
|
|
|
|
return {
|
|
envoi,
|
|
questionnaire,
|
|
questions,
|
|
};
|
|
}),
|
|
|
|
submit: publicProcedure
|
|
.input(z.object({
|
|
token: z.string(),
|
|
reponses: z.array(z.object({
|
|
questionId: z.number(),
|
|
reponseNumerique: z.number().optional(),
|
|
reponseTexte: z.string().optional(),
|
|
})),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const questionnaireDb = await import("./questionnaireDb");
|
|
|
|
// Récupérer l'envoi
|
|
const envoi = await questionnaireDb.getEnvoiByToken(input.token);
|
|
if (!envoi) throw new Error("Token invalide");
|
|
if (envoi.dateReponse) throw new Error("Questionnaire déjà répondu");
|
|
|
|
// Créer la réponse au questionnaire
|
|
const reponseQuestionnaireId = await questionnaireDb.createReponseQuestionnaire({
|
|
questionnaireId: envoi.questionnaireId,
|
|
apprenantId: envoi.apprenantId,
|
|
sequenceId: envoi.sequenceId,
|
|
});
|
|
|
|
// Enregistrer chaque réponse
|
|
for (const reponse of input.reponses) {
|
|
await questionnaireDb.createReponseQuestion({
|
|
reponseQuestionnaireId,
|
|
questionId: reponse.questionId,
|
|
reponseNumerique: reponse.reponseNumerique,
|
|
reponseTexte: reponse.reponseTexte,
|
|
});
|
|
}
|
|
|
|
// Mettre à jour l'envoi
|
|
await questionnaireDb.updateEnvoiQuestionnaire(envoi.id, {
|
|
dateReponse: new Date(),
|
|
});
|
|
|
|
// Envoyer une notification à l'administrateur
|
|
try {
|
|
const { notifyOwner } = await import('./_core/notification');
|
|
const questionnaire = await questionnaireDb.getQuestionnaireById(envoi.questionnaireId);
|
|
|
|
await notifyOwner({
|
|
title: 'Nouvelle réponse au questionnaire',
|
|
content: `Un apprenant a répondu au questionnaire "${questionnaire?.titre || 'Sans titre'}"`,
|
|
});
|
|
} catch (error) {
|
|
console.error('[Notification] Erreur lors de l\'envoi de la notification:', error);
|
|
// Ne pas bloquer la réponse si la notification échoue
|
|
}
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
envoyerAutomatiques: adminProcedure
|
|
.mutation(async () => {
|
|
const { processEnvoisAutomatiques } = await import('./questionnaireScheduler');
|
|
const result = await processEnvoisAutomatiques();
|
|
return result;
|
|
}),
|
|
}),
|
|
|
|
// ===== SUIVI QUESTIONNAIRES =====
|
|
questionnaireSuivi: router({
|
|
statsGlobales: adminProcedure.query(async () => {
|
|
const suiviDb = await import("./questionnaireSuiviDb");
|
|
return suiviDb.getStatsGlobales();
|
|
}),
|
|
|
|
statsByFormation: adminProcedure.query(async () => {
|
|
const suiviDb = await import("./questionnaireSuiviDb");
|
|
return suiviDb.getStatsByFormation();
|
|
}),
|
|
|
|
evolutionTemporelle: adminProcedure
|
|
.input(z.object({
|
|
startDate: z.date().optional(),
|
|
endDate: z.date().optional(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const suiviDb = await import("./questionnaireSuiviDb");
|
|
return suiviDb.getEvolutionTemporelle(input.startDate, input.endDate);
|
|
}),
|
|
|
|
statsByFormateur: adminProcedure.query(async () => {
|
|
const suiviDb = await import("./questionnaireSuiviDb");
|
|
return suiviDb.getStatsByFormateur();
|
|
}),
|
|
}),
|
|
|
|
// ===== ESPACE FORMATEUR =====
|
|
formateur: router({
|
|
calendrier: protectedProcedure
|
|
.input(z.object({
|
|
formateurId: z.number(),
|
|
dateDebut: z.date().optional(),
|
|
dateFin: z.date().optional(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const formateurDb = await import("./formateurDb");
|
|
return formateurDb.getCalendrierFormateur(input.formateurId, input.dateDebut, input.dateFin);
|
|
}),
|
|
|
|
apprenants: protectedProcedure
|
|
.input(z.object({ sequenceId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const formateurDb = await import("./formateurDb");
|
|
return formateurDb.getApprenantsSequence(input.sequenceId);
|
|
}),
|
|
|
|
supports: protectedProcedure
|
|
.input(z.object({ sequenceId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const formateurDb = await import("./formateurDb");
|
|
return formateurDb.getSupportsSequence(input.sequenceId);
|
|
}),
|
|
|
|
ajouterSupport: protectedProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
formateurId: z.number(),
|
|
nomFichier: z.string(),
|
|
typeFichier: z.string(),
|
|
tailleFichier: z.number(),
|
|
urlFichier: z.string(),
|
|
s3Key: z.string(),
|
|
description: z.string().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const formateurDb = await import("./formateurDb");
|
|
return formateurDb.ajouterSupport(input);
|
|
}),
|
|
|
|
supprimerSupport: protectedProcedure
|
|
.input(z.object({
|
|
supportId: z.number(),
|
|
formateurId: z.number(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const formateurDb = await import("./formateurDb");
|
|
const support = await formateurDb.supprimerSupport(input.supportId, input.formateurId);
|
|
|
|
// Note: La suppression du fichier S3 devrait être gérée par un processus de nettoyage séparé
|
|
// car il n'y a pas de fonction storageDelete dans l'API actuelle
|
|
|
|
return { success: true, support };
|
|
}),
|
|
|
|
validerPresence: protectedProcedure
|
|
.input(z.object({
|
|
inscriptionId: z.number(),
|
|
present: z.boolean(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const formateurDb = await import("./formateurDb");
|
|
await formateurDb.validerPresence(input.inscriptionId, input.present);
|
|
return { success: true };
|
|
}),
|
|
|
|
historique: protectedProcedure
|
|
.input(z.object({
|
|
formateurId: z.number(),
|
|
limit: z.number().optional(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const formateurDb = await import("./formateurDb");
|
|
return formateurDb.getHistoriqueFormateur(input.formateurId, input.limit);
|
|
}),
|
|
|
|
detailSequence: protectedProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
formateurId: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const formateurDb = await import("./formateurDb");
|
|
return formateurDb.getDetailSequence(input.sequenceId, input.formateurId);
|
|
}),
|
|
}),
|
|
|
|
// ===== ÉTABLISSEMENTS =====
|
|
etablissements: router({
|
|
list: adminProcedure.query(async () => {
|
|
const { getEtablissementsStats } = await import("./etablissementsDb");
|
|
return getEtablissementsStats();
|
|
}),
|
|
|
|
getDetail: adminProcedure
|
|
.input(z.object({ codeEtablissement: z.string() }))
|
|
.query(async ({ input }) => {
|
|
const { getEtablissementDetail } = await import("./etablissementsDb");
|
|
return getEtablissementDetail(input.codeEtablissement);
|
|
}),
|
|
}),
|
|
|
|
// ===== NOTIFICATIONS =====
|
|
notifications: router({
|
|
envoyerRemerciements: adminProcedure
|
|
.input(z.object({ sequenceId: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
const { envoyerRemerciementsSequence } = await import("./remerciementScheduler");
|
|
return envoyerRemerciementsSequence(input.sequenceId);
|
|
}),
|
|
|
|
envoyerRemerciementsAutomatiques: adminProcedure
|
|
.mutation(async () => {
|
|
const { processRemerciementsAutomatiques } = await import("./remerciementScheduler");
|
|
await processRemerciementsAutomatiques();
|
|
return { success: true };
|
|
}),
|
|
|
|
// Historique des notifications
|
|
historique: adminProcedure
|
|
.input(z.object({
|
|
type: z.enum(["remerciement", "notification_formateur_inscription", "notification_formateur_annulation", "alerte_capacite", "notification_liste_attente"]).optional(),
|
|
dateDebut: z.string().optional(),
|
|
dateFin: z.string().optional(),
|
|
statut: z.enum(["success", "failed"]).optional(),
|
|
email: z.string().optional(),
|
|
sequenceId: z.number().optional(),
|
|
limit: z.number().default(50),
|
|
offset: z.number().default(0),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { getNotificationLogs } = await import("./notificationLogsDb");
|
|
return getNotificationLogs({
|
|
type: input.type,
|
|
dateDebut: input.dateDebut ? new Date(input.dateDebut) : undefined,
|
|
dateFin: input.dateFin ? new Date(input.dateFin) : undefined,
|
|
statut: input.statut,
|
|
email: input.email,
|
|
sequenceId: input.sequenceId,
|
|
limit: input.limit,
|
|
offset: input.offset,
|
|
});
|
|
}),
|
|
|
|
// Statistiques des notifications
|
|
statistiques: adminProcedure
|
|
.input(z.object({
|
|
dateDebut: z.string().optional(),
|
|
dateFin: z.string().optional(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { getNotificationStats } = await import("./notificationLogsDb");
|
|
return getNotificationStats({
|
|
dateDebut: input.dateDebut ? new Date(input.dateDebut) : undefined,
|
|
dateFin: input.dateFin ? new Date(input.dateFin) : undefined,
|
|
});
|
|
}),
|
|
}),
|
|
|
|
// Attestations de formation
|
|
attestations: router({
|
|
// Générer une attestation pour une inscription
|
|
generer: adminProcedure
|
|
.input(z.object({
|
|
inscriptionId: z.number(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { genererAttestationPDF, enregistrerAttestation, getAttestation } = await import("./attestationService");
|
|
const db = await import("./db").then(m => m.getDb());
|
|
if (!db) throw new Error("Base de données non disponible");
|
|
|
|
// Vérifier si l'attestation existe déjà
|
|
const { inscriptions } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
const [inscription] = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
|
|
if (!inscription) {
|
|
throw new Error("Inscription introuvable");
|
|
}
|
|
|
|
const attestationExistante = await getAttestation(inscription.apprenantId, inscription.sequenceId);
|
|
if (attestationExistante) {
|
|
return {
|
|
success: true,
|
|
attestation: attestationExistante,
|
|
message: "Attestation déjà générée",
|
|
};
|
|
}
|
|
|
|
// Générer le PDF
|
|
const { s3Key, pdfUrl } = await genererAttestationPDF(input.inscriptionId);
|
|
|
|
// Enregistrer dans la base
|
|
const attestationId = await enregistrerAttestation(
|
|
input.inscriptionId,
|
|
inscription.apprenantId,
|
|
inscription.sequenceId,
|
|
s3Key,
|
|
pdfUrl
|
|
);
|
|
|
|
const attestation = await getAttestation(inscription.apprenantId, inscription.sequenceId);
|
|
|
|
return {
|
|
success: true,
|
|
attestation,
|
|
message: "Attestation générée avec succès",
|
|
};
|
|
}),
|
|
|
|
// Récupérer l'attestation d'un apprenant pour une séquence
|
|
get: publicProcedure
|
|
.input(z.object({
|
|
apprenantId: z.number(),
|
|
sequenceId: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { getAttestation } = await import("./attestationService");
|
|
return getAttestation(input.apprenantId, input.sequenceId);
|
|
}),
|
|
|
|
// Récupérer toutes les attestations d'un apprenant
|
|
listApprenant: publicProcedure
|
|
.input(z.object({
|
|
apprenantId: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { getAttestationsApprenant } = await import("./attestationService");
|
|
return getAttestationsApprenant(input.apprenantId);
|
|
}),
|
|
|
|
// Récupérer la configuration des attestations
|
|
getConfig: adminProcedure
|
|
.query(async () => {
|
|
const { getOrCreateConfigAttestation } = await import("./attestationService");
|
|
return getOrCreateConfigAttestation();
|
|
}),
|
|
|
|
// Mettre à jour la configuration des attestations
|
|
updateConfig: adminProcedure
|
|
.input(z.object({
|
|
nomSignataire: z.string().optional(),
|
|
fonctionSignataire: z.string().optional(),
|
|
texteAttestation: z.string().optional(),
|
|
logoUrl: z.string().optional(),
|
|
logoS3Key: z.string().optional(),
|
|
signatureUrl: z.string().optional(),
|
|
signatureS3Key: z.string().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { updateConfigAttestation } = await import("./attestationService");
|
|
await updateConfigAttestation(input);
|
|
return { success: true };
|
|
}),
|
|
|
|
// Récupérer l'historique des envois d'attestations
|
|
historique: adminProcedure
|
|
.query(async () => {
|
|
const { getHistoriqueAttestations } = await import("./attestationsDb");
|
|
return getHistoriqueAttestations();
|
|
}),
|
|
|
|
// Récupérer l'historique pour une séquence spécifique
|
|
historiqueBySequence: adminProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { getHistoriqueAttestationsBySequence } = await import("./attestationsDb");
|
|
return getHistoriqueAttestationsBySequence(input.sequenceId);
|
|
}),
|
|
|
|
// Générer une prévisualisation du modèle d'attestation
|
|
previsualiser: adminProcedure
|
|
.mutation(async () => {
|
|
const { genererPreviewAttestation } = await import("./attestationService");
|
|
return genererPreviewAttestation();
|
|
}),
|
|
}),
|
|
|
|
// ===== PRESENCES (ÉMARGEMENT NUMÉRIQUE) =====
|
|
presences: router({
|
|
// Générer un QR code pour une séquence
|
|
generateQRCode: protectedProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const { generateQRToken, generateQRCodeDataURL } = await import("./qrCodeGenerator");
|
|
const { getDb } = await import("./db");
|
|
const { sequences } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
|
|
const db = await getDb();
|
|
if (!db) throw new Error("Database not available");
|
|
|
|
// Vérifier que la séquence existe
|
|
const sequence = await db.select().from(sequences).where(eq(sequences.id, input.sequenceId)).limit(1);
|
|
if (sequence.length === 0) {
|
|
throw new Error("Séquence introuvable");
|
|
}
|
|
|
|
// Générer un nouveau token si nécessaire
|
|
let token = sequence[0].qrCodeToken;
|
|
if (!token) {
|
|
token = generateQRToken();
|
|
await db.update(sequences).set({ qrCodeToken: token }).where(eq(sequences.id, input.sequenceId));
|
|
}
|
|
|
|
// Générer le QR code
|
|
const qrCodeDataURL = await generateQRCodeDataURL(token);
|
|
|
|
return {
|
|
token,
|
|
qrCodeDataURL,
|
|
};
|
|
}),
|
|
|
|
// Valider une présence (scan QR code ou manuel)
|
|
valider: publicProcedure
|
|
.input(z.object({
|
|
token: z.string().optional(),
|
|
inscriptionId: z.number(),
|
|
dateFormationId: z.number(),
|
|
modeValidation: z.enum(["qrcode", "manuel"]),
|
|
validateurId: z.number().optional(),
|
|
commentaire: z.string().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { validerPresence } = await import("./presenceDb");
|
|
const { getDb } = await import("./db");
|
|
const { sequences, inscriptions } = await import("../drizzle/schema");
|
|
const { eq } = await import("drizzle-orm");
|
|
|
|
// Si mode QR code, vérifier le token
|
|
if (input.modeValidation === "qrcode" && input.token) {
|
|
const db = await getDb();
|
|
if (!db) throw new Error("Database not available");
|
|
|
|
// Récupérer l'inscription pour vérifier la séquence
|
|
const inscription = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
|
|
if (inscription.length === 0) {
|
|
throw new Error("Inscription introuvable");
|
|
}
|
|
|
|
// Vérifier que le token correspond à la séquence
|
|
const sequence = await db.select().from(sequences).where(eq(sequences.id, inscription[0].sequenceId)).limit(1);
|
|
if (sequence.length === 0 || sequence[0].qrCodeToken !== input.token) {
|
|
throw new Error("QR code invalide");
|
|
}
|
|
}
|
|
|
|
const result = await validerPresence({
|
|
inscriptionId: input.inscriptionId,
|
|
dateFormationId: input.dateFormationId,
|
|
modeValidation: input.modeValidation,
|
|
validateurId: input.validateurId,
|
|
commentaire: input.commentaire,
|
|
});
|
|
|
|
// Vérifier si toutes les présences sont validées
|
|
const { checkAllPresencesValidated } = await import("./presenceDb");
|
|
const allValidated = await checkAllPresencesValidated(input.inscriptionId);
|
|
|
|
// Si toutes les présences sont validées, générer l'attestation automatiquement
|
|
if (allValidated) {
|
|
try {
|
|
const { genererAttestationPDF, enregistrerAttestation } = await import("./attestationService");
|
|
|
|
// Générer le PDF
|
|
const { s3Key, pdfUrl } = await genererAttestationPDF(input.inscriptionId);
|
|
|
|
// Récupérer les données de l'inscription
|
|
const db = await getDb();
|
|
if (db) {
|
|
const inscriptionData = await db.select().from(inscriptions).where(eq(inscriptions.id, input.inscriptionId)).limit(1);
|
|
if (inscriptionData.length > 0) {
|
|
// Enregistrer l'attestation en base
|
|
await enregistrerAttestation(
|
|
input.inscriptionId,
|
|
inscriptionData[0].apprenantId,
|
|
inscriptionData[0].sequenceId,
|
|
s3Key,
|
|
pdfUrl
|
|
);
|
|
}
|
|
}
|
|
|
|
return {
|
|
...result,
|
|
attestationGenerated: true,
|
|
attestationUrl: pdfUrl,
|
|
};
|
|
} catch (error) {
|
|
console.error("Erreur lors de la génération de l'attestation:", error);
|
|
// Ne pas bloquer la validation de présence si l'attestation échoue
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}),
|
|
|
|
// Lister les présences pour une séquence
|
|
listBySequence: protectedProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { getPresencesBySequence } = await import("./presenceDb");
|
|
return getPresencesBySequence(input.sequenceId);
|
|
}),
|
|
|
|
// Lister les présences pour une inscription
|
|
listByInscription: publicProcedure
|
|
.input(z.object({
|
|
inscriptionId: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { getPresencesByInscription } = await import("./presenceDb");
|
|
return getPresencesByInscription(input.inscriptionId);
|
|
}),
|
|
|
|
// Supprimer une présence
|
|
delete: protectedProcedure
|
|
.input(z.object({
|
|
presenceId: z.number(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { supprimerPresence } = await import("./presenceDb");
|
|
return supprimerPresence(input.presenceId);
|
|
}),
|
|
|
|
// Vérifier si toutes les présences sont validées pour une inscription
|
|
checkAllValidated: publicProcedure
|
|
.input(z.object({
|
|
inscriptionId: z.number(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { checkAllPresencesValidated } = await import("./presenceDb");
|
|
return checkAllPresencesValidated(input.inscriptionId);
|
|
}),
|
|
}),
|
|
|
|
// ===== GESTION DES ATTESTATIONS =====
|
|
gestionAttestations: router({
|
|
// Récupérer la configuration d'une formation
|
|
getFormationConfig: adminProcedure
|
|
.input(z.object({ formationId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const { getFormationConfig } = await import("./gestionAttestationsDb");
|
|
return getFormationConfig(input.formationId);
|
|
}),
|
|
|
|
// Mettre à jour la configuration d'une formation
|
|
updateFormationConfig: adminProcedure
|
|
.input(z.object({
|
|
formationId: z.number(),
|
|
modeAttestation: z.enum(["auto", "manuel"]),
|
|
modeEnvoi: z.enum(["auto", "manuel"]),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { updateFormationConfig } = await import("./gestionAttestationsDb");
|
|
return updateFormationConfig(
|
|
input.formationId,
|
|
input.modeAttestation,
|
|
input.modeEnvoi
|
|
);
|
|
}),
|
|
|
|
// Récupérer les apprenants avec leur statut d'attestation
|
|
getApprenantsWithStatus: adminProcedure
|
|
.input(z.object({ formationId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
try {
|
|
const { getApprenantsWithAttestationStatus } = await import("./gestionAttestationsDb");
|
|
const results = await getApprenantsWithAttestationStatus(input.formationId);
|
|
console.log("[gestionAttestations] Found", results.length, "apprenants for formation", input.formationId);
|
|
return results;
|
|
} catch (error) {
|
|
console.error("[gestionAttestations] Error fetching apprenants:", error);
|
|
throw error;
|
|
}
|
|
}),
|
|
|
|
// Uploader un document d'attestation
|
|
uploadDocument: adminProcedure
|
|
.input(z.object({
|
|
inscriptionId: z.number(),
|
|
documentUrl: z.string(),
|
|
documentS3Key: z.string(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const { uploadAttestationDocument } = await import("./gestionAttestationsDb");
|
|
return uploadAttestationDocument(
|
|
input.inscriptionId,
|
|
input.documentUrl,
|
|
input.documentS3Key,
|
|
ctx.user.id
|
|
);
|
|
}),
|
|
|
|
// Supprimer un document d'attestation
|
|
deleteDocument: adminProcedure
|
|
.input(z.object({ attestationId: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
const { deleteAttestationDocument } = await import("./gestionAttestationsDb");
|
|
return deleteAttestationDocument(input.attestationId);
|
|
}),
|
|
|
|
// Envoyer une attestation par email
|
|
sendAttestation: adminProcedure
|
|
.input(z.object({
|
|
attestationId: z.number(),
|
|
apprenantEmail: z.string(),
|
|
apprenantNom: z.string(),
|
|
apprenantPrenom: z.string(),
|
|
formationNom: z.string(),
|
|
pdfUrl: z.string(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { sendAttestationEmail } = await import("./emailService");
|
|
const { markAttestationAsSent } = await import("./gestionAttestationsDb");
|
|
|
|
// Envoyer l'email
|
|
await sendAttestationEmail({
|
|
to: input.apprenantEmail,
|
|
apprenantNom: input.apprenantNom,
|
|
apprenantPrenom: input.apprenantPrenom,
|
|
formationNom: input.formationNom,
|
|
pdfUrl: input.pdfUrl,
|
|
});
|
|
|
|
// Marquer comme envoyé
|
|
await markAttestationAsSent(input.attestationId);
|
|
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
});
|
|
|
|
export type AppRouter = typeof appRouter;
|