Checkpoint: Audit et fiabilisation SONUM : suppression des composants de démonstration non référencés, nettoyage des dépendances inutilisées, contrôles d’écriture cohérents pour les comptes readonly, validation et normalisation des entrées, transactions sur les opérations multi-étapes, optimisations de requêtes et découpage dynamique des pages lourdes. TypeScript, 37 tests Vitest et build de production validés.

This commit is contained in:
Manus
2026-08-17 20:23:47 +00:00
parent 0205859880
commit 7c97bec6c1
17 changed files with 408 additions and 4770 deletions

View File

@@ -35,6 +35,7 @@ import {
getMesSolutionsGroupees,
getToutesLesSolutionsGroupees,
getSolutions,
searchEtablissements,
recordConsultation,
removeEtablissementFromUser,
repondreDemandeContact,
@@ -48,14 +49,12 @@ import {
getMessagesCanal,
sendMessageCanal,
isMemberOfCanal,
getCanalById,
getMembresCanal,
addMembresCanal,
createDemandeMiseEnRelation,
getAllDemandesMiseEnRelation,
getDemandesMiseEnRelationByUser,
traiterDemandeMiseEnRelation,
getDemandeMiseEnRelationById,
updateUser,
updateUserCgu,
updateUserSonumRole,
@@ -74,32 +73,48 @@ import { sdk } from "./_core/sdk";
// ─── Middleware gestionnaire SONUM ────────────────────────────────────────────
/** Détermine l'accès de gestionnaire une seule fois et évite des règles divergentes. */
function isGestionnaire(user: { sonumRole?: string | null; role?: string | null }) {
return user.sonumRole === "gestionnaire" || user.role === "admin";
}
const gestionnaireProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") {
if (!isGestionnaire(ctx.user)) {
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." });
/** Bloque les mutations pour les utilisateurs en lecture seule (role === 'readonly'). */
function assertWritable(role?: string | null) {
if (role === "readonly") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Votre compte est en lecture seule. Contactez un gestionnaire SONUM pour obtenir les droits de modification.",
});
}
}
const writeProcedure = protectedProcedure.use(({ ctx, next }) => {
assertWritable(ctx.user.role);
return next({ ctx });
});
/** Les gestionnaires en lecture seule conservent les consultations, mais jamais les écritures. */
const gestionnaireWriteProcedure = gestionnaireProcedure.use(({ ctx, next }) => {
assertWritable(ctx.user.role);
return next({ ctx });
});
/// ─── Canaux de Discussion ────────────────────────────────────────────────────
const canauxRouter = router({
list: protectedProcedure.query(async ({ ctx }) => {
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
return getCanauxForUser(ctx.user.id, isGestionnaire);
return getCanauxForUser(ctx.user.id, isGestionnaire(ctx.user));
}),
messages: protectedProcedure
.input(z.object({ canalId: z.number().int() }))
.query(async ({ input, ctx }) => {
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
if (!isGestionnaire) {
if (!isGestionnaire(ctx.user)) {
const isMember = await isMemberOfCanal(input.canalId, ctx.user.id);
if (!isMember) throw new TRPCError({ code: "FORBIDDEN" });
}
@@ -108,33 +123,32 @@ const canauxRouter = router({
membres: protectedProcedure
.input(z.object({ canalId: z.number().int() }))
.query(async ({ input, ctx }) => {
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
if (!isGestionnaire) {
if (!isGestionnaire(ctx.user)) {
const isMember = await isMemberOfCanal(input.canalId, ctx.user.id);
if (!isMember) throw new TRPCError({ code: "FORBIDDEN" });
}
return getMembresCanal(input.canalId);
}),
}),
sendMessage: writeProcedure
.input(z.object({ canalId: z.number().int(), contenu: z.string().min(1).max(5000) }))
.input(z.object({ canalId: z.number().int().positive(), contenu: z.string().trim().min(1).max(5000) }))
.mutation(async ({ input, ctx }) => {
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
if (!isGestionnaire) {
if (!isGestionnaire(ctx.user)) {
const isMember = await isMemberOfCanal(input.canalId, ctx.user.id);
if (!isMember) throw new TRPCError({ code: "FORBIDDEN" });
}
await sendMessageCanal({ canalId: input.canalId, auteurId: ctx.user.id, auteurNom: ctx.user.name ?? "Inconnu", contenu: input.contenu });
return { success: true };
}),
create: gestionnaireProcedure
create: gestionnaireWriteProcedure
.input(z.object({
titre: z.string().min(1).max(255),
description: z.string().optional(),
titre: z.string().trim().min(1).max(255),
description: z.string().trim().max(2000).optional(),
type: z.enum(["contact_referent", "mise_en_relation_public", "mise_en_relation_prive"]),
visibilite: z.enum(["public", "prive"]),
etablissementId: z.number().int().optional(),
demandeMiseEnRelationId: z.number().int().optional(),
membreIds: z.array(z.number().int()),
etablissementId: z.number().int().positive().optional(),
demandeMiseEnRelationId: z.number().int().positive().optional(),
// La contrainte d'unicité évite les doublons lors de la création multi-sélection.
membreIds: z.array(z.number().int().positive()).min(1).transform((ids) => Array.from(new Set(ids))),
}))
.mutation(async ({ input, ctx }) => {
const canalId = await createCanal({ ...input, creePar: ctx.user.id });
@@ -143,15 +157,19 @@ const canauxRouter = router({
}
return { canalId };
}),
addMembres: gestionnaireProcedure
.input(z.object({ canalId: z.number().int(), userIds: z.array(z.number().int()) }))
addMembres: gestionnaireWriteProcedure
.input(z.object({ canalId: z.number().int().positive(), userIds: z.array(z.number().int().positive()).min(1).transform((ids) => Array.from(new Set(ids))) }))
.mutation(async ({ input }) => { await addMembresCanal(input.canalId, input.userIds); return { success: true }; }),
});
// ─── Demandes de Mise en Relation ─────────────────────────────────────────────
const miseEnRelationRouter = router({
soumettre: writeProcedure
.input(z.object({ sujet: z.string().min(1).max(500), message: z.string().min(1), etablissementDemandeurId: z.number().int().optional() }))
.input(z.object({
sujet: z.string().trim().min(1).max(500),
message: z.string().trim().min(1).max(5000),
etablissementDemandeurId: z.number().int().positive().optional(),
}))
.mutation(async ({ input, ctx }) => {
const id = await createDemandeMiseEnRelation({
demandeurId: ctx.user.id, demandeurNom: ctx.user.name ?? "Inconnu",
@@ -188,8 +206,8 @@ export const appRouter = router({
*/
loginLocal: publicProcedure
.input(z.object({
// Accepte email ou login court
email: z.string().min(1),
// Accepte email ou login court, sans conserver les espaces saisis autour de l'identifiant.
email: z.string().trim().min(1).max(320),
password: z.string().min(1),
}))
.mutation(async ({ input, ctx }) => {
@@ -242,58 +260,53 @@ export const appRouter = router({
.input(z.object({ search: z.string().optional() }))
.query(({ input }) => getSolutions(input.search)),
createEditeur: protectedProcedure
.input(z.object({ nom: z.string().min(1) }))
createEditeur: writeProcedure
.input(z.object({ nom: z.string().trim().min(1).max(255) }))
.mutation(async ({ input, ctx }) => {
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
return createEditeur(input.nom, isGestionnaire);
return createEditeur(input.nom, isGestionnaire(ctx.user));
}),
createBlocFonctionnel: protectedProcedure
.input(z.object({ nom: z.string().min(1) }))
createBlocFonctionnel: writeProcedure
.input(z.object({ nom: z.string().trim().min(1).max(255) }))
.mutation(async ({ input, ctx }) => {
const isGestionnaire = ctx.user.sonumRole === "gestionnaire" || ctx.user.role === "admin";
return createBlocFonctionnel(input.nom, isGestionnaire);
return createBlocFonctionnel(input.nom, isGestionnaire(ctx.user));
}),
updateBlocFonctionnel: gestionnaireProcedure
.input(z.object({ id: z.number().int(), nom: z.string().min(1) }))
updateBlocFonctionnel: gestionnaireWriteProcedure
.input(z.object({ id: z.number().int().positive(), nom: z.string().trim().min(1).max(255) }))
.mutation(({ input }) => updateBlocFonctionnel(input.id, input.nom)),
deleteBlocFonctionnel: gestionnaireProcedure
.input(z.object({ id: z.number().int() }))
deleteBlocFonctionnel: gestionnaireWriteProcedure
.input(z.object({ id: z.number().int().positive() }))
.mutation(({ input }) => deleteBlocFonctionnel(input.id)),
updateEditeur: gestionnaireProcedure
.input(z.object({ id: z.number().int(), nom: z.string().min(1) }))
updateEditeur: gestionnaireWriteProcedure
.input(z.object({ id: z.number().int().positive(), nom: z.string().trim().min(1).max(255) }))
.mutation(({ input }) => updateEditeur(input.id, input.nom)),
deleteEditeur: gestionnaireProcedure
.input(z.object({ id: z.number().int() }))
deleteEditeur: gestionnaireWriteProcedure
.input(z.object({ id: z.number().int().positive() }))
.mutation(({ input }) => deleteEditeur(input.id)),
statistiques: gestionnaireProcedure.query(() => getStatistiques()),
createSolution: protectedProcedure
createSolution: writeProcedure
.input(z.object({
nom: z.string().min(1),
editeurId: z.number().int(),
blocFonctionnelId: z.number().int().optional().nullable(),
nom: z.string().trim().min(1).max(255),
editeurId: z.number().int().positive(),
blocFonctionnelId: z.number().int().positive().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);
return createSolution(input.nom, input.editeurId, input.blocFonctionnelId, isGestionnaire(ctx.user));
}),
updateSolution: protectedProcedure
updateSolution: gestionnaireWriteProcedure
.input(z.object({
id: z.number().int(),
nom: z.string().min(1),
editeurId: z.number().int(),
blocFonctionnelId: z.number().int().optional().nullable(),
id: z.number().int().positive(),
nom: z.string().trim().min(1).max(255),
editeurId: z.number().int().positive(),
blocFonctionnelId: z.number().int().positive().optional().nullable(),
}))
.mutation(async ({ input, ctx }) => {
if (ctx.user.sonumRole !== "gestionnaire" && ctx.user.role !== "admin") throw new TRPCError({ code: "FORBIDDEN" });
.mutation(({ input }) => {
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" });
deleteSolution: gestionnaireWriteProcedure
.input(z.object({ id: z.number().int().positive() }))
.mutation(({ input }) => {
return deleteSolution(input.id);
}),
}),
@@ -348,17 +361,13 @@ export const appRouter = router({
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,
})
);
}),
.query(({ input, ctx }) => searchEtablissements({
...input,
userId: ctx.user.id,
sonumRole: ctx.user.sonumRole,
})),
create: gestionnaireProcedure
create: gestionnaireWriteProcedure
.input(z.object({
finess: z.string().optional(),
nom: z.string().min(1),
@@ -375,7 +384,7 @@ export const appRouter = router({
return result[0];
}),
update: protectedProcedure
update: writeProcedure
.input(z.object({
id: z.number().int(),
visibilite: z.enum(["tous", "gestionnaires"]).optional(),
@@ -413,7 +422,7 @@ export const appRouter = router({
return getLogicielsByEtablissement(input.etablissementId);
}),
upsert: protectedProcedure
upsert: writeProcedure
.input(z.object({
id: z.number().int().optional(),
etablissementId: z.number().int(),
@@ -434,20 +443,19 @@ export const appRouter = router({
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
delete: writeProcedure
.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);
const wasDeleted = await deleteLogicielEtablissement(input.id, input.etablissementId);
if (!wasDeleted) throw new TRPCError({ code: "NOT_FOUND" });
return { success: true };
}),
mesSolutions: protectedProcedure
@@ -493,7 +501,7 @@ export const appRouter = router({
envoyer: writeProcedure
.input(z.object({
etablissementCibleId: z.number().int(),
message: z.string().min(1),
message: z.string().trim().min(1).max(5000),
}))
.mutation(async ({ input, ctx }) => {
const etab = await getEtablissementById(input.etablissementCibleId);
@@ -525,10 +533,10 @@ export const appRouter = router({
toutesLesDemandes: gestionnaireProcedure.query(() => getAllDemandes()),
repondre: protectedProcedure
repondre: writeProcedure
.input(z.object({
id: z.number().int(),
reponse: z.string().min(1),
id: z.number().int().positive(),
reponse: z.string().trim().min(1).max(5000),
}))
.mutation(async ({ input, ctx }) => {
const demande = await getDemandeById(input.id);
@@ -547,7 +555,7 @@ export const appRouter = router({
users: gestionnaireProcedure.query(() => getAllUsersWithAffectations()),
/** Crée un utilisateur manuellement avec un mot de passe local */
createUser: gestionnaireProcedure
createUser: gestionnaireWriteProcedure
.input(z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
@@ -574,7 +582,7 @@ export const appRouter = router({
}),
/** Met à jour les informations d'un utilisateur */
updateUser: gestionnaireProcedure
updateUser: gestionnaireWriteProcedure
.input(z.object({
userId: z.number().int(),
firstName: z.string().min(1).optional(),
@@ -592,7 +600,7 @@ export const appRouter = router({
}),
/** Réinitialise le mot de passe d'un utilisateur local */
resetPassword: gestionnaireProcedure
resetPassword: gestionnaireWriteProcedure
.input(z.object({
userId: z.number().int(),
newPassword: z.string().min(8),
@@ -603,7 +611,7 @@ export const appRouter = router({
}),
/** Supprime un utilisateur */
deleteUser: gestionnaireProcedure
deleteUser: gestionnaireWriteProcedure
.input(z.object({ userId: z.number().int() }))
.mutation(async ({ input }) => {
await deleteUser(input.userId);
@@ -611,7 +619,7 @@ export const appRouter = router({
}),
/** Ancienne procédure de mise à jour du rôle (rétrocompatibilité) */
updateRole: gestionnaireProcedure
updateRole: gestionnaireWriteProcedure
.input(z.object({
userId: z.number().int(),
sonumRole: z.enum(["referent", "gestionnaire", "adherent"]),
@@ -627,7 +635,7 @@ export const appRouter = router({
.query(({ input }) => getAffectationsByUser(input.userId)),
/** Remplace toutes les affectations d'un adhérent */
setAffectations: gestionnaireProcedure
setAffectations: gestionnaireWriteProcedure
.input(z.object({
userId: z.number().int(),
etablissementIds: z.array(z.number().int()),
@@ -638,7 +646,7 @@ export const appRouter = router({
}),
/** Ajoute un établissement à un utilisateur */
assignEtablissement: gestionnaireProcedure
assignEtablissement: gestionnaireWriteProcedure
.input(z.object({
userId: z.number().int(),
etablissementId: z.number().int(),
@@ -649,7 +657,7 @@ export const appRouter = router({
}),
/** Retire un établissement d'un utilisateur */
removeEtablissement: gestionnaireProcedure
removeEtablissement: gestionnaireWriteProcedure
.input(z.object({
userId: z.number().int(),
etablissementId: z.number().int(),
@@ -665,7 +673,7 @@ export const appRouter = router({
.query(({ input }) => getUsersForEtablissement(input.etablissementId)),
/** Définit le référent numérique d'un établissement */
setReferentForEtablissement: gestionnaireProcedure
setReferentForEtablissement: gestionnaireWriteProcedure
.input(z.object({
etablissementId: z.number().int(),
referentId: z.number().int().nullable(),
@@ -676,7 +684,7 @@ export const appRouter = router({
}),
/** Remplace tous les adhérents affectés à un établissement */
setAdherentsForEtablissement: gestionnaireProcedure
setAdherentsForEtablissement: gestionnaireWriteProcedure
.input(z.object({
etablissementId: z.number().int(),
userIds: z.array(z.number().int()),