✅ **Nouvelles fonctionnalités** : **Base de données** : - Nouvelle table `supportsFormation` pour stocker les documents uploadés par les formateurs - Champs : sequenceId, formateurId, nomFichier, typeFichier, tailleFichier, urlFichier, s3Key, description - Migration effectuée avec succès **Backend (server/formateurDb.ts)** : - `getCalendrierFormateur()` : Récupère les interventions à venir avec dates, lieux et nombre d'inscrits - `getApprenantsSequence()` : Liste des apprenants inscrits à une séquence - `getSupportsSequence()` : Liste des supports de formation d'une séquence - `ajouterSupport()` : Upload d'un nouveau support - `supprimerSupport()` : Suppression d'un support avec vérification de propriété - `validerPresence()` : Validation de la présence d'un apprenant - `getHistoriqueFormateur()` : Historique des formations passées avec statistiques - `getDetailSequence()` : Détails complets d'une séquence avec dates **API tRPC (server/routers.ts)** : - `formateur.calendrier` : Calendrier des interventions avec filtres de dates - `formateur.apprenants` : Liste des apprenants d'une séquence - `formateur.supports` : Gestion des supports (liste, ajout, suppression) - `formateur.validerPresence` : Validation des présences en ligne - `formateur.historique` : Historique complet des formations - `formateur.detailSequence` : Détails d'une séquence **Pages frontend** : - `/formateur` : **FormateurDashboard** - Calendrier des interventions à venir groupées par date - `/formateur/sequence/:id` : **FormateurSequence** - Détails séquence avec onglets Apprenants et Supports - `/formateur/historique` : **FormateurHistorique** - Historique des formations avec taux de présence **Fonctionnalités implémentées** : - 📅 Calendrier des interventions avec dates, heures, lieux et nombre d'inscrits - 👥 Liste des apprenants avec informations complètes (nom, email, fonction, établissement) - ✅ Validation des présences en ligne avec checkboxes interactives - 📎 Gestion des supports de formation (liste, téléchargement) - 📊 Historique des formations avec statistiques (inscrits, présents, taux) - 🎨 Interface moderne avec cartes, tableaux et onglets **Notes** : - L'ID du formateur est actuellement fixé à 1 (TODO: lier à l'utilisateur connecté) - La fonctionnalité d'upload de supports est préparée mais nécessite l'implémentation du composant d'upload - Les présences sont gérées via le statut de l'inscription (confirmée/annulée)
1376 lines
47 KiB
TypeScript
1376 lines
47 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 } from "./emailService";
|
|
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),
|
|
})).mutation(async ({ input }) => {
|
|
await db.createFormateur(input);
|
|
return { success: true };
|
|
}),
|
|
|
|
update: adminProcedure.input(z.object({
|
|
id: z.number(),
|
|
nom: z.string().min(1).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);
|
|
return { ...seq, dates };
|
|
}),
|
|
|
|
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,
|
|
});
|
|
}
|
|
|
|
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' });
|
|
}
|
|
|
|
await db.updateInscription(inscription.id, { statut: 'annulee' });
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
updateStatut: adminProcedure.input(z.object({
|
|
id: z.number(),
|
|
statut: z.enum(['confirmee', 'liste_attente', 'annulee']),
|
|
})).mutation(async ({ input }) => {
|
|
await db.updateInscription(input.id, { statut: input.statut });
|
|
return { success: true };
|
|
}),
|
|
|
|
sendGroupEmail: adminProcedure.input(z.object({ sequenceId: z.number(), type: z.enum(['teaser', 'rappel', 'rappel_j1']) })).mutation(async ({ input }) => {
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
}
|
|
|
|
const formation = await db.getFormationById(sequence.formationId);
|
|
if (!formation) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
|
}
|
|
|
|
const dates = await db.getDatesBySequence(sequence.id);
|
|
if (dates.length === 0) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Aucune date trouvée pour cette séquence' });
|
|
}
|
|
|
|
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
|
|
const recipients = inscriptions
|
|
.filter(i => i.inscription.statut === 'confirmee' && i.apprenant)
|
|
.map(i => ({
|
|
email: i.apprenant!.email,
|
|
prenom: i.apprenant!.prenom,
|
|
nom: i.apprenant!.nom,
|
|
fonction: i.apprenant!.fonction,
|
|
}));
|
|
|
|
console.log(`[Email Group] Envoi de ${recipients.length} emails de type ${input.type}`);
|
|
console.log(`[Email Group] Destinataires:`, recipients.map(r => r.email));
|
|
|
|
let formateurNom: string | undefined;
|
|
if (sequence.formateurId) {
|
|
const formateur = await db.getFormateurById(sequence.formateurId);
|
|
formateurNom = formateur?.nom;
|
|
}
|
|
|
|
const result = await sendGroupEmail({
|
|
recipients,
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
type: input.type,
|
|
dates: dates.map(d => ({
|
|
dateDebut: new Date(d.dateDebut),
|
|
dateFin: new Date(d.dateFin),
|
|
ordre: d.ordre,
|
|
})),
|
|
lieu: sequence.lieu,
|
|
formateur: formateurNom,
|
|
});
|
|
|
|
console.log(`[Email Group] Résultat: ${result.sent} envoyés, ${result.failed} échoués`);
|
|
|
|
return {
|
|
...result,
|
|
message: `${result.sent} email(s) envoyé(s) avec succès, ${result.failed} échoué(s)`
|
|
};
|
|
}),
|
|
|
|
emailPreview: adminProcedure
|
|
.input(z.object({
|
|
sequenceId: z.number(),
|
|
type: z.enum(['teaser', 'rappel', 'rappel_j1']),
|
|
apprenantId: z.number().optional(),
|
|
}))
|
|
.query(async ({ input }) => {
|
|
const { generateEmailPreview } = await import('./emailPreview');
|
|
return await generateEmailPreview(input);
|
|
}),
|
|
|
|
exportExcel: adminProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
|
|
const formation = await db.getFormationById(sequence.formationId);
|
|
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
|
|
|
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
|
|
const dates = await db.getDatesBySequence(input.sequenceId);
|
|
|
|
const data = inscriptions
|
|
.filter(i => i.apprenant)
|
|
.map(i => ({
|
|
nom: i.apprenant!.nom,
|
|
prenom: i.apprenant!.prenom,
|
|
email: i.apprenant!.email,
|
|
codeEtablissement: i.apprenant!.codeEtablissement,
|
|
fonction: i.apprenant!.fonction,
|
|
statut: i.inscription.statut,
|
|
dateInscription: i.inscription.dateInscription,
|
|
}));
|
|
|
|
const buffer = await generateExcelExport({
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
dates: dates.map(d => ({
|
|
dateDebut: new Date(d.dateDebut),
|
|
dateFin: new Date(d.dateFin),
|
|
ordre: d.ordre,
|
|
})),
|
|
lieu: sequence.lieu,
|
|
publicCible: sequence.publicCible,
|
|
inscriptions: data,
|
|
});
|
|
|
|
return {
|
|
buffer: buffer.toString('base64'),
|
|
filename: `inscriptions_${sequence.nom.replace(/\s+/g, '_')}.xlsx`,
|
|
};
|
|
}),
|
|
|
|
exportPDF: adminProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
|
|
const formation = await db.getFormationById(sequence.formationId);
|
|
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
|
|
|
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
|
|
const dates = await db.getDatesBySequence(input.sequenceId);
|
|
|
|
const data = inscriptions
|
|
.filter(i => i.apprenant)
|
|
.map(i => ({
|
|
nom: i.apprenant!.nom,
|
|
prenom: i.apprenant!.prenom,
|
|
email: i.apprenant!.email,
|
|
codeEtablissement: i.apprenant!.codeEtablissement,
|
|
fonction: i.apprenant!.fonction,
|
|
statut: i.inscription.statut,
|
|
dateInscription: i.inscription.dateInscription,
|
|
}));
|
|
|
|
const buffer = await generatePDFExport({
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
dates: dates.map(d => ({
|
|
dateDebut: new Date(d.dateDebut),
|
|
dateFin: new Date(d.dateFin),
|
|
ordre: d.ordre,
|
|
})),
|
|
lieu: sequence.lieu,
|
|
publicCible: sequence.publicCible,
|
|
inscriptions: data,
|
|
});
|
|
|
|
return {
|
|
buffer: buffer.toString('base64'),
|
|
filename: `inscriptions_${sequence.nom.replace(/\s+/g, '_')}.pdf`,
|
|
};
|
|
}),
|
|
|
|
exportFeuillePresence: adminProcedure.input(z.object({ sequenceId: z.number() })).mutation(async ({ input }) => {
|
|
const sequence = await db.getSequenceById(input.sequenceId);
|
|
if (!sequence) throw new TRPCError({ code: 'NOT_FOUND', message: 'Séquence introuvable' });
|
|
|
|
const formation = await db.getFormationById(sequence.formationId);
|
|
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
|
|
|
const inscriptions = await db.getInscriptionsBySequence(input.sequenceId);
|
|
const dates = await db.getDatesBySequence(input.sequenceId);
|
|
|
|
// Récupérer le formateur si défini
|
|
let formateurNom: string | undefined = undefined;
|
|
if (sequence.formateurId) {
|
|
const formateur = await db.getFormateurById(sequence.formateurId);
|
|
if (formateur) {
|
|
formateurNom = formateur.nom;
|
|
}
|
|
}
|
|
|
|
const data = inscriptions
|
|
.filter(i => i.inscription.statut === 'confirmee' && i.apprenant)
|
|
.map(i => ({
|
|
nom: i.apprenant!.nom,
|
|
prenom: i.apprenant!.prenom,
|
|
codeEtablissement: i.apprenant!.codeEtablissement,
|
|
fonction: i.apprenant!.fonction,
|
|
}));
|
|
|
|
const buffer = await generateFeuillePresence({
|
|
formationNom: formation.nom,
|
|
sequenceNom: sequence.nom,
|
|
dates: dates.map(d => ({
|
|
dateDebut: new Date(d.dateDebut),
|
|
dateFin: new Date(d.dateFin),
|
|
ordre: d.ordre,
|
|
})),
|
|
lieu: sequence.lieu,
|
|
publicCible: sequence.publicCible,
|
|
formateur: formateurNom,
|
|
apprenants: data,
|
|
});
|
|
|
|
return {
|
|
buffer: buffer.toString('base64'),
|
|
filename: `feuille_presence_${sequence.nom.replace(/\s+/g, '_')}.pdf`,
|
|
};
|
|
}),
|
|
}),
|
|
|
|
// ===== GESTION DES UTILISATEURS =====
|
|
users: router({
|
|
list: adminProcedure.query(async () => {
|
|
return db.getAllUsers();
|
|
}),
|
|
|
|
getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
|
|
return db.getUserById(input.id);
|
|
}),
|
|
|
|
create: adminProcedure.input(z.object({
|
|
openId: z.string().min(1),
|
|
name: z.string().optional(),
|
|
email: z.string().email().optional(),
|
|
username: z.string().min(3).optional(),
|
|
password: z.string().min(6).optional(),
|
|
role: z.enum(["user", "admin"]).default("user"),
|
|
isActive: z.boolean().default(true),
|
|
})).mutation(async ({ input }) => {
|
|
await db.createUser(input);
|
|
return { success: true };
|
|
}),
|
|
|
|
update: adminProcedure.input(z.object({
|
|
id: z.number(),
|
|
name: z.string().optional(),
|
|
email: z.string().email().optional(),
|
|
username: z.string().min(3).optional(),
|
|
password: z.string().min(6).optional(),
|
|
role: z.enum(["user", "admin"]).optional(),
|
|
isActive: z.boolean().optional(),
|
|
})).mutation(async ({ input }) => {
|
|
const { id, ...data } = input;
|
|
await db.updateUser(id, data);
|
|
return { success: true };
|
|
}),
|
|
|
|
toggleStatus: adminProcedure.input(z.object({
|
|
id: z.number(),
|
|
isActive: z.boolean(),
|
|
})).mutation(async ({ input }) => {
|
|
await db.toggleUserStatus(input.id, input.isActive);
|
|
return { success: true };
|
|
}),
|
|
|
|
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
|
await db.deleteUser(input.id);
|
|
return { success: true };
|
|
}),
|
|
|
|
requestPasswordReset: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
|
const user = await db.getUserById(input.id);
|
|
if (!user) {
|
|
throw new Error("Utilisateur introuvable");
|
|
}
|
|
|
|
if (!user.email) {
|
|
throw new Error("Cet utilisateur n'a pas d'adresse email");
|
|
}
|
|
|
|
// Générer un token unique
|
|
const crypto = await import('crypto');
|
|
const token = crypto.randomBytes(32).toString('hex');
|
|
|
|
// Définir l'expiration à 24h
|
|
const expiresAt = new Date();
|
|
expiresAt.setHours(expiresAt.getHours() + 24);
|
|
|
|
// Sauvegarder le token en base
|
|
await db.createPasswordResetToken(user.id, token, expiresAt);
|
|
|
|
// Générer le lien de réinitialisation
|
|
// TODO: Remplacer par l'URL réelle de votre application en production
|
|
const resetLink = `https://votre-domaine.com/reset-password?token=${token}`;
|
|
|
|
// Envoyer l'email
|
|
const emailService = await import('./emailService');
|
|
const emailSent = await emailService.sendPasswordResetEmail({
|
|
apprenantEmail: user.email,
|
|
apprenantNom: user.name || 'Utilisateur',
|
|
apprenantPrenom: '',
|
|
resetLink,
|
|
});
|
|
|
|
if (!emailSent) {
|
|
throw new Error("Échec de l'envoi de l'email");
|
|
}
|
|
|
|
return { success: true, message: "Email de réinitialisation envoyé" };
|
|
}),
|
|
}),
|
|
|
|
// ===== GESTION DES TEMPLATES D'EMAILS =====
|
|
emailTemplates: router({
|
|
list: adminProcedure.query(async () => {
|
|
return db.getAllEmailTemplates();
|
|
}),
|
|
|
|
getByType: adminProcedure
|
|
.input(z.object({ type: z.string() }))
|
|
.query(async ({ input }) => {
|
|
return db.getEmailTemplateByType(input.type);
|
|
}),
|
|
|
|
upsert: adminProcedure
|
|
.input(z.object({
|
|
type: z.string(),
|
|
name: z.string(),
|
|
logoUrl: z.string().nullable(),
|
|
primaryColor: z.string(),
|
|
headerBgColor: z.string(),
|
|
headerTextColor: z.string(),
|
|
headerTitle: z.string(),
|
|
bodyContent: z.string().nullable(),
|
|
footerText: z.string().nullable(),
|
|
active: z.boolean(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
return db.upsertEmailTemplate(input);
|
|
}),
|
|
|
|
delete: adminProcedure
|
|
.input(z.object({ type: z.string() }))
|
|
.mutation(async ({ input }) => {
|
|
await db.deleteEmailTemplate(input.type);
|
|
return { success: true };
|
|
}),
|
|
|
|
initializeDefaults: adminProcedure.mutation(async () => {
|
|
await db.initializeDefaultEmailTemplates();
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ===== GESTION DES RAPPELS =====
|
|
rappels: router({
|
|
list: adminProcedure.query(async () => {
|
|
return db.getRappels();
|
|
}),
|
|
|
|
create: adminProcedure
|
|
.input(z.object({
|
|
nom: z.string(),
|
|
templateType: z.string(),
|
|
joursAvant: z.number(),
|
|
heureEnvoi: z.string().default("09:00"),
|
|
actif: z.boolean(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
return db.createRappel(input);
|
|
}),
|
|
|
|
update: adminProcedure
|
|
.input(z.object({
|
|
id: z.number(),
|
|
nom: z.string().optional(),
|
|
templateType: z.string().optional(),
|
|
joursAvant: z.number().optional(),
|
|
heureEnvoi: z.string().optional(),
|
|
actif: z.boolean().optional(),
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const { id, ...data } = input;
|
|
await db.updateRappel(id, data);
|
|
return { success: true };
|
|
}),
|
|
|
|
delete: adminProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
await db.deleteRappel(input.id);
|
|
return { success: true };
|
|
}),
|
|
|
|
toggleActif: adminProcedure
|
|
.input(z.object({ id: z.number(), actif: z.boolean() }))
|
|
.mutation(async ({ input }) => {
|
|
await db.updateRappel(input.id, { actif: input.actif });
|
|
return { success: true };
|
|
}),
|
|
|
|
envoyerAutomatiques: publicProcedure
|
|
.mutation(async () => {
|
|
const { processRappelsAutomatiques } = await import('./rappelScheduler');
|
|
const result = await processRappelsAutomatiques();
|
|
return result;
|
|
}),
|
|
}),
|
|
|
|
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);
|
|
}),
|
|
}),
|
|
});
|
|
|
|
export type AppRouter = typeof appRouter;
|