Files
sonum/server/routers.ts

601 lines
25 KiB
TypeScript

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;