import { z } from "zod"; import { protectedProcedure, router } from "../_core/trpc"; import { TRPCError } from "@trpc/server"; import { getDb } from "../db"; import { catalogueFormation } from "../../drizzle/schema"; import { eq, like, or, and } from "drizzle-orm"; // ─── Helpers ──────────────────────────────────────────────────────────────── function isAdmin(role: string) { return role === "admin"; } // ─── Router ───────────────────────────────────────────────────────────────── export const catalogueRouter = router({ /** Lister toutes les formations du catalogue (accessible à tous les utilisateurs connectés) */ list: protectedProcedure .input( z.object({ search: z.string().optional(), theme: z.string().optional(), validateeItinova: z.boolean().optional(), opcoEligible: z.boolean().optional(), }).optional() ) .query(async ({ input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" }); const rows = await db.select().from(catalogueFormation); let filtered = rows.filter(r => r.actif); if (input?.search) { const s = input.search.toLowerCase(); filtered = filtered.filter(r => r.intitule.toLowerCase().includes(s) || (r.theme ?? "").toLowerCase().includes(s) || (r.motsCles ?? "").toLowerCase().includes(s) || (r.prestataire ?? "").toLowerCase().includes(s) ); } if (input?.theme) { filtered = filtered.filter(r => r.theme === input.theme); } if (input?.validateeItinova !== undefined) { filtered = filtered.filter(r => r.validateeItinova === input.validateeItinova); } if (input?.opcoEligible !== undefined) { filtered = filtered.filter(r => r.opcoEligible === input.opcoEligible); } return filtered; }), /** Obtenir une formation par ID */ 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 rows = await db.select().from(catalogueFormation).where(eq(catalogueFormation.id, input.id)); if (!rows[0]) throw new TRPCError({ code: "NOT_FOUND", message: "Formation introuvable" }); return rows[0]; }), /** Créer une formation dans le catalogue (admin uniquement) */ create: protectedProcedure .input( z.object({ intitule: z.string().min(1), theme: z.string().optional(), motsCles: z.string().optional(), duree: z.string().optional(), prestataire: z.string().optional(), description: z.string().optional(), objectifs: z.string().optional(), publicConcerne: z.string().optional(), validateeItinova: z.boolean().optional(), opcoEligible: z.boolean().optional(), budgetOpco: z.string().optional(), referenceOpco: z.string().optional(), source: 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.insert(catalogueFormation).values({ intitule: input.intitule, theme: input.theme ?? null, motsCles: input.motsCles ?? null, duree: input.duree ?? null, prestataire: input.prestataire ?? null, description: input.description ?? null, objectifs: input.objectifs ?? null, publicConcerne: input.publicConcerne ?? null, validateeItinova: input.validateeItinova ?? false, opcoEligible: input.opcoEligible ?? false, budgetOpco: input.budgetOpco ? input.budgetOpco : null, referenceOpco: input.referenceOpco ?? null, source: input.source ?? "manuel", actif: true, }); return { success: true }; }), /** Mettre à jour une formation du catalogue (admin uniquement) */ update: protectedProcedure .input( z.object({ id: z.number(), intitule: z.string().min(1).optional(), theme: z.string().optional(), motsCles: z.string().optional(), duree: z.string().optional(), prestataire: z.string().optional(), description: z.string().optional(), objectifs: z.string().optional(), publicConcerne: z.string().optional(), validateeItinova: z.boolean().optional(), opcoEligible: z.boolean().optional(), budgetOpco: z.string().optional(), referenceOpco: z.string().optional(), actif: z.boolean().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" }); const { id, ...rest } = input; const updateData: Record = {}; if (rest.intitule !== undefined) updateData.intitule = rest.intitule; if (rest.theme !== undefined) updateData.theme = rest.theme; if (rest.motsCles !== undefined) updateData.motsCles = rest.motsCles; if (rest.duree !== undefined) updateData.duree = rest.duree; if (rest.prestataire !== undefined) updateData.prestataire = rest.prestataire; if (rest.description !== undefined) updateData.description = rest.description; if (rest.objectifs !== undefined) updateData.objectifs = rest.objectifs; if (rest.publicConcerne !== undefined) updateData.publicConcerne = rest.publicConcerne; if (rest.validateeItinova !== undefined) updateData.validateeItinova = rest.validateeItinova; if (rest.opcoEligible !== undefined) updateData.opcoEligible = rest.opcoEligible; if (rest.budgetOpco !== undefined) updateData.budgetOpco = rest.budgetOpco; if (rest.referenceOpco !== undefined) updateData.referenceOpco = rest.referenceOpco; if (rest.actif !== undefined) updateData.actif = rest.actif; await db.update(catalogueFormation).set(updateData).where(eq(catalogueFormation.id, id)); return { success: true }; }), /** Supprimer une formation du catalogue (admin uniquement) */ delete: protectedProcedure .input(z.object({ id: z.number() })) .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.delete(catalogueFormation).where(eq(catalogueFormation.id, input.id)); return { success: true }; }), /** Qualifier une formation comme "Validée Itinova" (admin uniquement) */ toggleValideeItinova: protectedProcedure .input(z.object({ id: z.number(), valeur: z.boolean() })) .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(catalogueFormation) .set({ validateeItinova: input.valeur }) .where(eq(catalogueFormation.id, input.id)); return { success: true }; }), /** Importer des formations depuis un CSV/Excel (admin uniquement) */ importBulk: protectedProcedure .input( z.object({ formations: z.array( z.object({ intitule: z.string().min(1), theme: z.string().optional(), motsCles: z.string().optional(), duree: z.string().optional(), prestataire: z.string().optional(), description: z.string().optional(), objectifs: z.string().optional(), publicConcerne: z.string().optional(), opcoEligible: z.boolean().optional(), budgetOpco: z.string().optional(), referenceOpco: z.string().optional(), }) ), source: 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" }); let inserted = 0; for (const f of input.formations) { await db.insert(catalogueFormation).values({ intitule: f.intitule, theme: f.theme ?? null, motsCles: f.motsCles ?? null, duree: f.duree ?? null, prestataire: f.prestataire ?? null, description: f.description ?? null, objectifs: f.objectifs ?? null, publicConcerne: f.publicConcerne ?? null, validateeItinova: false, opcoEligible: f.opcoEligible ?? false, budgetOpco: f.budgetOpco ?? null, referenceOpco: f.referenceOpco ?? null, source: input.source ?? "import", actif: true, }); inserted++; } return { success: true, inserted }; }), /** Suggestion automatique : trouver les formations correspondant à un intitulé/thème */ suggest: protectedProcedure .input( z.object({ intitule: z.string(), theme: z.string().optional(), motsCles: z.string().optional(), }) ) .query(async ({ input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" }); const allFormations = await db.select().from(catalogueFormation).where(eq(catalogueFormation.actif, true)); const searchTerms = [ input.intitule.toLowerCase(), ...(input.theme ? [input.theme.toLowerCase()] : []), ...(input.motsCles ? input.motsCles.toLowerCase().split(",").map(k => k.trim()) : []), ].filter(Boolean); function scoreFormation(f: typeof allFormations[0]): number { let score = 0; const haystack = [ f.intitule, f.theme ?? "", f.motsCles ?? "", f.description ?? "", ].join(" ").toLowerCase(); for (const term of searchTerms) { if (haystack.includes(term)) score += 2; // Correspondance partielle (au moins 4 caractères) if (term.length >= 4) { const words = haystack.split(/\s+/); for (const word of words) { if (word.startsWith(term.substring(0, 4))) score += 1; } } } // Bonus si validée Itinova if (f.validateeItinova) score += 5; return score; } const scored = allFormations .map(f => ({ ...f, _score: scoreFormation(f) })) .filter(f => f._score > 0) .sort((a, b) => b._score - a._score) .slice(0, 10); // Séparer validées Itinova des autres const validateesItinova = scored.filter(f => f.validateeItinova); const autres = scored.filter(f => !f.validateeItinova); return { validateesItinova, autres }; }), /** Lister les thèmes distincts du catalogue */ themes: protectedProcedure.query(async () => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB non disponible" }); const rows = await db.select({ theme: catalogueFormation.theme }).from(catalogueFormation); const themes = [...new Set(rows.map(r => r.theme).filter(Boolean))] as string[]; return themes.sort(); }), });