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"; 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 }); }); 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 }; }), 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 }; }), 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 }; }), }), 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 }; }), 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;