import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { assignEtablissementToUser, authenticateLocalUser, createDemandeContact, createBlocFonctionnel, updateBlocFonctionnel, deleteBlocFonctionnel, createEditeur, updateEditeur, deleteEditeur, createLocalUser, createSolution, updateSolution, deleteSolution, getStatistiques, deleteLogicielEtablissement, deleteUser, getAllDemandes, getAllEtablissements, getAllUsersWithAffectations, getAffectationsByUser, getBlocsFonctionnels, getConsultationCount, getConsultationsList, getDemandeById, getDemandesByDemandeur, getDemandesRecuesParEtablissement, getEditeurs, getEtablissementById, getEtablissementsByAdherent, getEtablissementsByReferent, getLogicielsByEtablissement, getMesSolutionsGroupees, getToutesLesSolutionsGroupees, getSolutions, recordConsultation, removeEtablissementFromUser, repondreDemandeContact, setAffectationsForUser, getUsersForEtablissement, setReferentForEtablissement, setAdherentsForEtablissement, updateLocalPassword, updateUser, updateUserCgu, updateUserSonumRole, upsertLogicielEtablissement, upsertUser, } from "./db"; import { COOKIE_NAME } from "@shared/const"; import { getSessionCookieOptions } from "./_core/cookies"; import { systemRouter } from "./_core/systemRouter"; import { protectedProcedure, publicProcedure, router } from "./_core/trpc"; import { notifyOwner } from "./_core/notification"; import { getDb } from "./db"; import { etablissements } from "../drizzle/schema"; import { eq } from "drizzle-orm"; import { sdk } from "./_core/sdk"; // ─── Middleware gestionnaire SONUM ──────────────────────────────────────────── const gestionnaireProcedure = protectedProcedure.use(({ ctx, next }) => { if (ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") { throw new TRPCError({ code: "FORBIDDEN", message: "Accès réservé aux gestionnaires SONUM" }); } return next({ ctx }); }); /** Bloque les mutations pour les utilisateurs en lecture seule (role === 'readonly') */ const writeProcedure = protectedProcedure.use(({ ctx, next }) => { if (ctx.user.role === "readonly") { throw new TRPCError({ code: "FORBIDDEN", message: "Votre compte est en lecture seule. Contactez un gestionnaire SONUM pour obtenir les droits de modification." }); } return next({ ctx }); }); // ─── Router principal ───────────────────────────────────────────────────────── export const appRouter = router({ system: systemRouter, // ─── Auth ────────────────────────────────────────────────────────────────── auth: router({ me: publicProcedure.query((opts) => opts.ctx.user), logout: publicProcedure.mutation(({ ctx }) => { const cookieOptions = getSessionCookieOptions(ctx.req); ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 }); return { success: true } as const; }), /** * Connexion locale par email + mot de passe. * Crée un cookie de session identique à celui de l'OAuth. */ loginLocal: publicProcedure .input(z.object({ // Accepte email ou login court email: z.string().min(1), password: z.string().min(1), })) .mutation(async ({ input, ctx }) => { const user = await authenticateLocalUser(input.email, input.password); if (!user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Identifiant ou mot de passe incorrect" }); } // Créer un token de session avec l'openId de l'utilisateur local // Le champ name doit être non vide pour passer la vérification JWT const displayName = user.name || [user.firstName, user.lastName].filter(Boolean).join(' ') || user.login || user.email || 'Utilisateur'; const sessionToken = await sdk.createSessionToken(user.openId!, { name: displayName, }); const cookieOptions = getSessionCookieOptions(ctx.req); ctx.res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: 1000 * 60 * 60 * 24 * 365, // 1 an }); return { success: true, user }; }), }), // ─── CGU ─────────────────────────────────────────────────────────────────── cgu: router({ accept: protectedProcedure.mutation(async ({ ctx }) => { await updateUserCgu(ctx.user.id); return { success: true }; }), status: protectedProcedure.query(({ ctx }) => ({ // La CGU doit être acceptée à chaque session (pas seulement la première fois) // On retourne toujours les données réelles mais le frontend gère la session accepted: ctx.user.cguAccepted, acceptedAt: ctx.user.cguAcceptedAt, userId: ctx.user.id, })), }), // ─── Référentiel ─────────────────────────────────────────────────────────── referentiel: router({ editeurs: publicProcedure.query(() => getEditeurs()), blocsFonctionnels: publicProcedure.query(() => getBlocsFonctionnels()), solutions: publicProcedure .input(z.object({ search: z.string().optional() })) .query(({ input }) => getSolutions(input.search)), createEditeur: protectedProcedure .input(z.object({ nom: z.string().min(1) })) .mutation(async ({ input, ctx }) => { const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin"; return createEditeur(input.nom, isGestionnaire); }), createBlocFonctionnel: protectedProcedure .input(z.object({ nom: z.string().min(1) })) .mutation(async ({ input, ctx }) => { const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin"; return createBlocFonctionnel(input.nom, isGestionnaire); }), updateBlocFonctionnel: gestionnaireProcedure .input(z.object({ id: z.number().int(), nom: z.string().min(1) })) .mutation(({ input }) => updateBlocFonctionnel(input.id, input.nom)), deleteBlocFonctionnel: gestionnaireProcedure .input(z.object({ id: z.number().int() })) .mutation(({ input }) => deleteBlocFonctionnel(input.id)), updateEditeur: gestionnaireProcedure .input(z.object({ id: z.number().int(), nom: z.string().min(1) })) .mutation(({ input }) => updateEditeur(input.id, input.nom)), deleteEditeur: gestionnaireProcedure .input(z.object({ id: z.number().int() })) .mutation(({ input }) => deleteEditeur(input.id)), statistiques: gestionnaireProcedure.query(() => getStatistiques()), createSolution: protectedProcedure .input(z.object({ nom: z.string().min(1), editeurId: z.number().int(), blocFonctionnelId: z.number().int().optional().nullable(), })) .mutation(async ({ input, ctx }) => { const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin"; return createSolution(input.nom, input.editeurId, input.blocFonctionnelId, isGestionnaire); }), updateSolution: protectedProcedure .input(z.object({ id: z.number().int(), nom: z.string().min(1), editeurId: z.number().int(), blocFonctionnelId: z.number().int().optional().nullable(), })) .mutation(async ({ input, ctx }) => { if (ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") throw new TRPCError({ code: "FORBIDDEN" }); return updateSolution(input.id, input.nom, input.editeurId, input.blocFonctionnelId ?? null); }), deleteSolution: protectedProcedure .input(z.object({ id: z.number().int() })) .mutation(async ({ input, ctx }) => { if (ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") throw new TRPCError({ code: "FORBIDDEN" }); return deleteSolution(input.id); }), }), // ─── Établissements ──────────────────────────────────────────────────────── etablissements: router({ /** * Retourne les établissements selon le rôle : * - référent : ses établissements * - adhérent : ses établissements affectés * - gestionnaire : tous */ mesEtablissements: protectedProcedure.query(({ ctx }) => { if (ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin") { return getAllEtablissements(); } if (ctx.user.sonumRole === "adherent") { return getEtablissementsByAdherent(ctx.user.id); } return getEtablissementsByReferent(ctx.user.id); }), all: gestionnaireProcedure.query(() => getAllEtablissements()), byId: protectedProcedure .input(z.object({ id: z.number().int() })) .query(async ({ input, ctx }) => { const etab = await getEtablissementById(input.id); if (!etab) throw new TRPCError({ code: "NOT_FOUND" }); // Adhérent : vérifier qu'il a accès à cet établissement if (ctx.user.sonumRole === "adherent") { const affectations = await getAffectationsByUser(ctx.user.id); if (!affectations.includes(input.id)) { throw new TRPCError({ code: "FORBIDDEN" }); } } if (etab.visibilite === "gestionnaires" && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") { if (etab.referentId !== ctx.user.id) { throw new TRPCError({ code: "FORBIDDEN" }); } } return etab; }), search: protectedProcedure .input(z.object({ solutionId: z.number().int().optional(), editeurId: z.number().int().optional(), blocFonctionnelId: z.number().int().optional(), region: z.string().optional(), typeActivite: z.string().optional(), tailleEffectifs: z.string().optional(), etatDeploiement: z.string().optional(), })) .query(({ input, ctx }) => { return import("./db").then(({ searchEtablissements }) => searchEtablissements({ ...input, userId: ctx.user.id, sonumRole: ctx.user.sonumRole, }) ); }), create: gestionnaireProcedure .input(z.object({ finess: z.string().optional(), nom: z.string().min(1), region: z.string().optional(), departement: z.string().optional(), typeActivite: z.string().optional(), tailleEffectifs: z.string().optional(), referentId: z.number().int().optional(), })) .mutation(async ({ input }) => { const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); const result = await db.insert(etablissements).values(input); return result[0]; }), update: protectedProcedure .input(z.object({ id: z.number().int(), visibilite: z.enum(["tous", "gestionnaires"]).optional(), accepteMiseEnRelation: z.boolean().optional(), })) .mutation(async ({ input, ctx }) => { const etab = await getEtablissementById(input.id); if (!etab) throw new TRPCError({ code: "NOT_FOUND" }); if (etab.referentId !== ctx.user.id && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") { throw new TRPCError({ code: "FORBIDDEN" }); } const db = await getDb(); if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); const { id, ...updateData } = input; await db.update(etablissements).set(updateData).where(eq(etablissements.id, id)); return { success: true }; }), }), // ─── Logiciels ───────────────────────────────────────────────────────────── logiciels: router({ byEtablissement: protectedProcedure .input(z.object({ etablissementId: z.number().int() })) .query(async ({ input, ctx }) => { const etab = await getEtablissementById(input.etablissementId); if (!etab) throw new TRPCError({ code: "NOT_FOUND" }); // Adhérent : vérifier affectation if (ctx.user.sonumRole === "adherent") { const affectations = await getAffectationsByUser(ctx.user.id); if (!affectations.includes(input.etablissementId)) throw new TRPCError({ code: "FORBIDDEN" }); } if (etab.visibilite === "gestionnaires" && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") { if (etab.referentId !== ctx.user.id) throw new TRPCError({ code: "FORBIDDEN" }); } return getLogicielsByEtablissement(input.etablissementId); }), upsert: protectedProcedure .input(z.object({ id: z.number().int().optional(), etablissementId: z.number().int(), solutionId: z.number().int(), etatDeploiement: z.enum(["demarrage", "en_cours", "operationnel", "en_remplacement"]), modeHebergement: z.enum(["hds", "on_premise", "hybride"]).optional().nullable(), modeFacturation: z.enum(["saas", "achat_maintenance", "location"]).optional().nullable(), interoperabilite: z.enum(["non", "oui_interface", "oui_eai"]).optional().nullable(), versionMajeure: z.string().optional().nullable(), commentaire: z.string().optional().nullable(), contactNom: z.string().optional().nullable(), contactFonction: z.string().optional().nullable(), contactEmail: z.string().optional().nullable(), })) .mutation(async ({ input, ctx }) => { const etab = await getEtablissementById(input.etablissementId); if (!etab) throw new TRPCError({ code: "NOT_FOUND" }); if (etab.referentId !== ctx.user.id && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") { throw new TRPCError({ code: "FORBIDDEN" }); } if (ctx.user.role === "readonly") throw new TRPCError({ code: "FORBIDDEN", message: "Compte en lecture seule" }); return upsertLogicielEtablissement({ ...input, saisiePar: ctx.user.id }); }), delete: protectedProcedure .input(z.object({ id: z.number().int(), etablissementId: z.number().int() })) .mutation(async ({ input, ctx }) => { if (ctx.user.role === "readonly") throw new TRPCError({ code: "FORBIDDEN", message: "Compte en lecture seule" }); const etab = await getEtablissementById(input.etablissementId); if (!etab) throw new TRPCError({ code: "NOT_FOUND" }); if (etab.referentId !== ctx.user.id && ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") { throw new TRPCError({ code: "FORBIDDEN" }); } await deleteLogicielEtablissement(input.id); return { success: true }; }), mesSolutions: protectedProcedure .query(({ ctx }) => getMesSolutionsGroupees(ctx.user.id, ctx.user.sonumRole ?? "referent") ), toutesLesSolutions: protectedProcedure .query(() => getToutesLesSolutionsGroupees()), }), // ─── Traçabilité ─────────────────────────────────────────────────────────── tracabilite: router({ enregistrerConsultation: protectedProcedure .input(z.object({ etablissementId: z.number().int() })) .mutation(async ({ input, ctx }) => { await recordConsultation(input.etablissementId, ctx.user.id, ctx.user.name ?? "Inconnu"); return { success: true }; }), compteur: protectedProcedure .input(z.object({ etablissementId: z.number().int() })) .query(async ({ input, ctx }) => { const etab = await getEtablissementById(input.etablissementId); if (!etab) throw new TRPCError({ code: "NOT_FOUND" }); const canSee = etab.referentId === ctx.user.id || ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin"; if (!canSee) throw new TRPCError({ code: "FORBIDDEN" }); return { count: await getConsultationCount(input.etablissementId) }; }), liste: protectedProcedure .input(z.object({ etablissementId: z.number().int() })) .query(async ({ input, ctx }) => { const etab = await getEtablissementById(input.etablissementId); if (!etab) throw new TRPCError({ code: "NOT_FOUND" }); const canSee = etab.referentId === ctx.user.id || ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin"; if (!canSee) throw new TRPCError({ code: "FORBIDDEN" }); return getConsultationsList(input.etablissementId); }), }), // ─── Demandes de Contact ─────────────────────────────────────────────────── contact: router({ envoyer: writeProcedure .input(z.object({ etablissementCibleId: z.number().int(), message: z.string().min(1), })) .mutation(async ({ input, ctx }) => { const etab = await getEtablissementById(input.etablissementCibleId); if (!etab) throw new TRPCError({ code: "NOT_FOUND" }); await createDemandeContact({ etablissementCibleId: input.etablissementCibleId, demandeurId: ctx.user.id, demandeurNom: ctx.user.name ?? "Inconnu", demandeurEmail: ctx.user.email ?? "", message: input.message, }); await notifyOwner({ title: `Nouvelle demande de contact — ${etab.nom}`, content: `${ctx.user.name} souhaite contacter le référent de ${etab.nom}.\n\nMessage : ${input.message}`, }); return { success: true }; }), mesDemandes: protectedProcedure.query(({ ctx }) => getDemandesByDemandeur(ctx.user.id) ), demandesRecues: protectedProcedure.query(({ ctx }) => getDemandesRecuesParEtablissement(ctx.user.id) ), toutesLesDemandes: gestionnaireProcedure.query(() => getAllDemandes()), repondre: protectedProcedure .input(z.object({ id: z.number().int(), reponse: z.string().min(1), })) .mutation(async ({ input, ctx }) => { const demande = await getDemandeById(input.id); if (!demande) throw new TRPCError({ code: "NOT_FOUND" }); const etab = await getEtablissementById(demande.etablissementCibleId); const canReply = (etab?.referentId === ctx.user.id) || ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin"; if (!canReply) throw new TRPCError({ code: "FORBIDDEN" }); await repondreDemandeContact(input.id, input.reponse, ctx.user.id); return { success: true }; }), }), // ─── Administration ──────────────────────────────────────────────────────── admin: router({ /** Liste tous les utilisateurs avec leurs établissements affectés */ users: gestionnaireProcedure.query(() => getAllUsersWithAffectations()), /** Crée un utilisateur manuellement avec un mot de passe local */ createUser: gestionnaireProcedure .input(z.object({ firstName: z.string().min(1), lastName: z.string().min(1), login: z.string().min(2).optional(), email: z.string().email(), sonumRole: z.enum(["referent", "gestionnaire", "adherent"]), role: z.enum(["admin", "standard", "readonly"]).default("standard"), isActive: z.boolean().default(true), password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"), })) .mutation(async ({ input }) => { try { const userId = await createLocalUser(input); return { success: true, userId }; } catch (err: any) { if (err.message === "EMAIL_EXISTS") { throw new TRPCError({ code: "CONFLICT", message: "Un utilisateur avec cet email existe déjà" }); } if (err.message === "LOGIN_EXISTS") { throw new TRPCError({ code: "CONFLICT", message: "Un utilisateur avec ce login existe déjà" }); } throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: err.message }); } }), /** Met à jour les informations d'un utilisateur */ updateUser: gestionnaireProcedure .input(z.object({ userId: z.number().int(), firstName: z.string().min(1).optional(), lastName: z.string().min(1).optional(), login: z.string().min(2).optional(), email: z.string().email().optional(), sonumRole: z.enum(["referent", "gestionnaire", "adherent"]).optional(), role: z.enum(["admin", "standard", "readonly"]).optional(), isActive: z.boolean().optional(), })) .mutation(async ({ input }) => { const { userId, ...data } = input; await updateUser(userId, data); return { success: true }; }), /** Réinitialise le mot de passe d'un utilisateur local */ resetPassword: gestionnaireProcedure .input(z.object({ userId: z.number().int(), newPassword: z.string().min(8), })) .mutation(async ({ input }) => { await updateLocalPassword(input.userId, input.newPassword); return { success: true }; }), /** Supprime un utilisateur */ deleteUser: gestionnaireProcedure .input(z.object({ userId: z.number().int() })) .mutation(async ({ input }) => { await deleteUser(input.userId); return { success: true }; }), /** Ancienne procédure de mise à jour du rôle (rétrocompatibilité) */ updateRole: gestionnaireProcedure .input(z.object({ userId: z.number().int(), sonumRole: z.enum(["referent", "gestionnaire", "adherent"]), })) .mutation(async ({ input }) => { await updateUserSonumRole(input.userId, input.sonumRole); return { success: true }; }), /** Retourne les établissements affectés à un utilisateur */ affectations: gestionnaireProcedure .input(z.object({ userId: z.number().int() })) .query(({ input }) => getAffectationsByUser(input.userId)), /** Remplace toutes les affectations d'un adhérent */ setAffectations: gestionnaireProcedure .input(z.object({ userId: z.number().int(), etablissementIds: z.array(z.number().int()), })) .mutation(async ({ input }) => { await setAffectationsForUser(input.userId, input.etablissementIds); return { success: true }; }), /** Ajoute un établissement à un utilisateur */ assignEtablissement: gestionnaireProcedure .input(z.object({ userId: z.number().int(), etablissementId: z.number().int(), })) .mutation(async ({ input }) => { await assignEtablissementToUser(input.userId, input.etablissementId); return { success: true }; }), /** Retire un établissement d'un utilisateur */ removeEtablissement: gestionnaireProcedure .input(z.object({ userId: z.number().int(), etablissementId: z.number().int(), })) .mutation(async ({ input }) => { await removeEtablissementFromUser(input.userId, input.etablissementId); return { success: true }; }), /** Retourne les utilisateurs (adhérents + référent) d'un établissement */ getUsersForEtablissement: gestionnaireProcedure .input(z.object({ etablissementId: z.number().int() })) .query(({ input }) => getUsersForEtablissement(input.etablissementId)), /** Définit le référent numérique d'un établissement */ setReferentForEtablissement: gestionnaireProcedure .input(z.object({ etablissementId: z.number().int(), referentId: z.number().int().nullable(), })) .mutation(async ({ input }) => { await setReferentForEtablissement(input.etablissementId, input.referentId); return { success: true }; }), /** Remplace tous les adhérents affectés à un établissement */ setAdherentsForEtablissement: gestionnaireProcedure .input(z.object({ etablissementId: z.number().int(), userIds: z.array(z.number().int()), })) .mutation(async ({ input }) => { await setAdherentsForEtablissement(input.etablissementId, input.userIds); return { success: true }; }), }), }); export type AppRouter = typeof appRouter;