import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const"; import { TRPCError } from "@trpc/server"; import bcrypt from "bcryptjs"; import { z } from "zod"; import * as db from "./db"; import { getSessionCookieOptions } from "./_core/cookies"; import { sdk } from "./_core/sdk"; import { systemRouter } from "./_core/systemRouter"; import { adminProcedure, protectedProcedure, publicProcedure, router } from "./_core/trpc"; import { isOpexEtablissementCode, normalizeOpexEtablissementCode } from "../shared/opexValidation"; const writeProcedure = protectedProcedure.use(({ ctx, next }) => { if (ctx.user.role === "readonly") { throw new TRPCError({ code: "FORBIDDEN", message: "Accès en lecture seule" }); } return next({ ctx }); }); /** Normalise et valide la clé métier d'une base de répartition OPEX. */ const opexEtablissementCodeSchema = z .string() .transform(normalizeOpexEtablissementCode) .refine(isOpexEtablissementCode, { message: "Code établissement OPEX invalide" }); export const appRouter = router({ system: systemRouter, auth: router({ login: publicProcedure .input(z.object({ login: z.string().min(1), password: z.string().min(1) })) .mutation(async ({ input, ctx }) => { const user = await db.getUserByLogin(input.login); if (!user || !user.isActive) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Identifiants invalides" }); } const valid = await bcrypt.compare(input.password, user.passwordHash); if (!valid) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Identifiants invalides" }); } await db.updateLastSignedIn(user.id); const token = await sdk.createSessionToken(user.id, user.login, user.role); const cookieOptions = getSessionCookieOptions(ctx.req); ctx.res.cookie(COOKIE_NAME, token, { ...cookieOptions, maxAge: ONE_YEAR_MS }); return { id: user.id, login: user.login, email: user.email, firstName: user.firstName, lastName: user.lastName, role: user.role }; }), me: publicProcedure.query((opts) => { const u = opts.ctx.user; if (!u) return null; return { id: u.id, login: u.login, email: u.email, firstName: u.firstName, lastName: u.lastName, role: u.role, isActive: u.isActive }; }), logout: publicProcedure.mutation(({ ctx }) => { const cookieOptions = getSessionCookieOptions(ctx.req); ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 }); return { success: true } as const; }), }), users: router({ list: adminProcedure.query(async () => { const list = await db.listUsers(); return list.map((u) => ({ id: u.id, login: u.login, email: u.email, firstName: u.firstName, lastName: u.lastName, role: u.role, isActive: u.isActive, createdAt: u.createdAt, lastSignedIn: u.lastSignedIn })); }), create: adminProcedure .input(z.object({ login: z.string().min(1), password: z.string().min(6), email: z.string().email().optional().nullable(), firstName: z.string().optional().nullable(), lastName: z.string().optional().nullable(), role: z.enum(["admin", "standard", "readonly"]).default("standard") })) .mutation(async ({ input }) => { const existing = await db.getUserByLogin(input.login); if (existing) throw new TRPCError({ code: "CONFLICT", message: "Ce login existe déjà" }); const passwordHash = await bcrypt.hash(input.password, 10); await db.createUser({ login: input.login, passwordHash, email: input.email ?? null, firstName: input.firstName ?? null, lastName: input.lastName ?? null, role: input.role, isActive: true }); return { success: true }; }), update: adminProcedure .input(z.object({ id: z.number(), email: z.string().email().optional().nullable(), firstName: z.string().optional().nullable(), lastName: z.string().optional().nullable(), role: z.enum(["admin", "standard", "readonly"]).optional(), isActive: z.boolean().optional(), password: z.string().min(6).optional() })) .mutation(async ({ input }) => { const { id, password, ...rest } = input; const data: Record = { ...rest }; if (password) data.passwordHash = await bcrypt.hash(password, 10); await db.updateUser(id, data as Parameters[1]); return { success: true }; }), delete: adminProcedure .input(z.object({ id: z.number() })) .mutation(async ({ input }) => { await db.deleteUser(input.id); return { success: true }; }), importBulk: adminProcedure .input(z.array(z.object({ login: z.string().min(1), password: z.string().min(6), email: z.string().email().optional().nullable(), firstName: z.string().optional().nullable(), lastName: z.string().optional().nullable(), role: z.enum(["admin", "standard", "readonly"]).default("standard") }))) .mutation(async ({ input }) => { let created = 0; let skipped = 0; for (const u of input) { const existing = await db.getUserByLogin(u.login); if (existing) { skipped++; continue; } const passwordHash = await bcrypt.hash(u.password, 10); await db.createUser({ login: u.login, passwordHash, email: u.email ?? null, firstName: u.firstName ?? null, lastName: u.lastName ?? null, role: u.role, isActive: true }); created++; } return { created, skipped }; }), }), etablissements: router({ list: protectedProcedure.query(async () => db.listEtablissements()), upsert: adminProcedure .input(z.object({ code: z.string().min(1), nom: z.string().min(1), groupe: z.string().optional().nullable(), ville: z.string().optional().nullable(), actif: z.boolean().default(true) })) .mutation(async ({ input }) => { await db.upsertEtablissement(input); return { success: true }; }), delete: adminProcedure .input(z.object({ code: z.string() })) .mutation(async ({ input }) => { await db.deleteEtablissement(input.code); return { success: true }; }), }), parametres: router({ get: protectedProcedure.query(async () => { const rows = await db.getParametres(); return Object.fromEntries(rows.map((r) => [r.cle, r.valeur])); }), set: adminProcedure .input(z.object({ cle: z.string(), valeur: z.string() })) .mutation(async ({ input }) => { await db.setParametre(input.cle, input.valeur); return { success: true }; }), setBulk: adminProcedure .input(z.record(z.string(), z.string())) .mutation(async ({ input }) => { for (const [cle, valeur] of Object.entries(input)) await db.setParametre(cle, valeur); return { success: true }; }), }), opex: router({ getPostes: protectedProcedure .input(z.object({ annee: z.number() })) .query(async ({ input }) => db.getOpexPostes(input.annee)), upsertPoste: writeProcedure .input(z.object({ id: z.number().optional(), annee: z.number(), colIdx: z.number(), libelle: z.string(), libelleCourt: z.string().optional().nullable(), libelleDetail: z.string().optional().nullable(), fournisseur: z.string().optional().nullable(), categorie: z.string().optional().nullable(), type: z.string().optional().nullable(), facturation: z.string().optional().nullable(), modeVentilation: z.string().optional().nullable(), compte: z.string().optional().nullable(), detail: z.string().optional().nullable(), budgetN1: z.string().optional().nullable(), montant: z.string().optional().nullable(), isCustom: z.boolean().optional() })) .mutation(async ({ input }) => { await db.upsertOpexPoste(input as Parameters[0]); return { success: true }; }), deletePoste: writeProcedure .input(z.object({ annee: z.number(), id: z.number().int().positive() })) .mutation(async ({ input }) => ({ success: await db.deleteOpexPoste(input.annee, input.id) })), getMontantsEtab: protectedProcedure .input(z.object({ annee: z.number() })) .query(async ({ input }) => db.getOpexMontantsEtab(input.annee)), setMontantEtab: writeProcedure .input(z.object({ annee: z.number(), etablissementCode: z.string(), libellePoste: z.string(), montant: z.string().nullable() })) .mutation(async ({ input }) => { await db.setOpexMontantEtab(input as Parameters[0]); return { success: true }; }), // Sauvegarder en masse les montants par établissement (utilisé lors du passage en mode tout manuel) batchSetMontantsEtab: writeProcedure .input(z.object({ annee: z.number(), etablissementCode: z.string(), montants: z.array(z.object({ libellePoste: z.string(), montant: z.string(), })) })) .mutation(async ({ input }) => { await db.setOpexMontantsEtabBatch(input.montants.map((m) => ({ annee: input.annee, etablissementCode: input.etablissementCode, libellePoste: m.libellePoste, montant: m.montant, }))); return { success: true, count: input.montants.length }; }), getValidated: protectedProcedure .input(z.object({ annee: z.number() })) .query(async ({ input }) => db.getOpexValidated(input.annee)), validate: adminProcedure .input(z.object({ annee: z.number() })) .mutation(async ({ input, ctx }) => { await db.setOpexValidated(input.annee, ctx.user.id); return { success: true }; }), // Initialiser depuis les données sources JSON (seed initial) initFromSource: writeProcedure .input(z.object({ annee: z.number(), postes: z.array(z.object({ colIdx: z.number(), libelle: z.string(), libelleCourt: z.string().optional().nullable(), libelleDetail: z.string().optional().nullable(), fournisseur: z.string().optional().nullable(), categorie: z.string().optional().nullable(), type: z.string().optional().nullable(), facturation: z.string().optional().nullable(), modeVentilation: z.string().optional().nullable(), compte: z.string().optional().nullable(), detail: z.string().optional().nullable(), budgetN1: z.string().optional().nullable(), montant: z.string().optional().nullable(), isCustom: z.boolean().optional(), })) })) .mutation(async ({ input }) => { await db.insertOpexPostes(input.postes.map(p => ({ ...p, annee: input.annee }))); return { success: true }; }), // Bases de répartition par établissement (charges classe 6) getBasesRepartition: protectedProcedure .input(z.object({ annee: z.number() })) .query(async ({ input }) => db.getOpexBasesRepartition(input.annee)), // Upsert une base de répartition pour un établissement (standard + HEP) setBaseRepartition: writeProcedure .input(z.object({ annee: z.number(), etablissementCode: opexEtablissementCodeSchema, etablissementNom: z.string().optional().nullable(), baseRepartition: z.number(), baseRepartitionHep: z.number().optional().default(0), modeManuel: z.boolean().optional().default(false), })) .mutation(async ({ input }) => { await db.upsertOpexBaseRepartition(input); return { success: true }; }), // Import en masse des bases de répartition (depuis fichier Excel/CSV) importBasesRepartition: writeProcedure .input(z.object({ annee: z.number(), rows: z.array(z.object({ etablissementCode: opexEtablissementCodeSchema, etablissementNom: z.string().optional().nullable(), baseRepartition: z.number(), baseRepartitionHep: z.number().optional().default(0), })), })) .mutation(async ({ input }) => { await db.importOpexBasesRepartition(input.annee, input.rows); return { success: true, count: input.rows.length }; }), // Supprimer toutes les lignes OPEX d'une année deleteAll: writeProcedure .input(z.object({ annee: z.number() })) .mutation(async ({ input }) => { await db.deleteOpexPostes(input.annee); await db.deleteOpexMontantsEtab(input.annee); return { success: true }; }), }), // Les données de rémunération sont sensibles : aucune procédure de ce // sous-routeur n'est accessible aux profils standard ou lecture seule. masseSalariale: router({ get: adminProcedure.query(async () => db.getMasseSalariale()), import: adminProcedure .input(z.object({ salaries: z.array(z.object({ matricule: z.string().trim().min(1).max(32), nom: z.string().trim().min(1).max(150), prenom: z.string().trim().min(1).max(150), poste: z.string().trim().min(1).max(255), dateEmbauche: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date d'embauche invalide"), })).min(1).max(500), remunerations: z.array(z.object({ matricule: z.string().trim().min(1).max(32), annee: z.number().int().min(2000).max(2100), salaireAnnuelBrutHorsPrimesCents: z.number().int().min(0).max(100_000_000), salaireAnnuelBrutAvecPrimesCents: z.number().int().min(0).max(100_000_000), salaireMensuelBrutHorsPrimesCents: z.number().int().min(0).max(10_000_000), tauxChargeBps: z.number().int().min(0).max(100_000), periodeReference: z.string().regex(/^\d{4}-\d{2}$/).nullable().optional(), statut: z.enum(["annuel", "cumul_provisoire"]), source: z.string().trim().min(1).max(64), })).max(1_500), })) .mutation(async ({ input }) => db.importMasseSalariale(input.salaries, input.remunerations)), setEvolution: adminProcedure .input(z.object({ salarieId: z.number().int().positive(), annee: z.number().int().min(2000).max(2100), // ±100 % maximum : protège contre les erreurs de virgule dans la saisie. evolutionSalarialeBps: z.number().int().min(-10_000).max(10_000), })) .mutation(async ({ input, ctx }) => { await db.setMasseSalarialeEvolution({ ...input, createdBy: ctx.user.id }); return { success: true }; }), }), inventaire: router({ get: protectedProcedure .input(z.object({ annee: z.number() })) .query(async ({ input }) => { const postes = await db.getInventaire(input.annee); const meta = await db.getInventaireMeta(input.annee); return { postes, meta }; }), // Retourne les stats calculées par établissement (pour useBudgetData) getStats: protectedProcedure .input(z.object({ annee: z.number(), seuilFixesAns: z.number().default(5), seuilPortablesAns: z.number().default(4), coutFixe: z.number().default(850), coutPortable: z.number().default(950), })) .query(async ({ input }) => { const postes = await db.getInventaire(input.annee); const meta = await db.getInventaireMeta(input.annee); if (!meta) return { meta: null, etablissements: [] }; // Charger les noms des établissements const etabList = await db.listEtablissements(); const etabNoms = new Map(etabList.map(e => [e.code, e.nom])); // Grouper par établissement const byEtab = new Map(); for (const p of postes) { if (!byEtab.has(p.etablissementCode)) byEtab.set(p.etablissementCode, { fixes: [], portables: [] }); const e = byEtab.get(p.etablissementCode)!; if (p.typePoste === 'fixe') e.fixes.push(p); else e.portables.push(p); } const etablissements = Array.from(byEtab.entries()).map(([code, { fixes, portables }]) => { const nbFixesRenouveler = fixes.filter(f => f.ageAns !== null && parseFloat(f.ageAns) >= input.seuilFixesAns).length; const nbPortablesRenouveler = portables.filter(p => p.ageAns !== null && parseFloat(p.ageAns) >= input.seuilPortablesAns).length; const nbFixesSansDate = fixes.filter(f => f.ageAns === null).length; const nbPortablesSansDate = portables.filter(p => p.ageAns === null).length; const budgetFixes = nbFixesRenouveler * input.coutFixe; const budgetPortables = nbPortablesRenouveler * input.coutPortable; return { code, nom: etabNoms.get(code) ?? code, fixes: fixes.map(f => ({ libelle: f.libelle ?? '', date_achat: f.dateRef ?? '', age_ans: f.ageAns !== null ? parseFloat(f.ageAns) : null, a_renouveler: f.ageAns !== null && parseFloat(f.ageAns) >= input.seuilFixesAns, modele: f.modele ?? '', fabricant: f.fabricant ?? '' })), portables: portables.map(p => ({ libelle: p.libelle ?? '', date_achat: p.dateRef ?? '', age_ans: p.ageAns !== null ? parseFloat(p.ageAns) : null, a_renouveler: p.ageAns !== null && parseFloat(p.ageAns) >= input.seuilPortablesAns, modele: p.modele ?? '', fabricant: p.fabricant ?? '' })), stats: { nb_fixes_total: fixes.length, nb_portables_total: portables.length, nb_fixes_renouveler: nbFixesRenouveler, nb_portables_renouveler: nbPortablesRenouveler, nb_fixes_sans_date: nbFixesSansDate, nb_portables_sans_date: nbPortablesSansDate, budget_fixes: budgetFixes, budget_portables: budgetPortables, budget_total: budgetFixes + budgetPortables, }, }; }); const totalFixesR = etablissements.reduce((s, e) => s + e.stats.nb_fixes_renouveler, 0); const totalPortablesR = etablissements.reduce((s, e) => s + e.stats.nb_portables_renouveler, 0); const totalFixes = etablissements.reduce((s, e) => s + e.stats.nb_fixes_total, 0); const totalPortables = etablissements.reduce((s, e) => s + e.stats.nb_portables_total, 0); const budgetGlobal = etablissements.reduce((s, e) => s + e.stats.budget_total, 0); return { meta: { annee: input.annee, dateImport: meta.dateImport, nb_etablissements: etablissements.length, total_fixes: totalFixes, total_portables: totalPortables, total_fixes_renouveler: totalFixesR, total_portables_renouveler: totalPortablesR, budget_global: budgetGlobal, seuil_fixes_ans: input.seuilFixesAns, seuil_portables_ans: input.seuilPortablesAns, cout_fixe: input.coutFixe, cout_portable: input.coutPortable, }, etablissements, }; }), listMeta: protectedProcedure .query(async () => { const rows = await db.listInventaireMeta(); return rows; }), import: writeProcedure .input(z.object({ annee: z.number(), filename: z.string(), postes: z.array(z.object({ etablissementCode: z.string(), libelle: z.string().optional().nullable(), typePoste: z.enum(["fixe", "portable"]), dateRef: z.string().optional().nullable(), ageAns: z.string().optional().nullable(), modele: z.string().optional().nullable(), fabricant: z.string().optional().nullable() })) })) .mutation(async ({ input }) => { const nbFixes = input.postes.filter((p) => p.typePoste === "fixe").length; const nbPortables = input.postes.filter((p) => p.typePoste === "portable").length; const etabSet = new Set(input.postes.map((p) => p.etablissementCode)); await db.importInventaire(input.annee, input.postes.map((p) => ({ ...p, annee: input.annee })), { filename: input.filename, nbEtablissements: etabSet.size, nbFixes, nbPortables }); return { success: true, nbFixes, nbPortables, nbEtablissements: etabSet.size }; }), }), capex: router({ get: protectedProcedure .input(z.object({ annee: z.number(), etablissementCode: z.string() })) .query(async ({ input }) => db.getCapexLignes(input.annee, input.etablissementCode)), save: writeProcedure .input(z.object({ annee: z.number(), etablissementCode: z.string(), lignes: z.array(z.object({ cle: z.string(), montant: z.string().nullable() })) })) .mutation(async ({ input }) => { await db.saveCapexLignes(input.annee, input.etablissementCode, input.lignes); return { success: true }; }), }), }); export type AppRouter = typeof appRouter;