- Schéma DB : 3 nouvelles tables (catalogueFormation, planFormation, planFormationItem) - Backend tRPC : routers catalogue et planFormation (CRUD, import bulk CSV, suggestions, toggle validée Itinova) - Frontend : page Plans de formation (liste par établissement/année, CRUD, soumission/validation) - Frontend : page Détail plan (items, liaison catalogue, suggestions automatiques) - Frontend : page Catalogue de formation (liste, filtres thème/Itinova/OPCO, import CSV, badges) - Menu sidebar : nouvelle section "Plans de formations" avec sous-menus - Tests unitaires : 13 tests (scoring suggestions, validation données, parsing CSV)
309 lines
12 KiB
TypeScript
309 lines
12 KiB
TypeScript
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<string, unknown> = {};
|
|
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();
|
|
}),
|
|
});
|