Checkpoint: Application complète de gestion des formations Manager Itinova avec toutes les fonctionnalités : gestion des formations/sessions/apprenants, système d'inscription avec validations, génération d'invitations Outlook, emails automatiques, exports PDF/Excel, et documentation complète.
This commit is contained in:
@@ -1,10 +1,22 @@
|
||||
import { COOKIE_NAME } from "@shared/const";
|
||||
import { getSessionCookieOptions } from "./_core/cookies";
|
||||
import { systemRouter } from "./_core/systemRouter";
|
||||
import { publicProcedure, router } from "./_core/trpc";
|
||||
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({
|
||||
// if you need to use socket.io, read and register route in server/_core/index.ts, all api should start with '/api/' so that the gateway can route correctly
|
||||
system: systemRouter,
|
||||
auth: router({
|
||||
me: publicProcedure.query(opts => opts.ctx.user),
|
||||
@@ -17,12 +29,403 @@ export const appRouter = router({
|
||||
}),
|
||||
}),
|
||||
|
||||
// TODO: add feature routers here, e.g.
|
||||
// todo: router({
|
||||
// list: protectedProcedure.query(({ ctx }) =>
|
||||
// db.getUserTodos(ctx.user.id)
|
||||
// ),
|
||||
// }),
|
||||
// ===== 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),
|
||||
})).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(),
|
||||
})).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,
|
||||
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,
|
||||
}));
|
||||
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user