440 lines
16 KiB
TypeScript
440 lines
16 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.getAllFormations();
|
|
}),
|
|
|
|
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.getAllApprenants();
|
|
}),
|
|
|
|
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 };
|
|
}),
|
|
}),
|
|
|
|
// ===== SESSIONS =====
|
|
sessions: router({
|
|
list: adminProcedure.query(async () => {
|
|
return db.getAllSessions();
|
|
}),
|
|
|
|
listByFormation: publicProcedure.input(z.object({ formationId: z.number() })).query(async ({ input }) => {
|
|
return db.getSessionsByFormation(input.formationId);
|
|
}),
|
|
|
|
listAvecInscriptions: adminProcedure.input(z.object({ formationId: z.number() })).query(async ({ input }) => {
|
|
return db.getSessionsAvecInscriptions(input.formationId);
|
|
}),
|
|
|
|
getById: publicProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => {
|
|
return db.getSessionById(input.id);
|
|
}),
|
|
|
|
create: adminProcedure.input(z.object({
|
|
formationId: z.number(),
|
|
nom: z.string().min(1),
|
|
dateDebut: z.string(),
|
|
dateFin: z.string(),
|
|
lieu: z.string().min(1),
|
|
capaciteMax: z.number().default(12),
|
|
dateBlocage: z.string(),
|
|
statut: z.enum(["ouverte", "bloquee", "terminee"]).optional(),
|
|
})).mutation(async ({ input }) => {
|
|
await db.createSession({
|
|
...input,
|
|
dateDebut: new Date(input.dateDebut),
|
|
dateFin: new Date(input.dateFin),
|
|
dateBlocage: new Date(input.dateBlocage),
|
|
});
|
|
return { success: true };
|
|
}),
|
|
|
|
update: adminProcedure.input(z.object({
|
|
id: z.number(),
|
|
formationId: z.number().optional(),
|
|
nom: z.string().min(1).optional(),
|
|
dateDebut: z.string().optional(),
|
|
dateFin: z.string().optional(),
|
|
lieu: z.string().min(1).optional(),
|
|
capaciteMax: z.number().optional(),
|
|
dateBlocage: z.string().optional(),
|
|
statut: z.enum(["ouverte", "bloquee", "terminee"]).optional(),
|
|
})).mutation(async ({ input }) => {
|
|
const { id, ...data } = input;
|
|
const updateData: any = { ...data };
|
|
|
|
if (data.dateDebut) updateData.dateDebut = new Date(data.dateDebut);
|
|
if (data.dateFin) updateData.dateFin = new Date(data.dateFin);
|
|
if (data.dateBlocage) updateData.dateBlocage = new Date(data.dateBlocage);
|
|
|
|
await db.updateSession(id, updateData);
|
|
return { success: true };
|
|
}),
|
|
|
|
delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
|
await db.deleteSession(input.id);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ===== INSCRIPTIONS =====
|
|
inscriptions: router({
|
|
listBySession: adminProcedure.input(z.object({ sessionId: z.number() })).query(async ({ input }) => {
|
|
return db.getInscriptionsBySession(input.sessionId);
|
|
}),
|
|
|
|
listByApprenant: publicProcedure.input(z.object({ apprenantId: z.number() })).query(async ({ input }) => {
|
|
return db.getInscriptionsByApprenant(input.apprenantId);
|
|
}),
|
|
|
|
checkExisting: publicProcedure.input(z.object({
|
|
apprenantId: z.number(),
|
|
sessionId: z.number(),
|
|
})).query(async ({ input }) => {
|
|
return db.getInscriptionByApprenantAndSession(input.apprenantId, input.sessionId);
|
|
}),
|
|
|
|
inscrire: publicProcedure.input(z.object({
|
|
apprenantId: z.number(),
|
|
sessionId: z.number(),
|
|
})).mutation(async ({ input }) => {
|
|
// Vérifier si la session existe
|
|
const session = await db.getSessionById(input.sessionId);
|
|
if (!session) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' });
|
|
}
|
|
|
|
// Vérifier si la session est bloquée
|
|
const now = new Date();
|
|
if (session.statut === 'bloquee' || now >= new Date(session.dateBlocage)) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'Les inscriptions sont fermées pour cette session (J-15 dépassé)'
|
|
});
|
|
}
|
|
|
|
// Vérifier si l'apprenant existe déjà une inscription
|
|
const existing = await db.getInscriptionByApprenantAndSession(input.apprenantId, input.sessionId);
|
|
if (existing) {
|
|
throw new TRPCError({
|
|
code: 'CONFLICT',
|
|
message: 'Vous êtes déjà inscrit à cette session'
|
|
});
|
|
}
|
|
|
|
// Vérifier la capacité
|
|
const nbInscrits = await db.countInscriptionsConfirmees(input.sessionId);
|
|
const statut = nbInscrits >= session.capaciteMax ? 'liste_attente' : 'confirmee';
|
|
|
|
await db.createInscription({
|
|
apprenantId: input.apprenantId,
|
|
sessionId: input.sessionId,
|
|
statut,
|
|
});
|
|
|
|
// Envoyer l'email de confirmation avec invitation Outlook
|
|
const inscriptionSession = await db.getSessionById(input.sessionId);
|
|
const inscriptionApprenant = await db.getApprenantById(input.apprenantId);
|
|
const inscriptionFormation = inscriptionSession ? await db.getFormationById(inscriptionSession.formationId) : null;
|
|
|
|
if (inscriptionSession && inscriptionApprenant && inscriptionFormation) {
|
|
await sendInscriptionConfirmation({
|
|
apprenantEmail: inscriptionApprenant.email,
|
|
apprenantNom: inscriptionApprenant.nom,
|
|
apprenantPrenom: inscriptionApprenant.prenom,
|
|
apprenantFonction: inscriptionApprenant.fonction,
|
|
formationNom: inscriptionFormation.nom,
|
|
sessionNom: inscriptionSession.nom,
|
|
dateDebut: new Date(inscriptionSession.dateDebut),
|
|
dateFin: new Date(inscriptionSession.dateFin),
|
|
lieu: inscriptionSession.lieu,
|
|
statut,
|
|
});
|
|
}
|
|
|
|
return { success: true, statut };
|
|
}),
|
|
|
|
desinscrire: publicProcedure.input(z.object({
|
|
apprenantId: z.number(),
|
|
sessionId: z.number(),
|
|
})).mutation(async ({ input }) => {
|
|
// Vérifier si la session existe
|
|
const session = await db.getSessionById(input.sessionId);
|
|
if (!session) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' });
|
|
}
|
|
|
|
// Vérifier si la session est bloquée
|
|
const now = new Date();
|
|
if (session.statut === 'bloquee' || now >= new Date(session.dateBlocage)) {
|
|
throw new TRPCError({
|
|
code: 'FORBIDDEN',
|
|
message: 'Les désinscriptions sont fermées pour cette session (J-15 dépassé)'
|
|
});
|
|
}
|
|
|
|
// Trouver l'inscription
|
|
const inscription = await db.getInscriptionByApprenantAndSession(input.apprenantId, input.sessionId);
|
|
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 };
|
|
}),
|
|
|
|
sendGroupEmails: adminProcedure.input(z.object({
|
|
sessionId: z.number(),
|
|
type: z.enum(['teaser', 'rappel']),
|
|
})).mutation(async ({ input }) => {
|
|
const session = await db.getSessionById(input.sessionId);
|
|
if (!session) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' });
|
|
}
|
|
|
|
const formation = await db.getFormationById(session.formationId);
|
|
if (!formation) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
|
}
|
|
|
|
const inscriptions = await db.getInscriptionsBySession(input.sessionId);
|
|
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,
|
|
sessionNom: session.nom,
|
|
type: input.type,
|
|
dateDebut: new Date(session.dateDebut),
|
|
lieu: session.lieu,
|
|
});
|
|
|
|
return result;
|
|
}),
|
|
|
|
exportExcel: adminProcedure.input(z.object({ sessionId: z.number() })).mutation(async ({ input }) => {
|
|
const session = await db.getSessionById(input.sessionId);
|
|
if (!session) throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' });
|
|
|
|
const formation = await db.getFormationById(session.formationId);
|
|
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
|
|
|
const inscriptions = await db.getInscriptionsBySession(input.sessionId);
|
|
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: new Date(i.inscription.dateInscription),
|
|
}));
|
|
|
|
const buffer = generateExcelExport(
|
|
{
|
|
formationNom: formation.nom,
|
|
sessionNom: session.nom,
|
|
dateDebut: new Date(session.dateDebut),
|
|
dateFin: new Date(session.dateFin),
|
|
lieu: session.lieu,
|
|
},
|
|
data
|
|
);
|
|
|
|
return { data: buffer.toString('base64') };
|
|
}),
|
|
|
|
exportPDF: adminProcedure.input(z.object({ sessionId: z.number() })).mutation(async ({ input }) => {
|
|
const session = await db.getSessionById(input.sessionId);
|
|
if (!session) throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' });
|
|
|
|
const formation = await db.getFormationById(session.formationId);
|
|
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
|
|
|
const inscriptions = await db.getInscriptionsBySession(input.sessionId);
|
|
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: new Date(i.inscription.dateInscription),
|
|
}));
|
|
|
|
const buffer = generatePDFExport(
|
|
{
|
|
formationNom: formation.nom,
|
|
sessionNom: session.nom,
|
|
dateDebut: new Date(session.dateDebut),
|
|
dateFin: new Date(session.dateFin),
|
|
lieu: session.lieu,
|
|
},
|
|
data
|
|
);
|
|
|
|
return { data: buffer.toString('base64') };
|
|
}),
|
|
|
|
exportFeuillePresence: adminProcedure.input(z.object({ sessionId: z.number() })).mutation(async ({ input }) => {
|
|
const session = await db.getSessionById(input.sessionId);
|
|
if (!session) throw new TRPCError({ code: 'NOT_FOUND', message: 'Session introuvable' });
|
|
|
|
const formation = await db.getFormationById(session.formationId);
|
|
if (!formation) throw new TRPCError({ code: 'NOT_FOUND', message: 'Formation introuvable' });
|
|
|
|
const inscriptions = await db.getInscriptionsBySession(input.sessionId);
|
|
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: new Date(i.inscription.dateInscription),
|
|
}));
|
|
|
|
const buffer = generateFeuillePresence(
|
|
{
|
|
formationNom: formation.nom,
|
|
sessionNom: session.nom,
|
|
dateDebut: new Date(session.dateDebut),
|
|
dateFin: new Date(session.dateFin),
|
|
lieu: session.lieu,
|
|
},
|
|
data
|
|
);
|
|
|
|
return { data: buffer.toString('base64') };
|
|
}),
|
|
}),
|
|
});
|
|
|
|
export type AppRouter = typeof appRouter;
|