import { z } from "zod"; import { protectedProcedure, router } from "../_core/trpc"; import { TRPCError } from "@trpc/server"; import { getDb } from "../db"; import { planFormation, planFormationItem, catalogueFormation } from "../../drizzle/schema"; import { eq, and } from "drizzle-orm"; // ─── Helpers ──────────────────────────────────────────────────────────────── function isAdmin(role: string) { return role === "admin"; } // ─── Router Plans de formation ─────────────────────────────────────────────── export const planFormationRouter = router({ /** Lister les plans de formation * - Admin : voit tous les plans * - User : voit uniquement les plans de son établissement (via codeEtablissement) */ list: protectedProcedure .input( z.object({ codeEtablissement: z.string().optional(), annee: z.number().optional(), statut: z.enum(["brouillon", "soumis", "valide", "rejete"]).optional(), }).optional() ) .query(async ({ ctx, input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" }); let rows = await db.select().from(planFormation); if (input?.codeEtablissement) { rows = rows.filter(r => r.codeEtablissement === input.codeEtablissement); } if (input?.annee) { rows = rows.filter(r => r.annee === input.annee); } if (input?.statut) { rows = rows.filter(r => r.statut === input.statut); } return rows.sort((a, b) => b.annee - a.annee); }), /** Obtenir un plan par ID avec ses items */ getById: protectedProcedure .input(z.object({ id: z.number() })) .query(async ({ input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" }); const plans = await db.select().from(planFormation).where(eq(planFormation.id, input.id)); if (!plans[0]) throw new TRPCError({ code: "NOT_FOUND", message: "Plan introuvable" }); const items = await db.select().from(planFormationItem).where(eq(planFormationItem.planId, input.id)); // Récupérer les formations du catalogue associées const catalogueIds = items .map(i => i.catalogueFormationId) .filter((id): id is number => id !== null && id !== undefined); let catalogueItems: typeof catalogueFormation.$inferSelect[] = []; if (catalogueIds.length > 0) { catalogueItems = await db.select().from(catalogueFormation); catalogueItems = catalogueItems.filter(c => catalogueIds.includes(c.id)); } const itemsWithCatalogue = items.map(item => ({ ...item, catalogueFormation: item.catalogueFormationId ? catalogueItems.find(c => c.id === item.catalogueFormationId) ?? null : null, })); return { ...plans[0], items: itemsWithCatalogue }; }), /** Créer un plan de formation */ create: protectedProcedure .input( z.object({ codeEtablissement: z.string().min(1), nomEtablissement: z.string().optional(), annee: z.number().min(2020).max(2100), notes: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" }); await db.insert(planFormation).values({ codeEtablissement: input.codeEtablissement, nomEtablissement: input.nomEtablissement ?? null, annee: input.annee, statut: "brouillon", notes: input.notes ?? null, creePar: ctx.user.id, }); return { success: true }; }), /** Mettre à jour un plan de formation */ update: protectedProcedure .input( z.object({ id: z.number(), codeEtablissement: z.string().optional(), nomEtablissement: z.string().optional(), annee: z.number().optional(), notes: z.string().optional(), notesValidation: z.string().optional(), statut: z.enum(["brouillon", "soumis", "valide", "rejete"]).optional(), }) ) .mutation(async ({ ctx, input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" }); const { id, ...rest } = input; const updateData: Record = {}; if (rest.codeEtablissement !== undefined) updateData.codeEtablissement = rest.codeEtablissement; if (rest.nomEtablissement !== undefined) updateData.nomEtablissement = rest.nomEtablissement; if (rest.annee !== undefined) updateData.annee = rest.annee; if (rest.notes !== undefined) updateData.notes = rest.notes; if (rest.notesValidation !== undefined) updateData.notesValidation = rest.notesValidation; if (rest.statut !== undefined) { updateData.statut = rest.statut; if (rest.statut === "soumis") { updateData.dateSoumission = new Date(); } if ((rest.statut === "valide" || rest.statut === "rejete") && isAdmin(ctx.user.role)) { updateData.dateValidation = new Date(); updateData.validePar = ctx.user.id; } } await db.update(planFormation).set(updateData).where(eq(planFormation.id, id)); return { success: true }; }), /** Soumettre un plan pour validation */ soumettre: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" }); await db.update(planFormation).set({ statut: "soumis", dateSoumission: new Date(), }).where(eq(planFormation.id, input.id)); return { success: true }; }), /** Valider ou rejeter un plan (admin uniquement) */ valider: protectedProcedure .input( z.object({ id: z.number(), decision: z.enum(["valide", "rejete"]), notesValidation: z.string().optional(), }) ) .mutation(async ({ ctx, input }) => { if (!isAdmin(ctx.user.role)) { throw new TRPCError({ code: "FORBIDDEN", message: "Réservé aux administrateurs" }); } const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" }); await db.update(planFormation).set({ statut: input.decision, notesValidation: input.notesValidation ?? null, dateValidation: new Date(), validePar: ctx.user.id, }).where(eq(planFormation.id, input.id)); return { success: true }; }), /** Supprimer un plan (admin ou créateur si brouillon) */ delete: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ ctx, input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" }); const plans = await db.select().from(planFormation).where(eq(planFormation.id, input.id)); if (!plans[0]) throw new TRPCError({ code: "NOT_FOUND", message: "Plan introuvable" }); const plan = plans[0]; if (!isAdmin(ctx.user.role) && (plan.statut !== "brouillon" || plan.creePar !== ctx.user.id)) { throw new TRPCError({ code: "FORBIDDEN", message: "Vous ne pouvez supprimer que vos propres brouillons" }); } // Supprimer les items d'abord await db.delete(planFormationItem).where(eq(planFormationItem.planId, input.id)); await db.delete(planFormation).where(eq(planFormation.id, input.id)); return { success: true }; }), // ─── Items du plan ──────────────────────────────────────────────────────── /** Ajouter un item au plan */ addItem: protectedProcedure .input( z.object({ planId: z.number(), intitule: z.string().min(1), duree: z.string().optional(), prestataire: z.string().optional(), publicConcerne: z.string().optional(), nbPersonnes: z.number().optional(), description: z.string().optional(), budgetEstime: z.string().optional(), priorite: z.number().min(1).max(3).optional(), catalogueFormationId: z.number().optional(), }) ) .mutation(async ({ input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" }); await db.insert(planFormationItem).values({ planId: input.planId, intitule: input.intitule, duree: input.duree ?? null, prestataire: input.prestataire ?? null, publicConcerne: input.publicConcerne ?? null, nbPersonnes: input.nbPersonnes ?? null, description: input.description ?? null, statut: "en_attente", budgetEstime: input.budgetEstime ?? null, priorite: input.priorite ?? 2, catalogueFormationId: input.catalogueFormationId ?? null, }); return { success: true }; }), /** Mettre à jour un item */ updateItem: protectedProcedure .input( z.object({ id: z.number(), intitule: z.string().optional(), duree: z.string().optional(), prestataire: z.string().optional(), publicConcerne: z.string().optional(), nbPersonnes: z.number().optional(), description: z.string().optional(), statut: z.enum(["en_attente", "valide", "refuse", "suggere"]).optional(), budgetEstime: z.string().optional(), priorite: z.number().min(1).max(3).optional(), catalogueFormationId: z.number().nullable().optional(), }) ) .mutation(async ({ input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" }); const { id, ...rest } = input; const updateData: Record = {}; if (rest.intitule !== undefined) updateData.intitule = rest.intitule; if (rest.duree !== undefined) updateData.duree = rest.duree; if (rest.prestataire !== undefined) updateData.prestataire = rest.prestataire; if (rest.publicConcerne !== undefined) updateData.publicConcerne = rest.publicConcerne; if (rest.nbPersonnes !== undefined) updateData.nbPersonnes = rest.nbPersonnes; if (rest.description !== undefined) updateData.description = rest.description; if (rest.statut !== undefined) updateData.statut = rest.statut; if (rest.budgetEstime !== undefined) updateData.budgetEstime = rest.budgetEstime; if (rest.priorite !== undefined) updateData.priorite = rest.priorite; if (rest.catalogueFormationId !== undefined) updateData.catalogueFormationId = rest.catalogueFormationId; await db.update(planFormationItem).set(updateData).where(eq(planFormationItem.id, id)); return { success: true }; }), /** Supprimer un item */ deleteItem: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" }); await db.delete(planFormationItem).where(eq(planFormationItem.id, input.id)); return { success: true }; }), /** Lister les items d'un plan */ listItems: protectedProcedure .input(z.object({ planId: z.number() })) .query(async ({ input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" }); const items = await db.select().from(planFormationItem).where(eq(planFormationItem.planId, input.planId)); return items; }), });