184 lines
9.9 KiB
TypeScript
184 lines
9.9 KiB
TypeScript
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<string, unknown> = { ...rest };
|
|
if (password) data.passwordHash = await bcrypt.hash(password, 10);
|
|
await db.updateUser(id, data as Parameters<typeof db.updateUser>[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<typeof db.upsertOpexPoste>[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<typeof db.setOpexMontantEtab>[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;
|