✅ **Nouvelles fonctionnalités implémentées** :
**Backend complet** :
- Fichier questionnaireSuiviDb.ts avec 4 fonctions de statistiques
- getStatsByFormation() : Taux de réponse par formation
- getEvolutionTemporelle() : Évolution mensuelle des envois/réponses
- getStatsByFormateur() : Comparaison par formateur avec satisfaction moyenne
- getStatsGlobales() : Statistiques globales (questionnaires, envois, réponses, taux)
- Procédures tRPC complètes dans routers.ts
**Interface utilisateur** :
- Page AdminQuestionnaireSuivi avec graphiques interactifs (Recharts)
- 4 cartes de statistiques globales (questionnaires, envois, réponses, taux)
- Graphique en barres : Taux de réponse par formation
- Graphique en ligne : Évolution temporelle mensuelle
- Graphique camembert : Répartition des réponses par formation
- Graphique double axe : Comparaison formateurs (taux + satisfaction)
- Tableau détaillé des formateurs avec code couleur (vert >70%, orange >50%, rouge <50%)
- Lien "Suivi Questionnaires" ajouté dans le menu Analyses
Le tableau de bord offre une vue complète des performances des questionnaires pour optimiser le suivi et l'amélioration continue.
1286 lines
44 KiB
TypeScript
1286 lines
44 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();
|
|
}),
|
|
}),
|
|
|
|
// ===== É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;
|