Files
formation-manager-itinova/server/routers.ts

526 lines
18 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 { z } from "zod";
import * as db from "./db";
import { TRPCError } from "@trpc/server";
import { sendInscriptionConfirmation, sendGroupEmail } from "./emailService";
import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService";
// 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 };
}),
}),
// ===== 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 };
}),
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 };
}),
}),
// ===== SÉQUENCES =====
sequences: router({
list: adminProcedure.query(async () => {
const seqs = await db.getSequences();
// Récupérer les dates pour chaque séquence
const sequencesAvecDates = await Promise.all(
seqs.map(async (seq) => {
const dates = await db.getDatesBySequence(seq.id);
return { ...seq, dates };
})
);
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),
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: new Date(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: new Date(date.dateDebut),
dateFin: new Date(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),
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;
// Mettre à jour la séquence
const updateData: any = {
...sequenceData,
dateBlocage: new Date(sequenceData.dateBlocage),
};
await db.updateSequence(id, updateData);
// Supprimer les anciennes dates
await db.deleteDatesBySequence(id);
// Créer les nouvelles dates
for (const date of dates) {
await db.createDateFormation({
sequenceId: id,
dateDebut: new Date(date.dateDebut),
dateFin: new Date(date.dateFin),
ordre: date.ordre,
});
}
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({
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']),
})).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,
}));
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,
});
return result;
}),
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,
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,
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);
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,
apprenants: data,
});
return {
buffer: buffer.toString('base64'),
filename: `feuille_presence_${sequence.nom.replace(/\s+/g, '_')}.pdf`,
};
}),
}),
});
export type AppRouter = typeof appRouter;