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:
277
server/db.ts
277
server/db.ts
@@ -299,18 +299,46 @@ export async function upsertLogicielEtablissement(data: {
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
if (data.id) {
|
||||
await db.update(logicielsEtablissements).set({ ...data, updatedAt: new Date() }).where(eq(logicielsEtablissements.id, data.id));
|
||||
return data.id;
|
||||
const { id, ...values } = data;
|
||||
if (id) {
|
||||
/**
|
||||
* L'identifiant de la fiche ne suffit pas : le contrôle sur l'établissement
|
||||
* rend impossible une mise à jour croisée en cas d'identifiant falsifié.
|
||||
*/
|
||||
const existing = await db
|
||||
.select({ id: logicielsEtablissements.id })
|
||||
.from(logicielsEtablissements)
|
||||
.where(and(
|
||||
eq(logicielsEtablissements.id, id),
|
||||
eq(logicielsEtablissements.etablissementId, values.etablissementId),
|
||||
))
|
||||
.limit(1);
|
||||
if (!existing.length) return null;
|
||||
|
||||
await db
|
||||
.update(logicielsEtablissements)
|
||||
.set({ ...values, updatedAt: new Date() })
|
||||
.where(eq(logicielsEtablissements.id, id));
|
||||
return id;
|
||||
}
|
||||
const result = await db.insert(logicielsEtablissements).values(data);
|
||||
const result = await db.insert(logicielsEtablissements).values(values);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function deleteLogicielEtablissement(id: number) {
|
||||
/**
|
||||
* Supprime une fiche seulement si elle appartient à l'établissement déjà autorisé
|
||||
* par le routeur. Cette seconde condition protège la couche SQL elle-même.
|
||||
*/
|
||||
export async function deleteLogicielEtablissement(id: number, etablissementId: number): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.delete(logicielsEtablissements).where(eq(logicielsEtablissements.id, id));
|
||||
if (!db) return false;
|
||||
const result = await db
|
||||
.delete(logicielsEtablissements)
|
||||
.where(and(
|
||||
eq(logicielsEtablissements.id, id),
|
||||
eq(logicielsEtablissements.etablissementId, etablissementId),
|
||||
));
|
||||
return Number((result[0] as { affectedRows?: number } | undefined)?.affectedRows ?? 0) > 0;
|
||||
}
|
||||
|
||||
// ─── Traçabilité ──────────────────────────────────────────────────────────────
|
||||
@@ -663,21 +691,29 @@ export async function getAllUsersWithAffectations() {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const allUsers = await db.select().from(users).orderBy(users.name);
|
||||
const allAffectations = await db
|
||||
.select({
|
||||
userId: userEtablissements.userId,
|
||||
etablissementId: userEtablissements.etablissementId,
|
||||
etablissementNom: etablissements.nom,
|
||||
})
|
||||
.from(userEtablissements)
|
||||
.innerJoin(etablissements, eq(userEtablissements.etablissementId, etablissements.id));
|
||||
const [allUsers, allAffectations] = await Promise.all([
|
||||
db.select().from(users).orderBy(users.name),
|
||||
db
|
||||
.select({
|
||||
userId: userEtablissements.userId,
|
||||
etablissementId: userEtablissements.etablissementId,
|
||||
etablissementNom: etablissements.nom,
|
||||
})
|
||||
.from(userEtablissements)
|
||||
.innerJoin(etablissements, eq(userEtablissements.etablissementId, etablissements.id)),
|
||||
]);
|
||||
|
||||
// Évite un filter() complet pour chaque utilisateur : coût linéaire même avec un grand annuaire.
|
||||
const affectationsParUtilisateur = new Map<number, { id: number; nom: string }[]>();
|
||||
for (const affectation of allAffectations) {
|
||||
const affectations = affectationsParUtilisateur.get(affectation.userId) ?? [];
|
||||
affectations.push({ id: affectation.etablissementId, nom: affectation.etablissementNom });
|
||||
affectationsParUtilisateur.set(affectation.userId, affectations);
|
||||
}
|
||||
|
||||
return allUsers.map((u) => ({
|
||||
...u,
|
||||
etablissements: allAffectations
|
||||
.filter((a) => a.userId === u.id)
|
||||
.map((a) => ({ id: a.etablissementId, nom: a.etablissementNom })),
|
||||
etablissements: affectationsParUtilisateur.get(u.id) ?? [],
|
||||
hasLocalCredentials: false, // sera enrichi côté router si besoin
|
||||
}));
|
||||
}
|
||||
@@ -714,7 +750,9 @@ export async function getMesSolutionsGroupees(userId: number, sonumRole: string)
|
||||
.select({
|
||||
solutionId: solutions.id,
|
||||
solutionNom: solutions.nom,
|
||||
editeurId: solutions.editeurId,
|
||||
editeurNom: editeurs.nom,
|
||||
blocFonctionnelId: solutions.blocFonctionnelId,
|
||||
blocFonctionnelNom: blocsFonctionnels.nom,
|
||||
etablissementId: etablissements.id,
|
||||
etablissementNom: etablissements.nom,
|
||||
@@ -745,9 +783,9 @@ export async function getMesSolutionsGroupees(userId: number, sonumRole: string)
|
||||
map.set(row.solutionId, {
|
||||
solutionId: row.solutionId,
|
||||
solutionNom: row.solutionNom ?? "",
|
||||
editeurId: null,
|
||||
editeurId: row.editeurId ?? null,
|
||||
editeurNom: row.editeurNom ?? "",
|
||||
blocFonctionnelId: null,
|
||||
blocFonctionnelId: row.blocFonctionnelId ?? null,
|
||||
blocFonctionnelNom: row.blocFonctionnelNom ?? null,
|
||||
nbEtablissements: 0,
|
||||
etablissements: [],
|
||||
@@ -882,71 +920,60 @@ export async function getStatistiques() {
|
||||
const db = await getDb();
|
||||
if (!db) return null;
|
||||
|
||||
// Total établissements
|
||||
const [{ total: totalEtablissements }] = await db
|
||||
.select({ total: sql<number>`COUNT(*)` })
|
||||
.from(etablissements);
|
||||
|
||||
// Total solutions distinctes utilisées
|
||||
const [{ total: totalSolutions }] = await db
|
||||
.select({ total: sql<number>`COUNT(DISTINCT solutionId)` })
|
||||
.from(logicielsEtablissements);
|
||||
|
||||
// Total fiches logiciels (lignes logiciels_etablissements)
|
||||
const [{ total: totalFiches }] = await db
|
||||
.select({ total: sql<number>`COUNT(*)` })
|
||||
.from(logicielsEtablissements);
|
||||
|
||||
// Établissements avec au moins un logiciel
|
||||
const [{ total: etabAvecLogiciel }] = await db
|
||||
.select({ total: sql<number>`COUNT(DISTINCT etablissementId)` })
|
||||
.from(logicielsEtablissements);
|
||||
|
||||
// Répartition par bloc fonctionnel
|
||||
const parBloc = await db
|
||||
.select({
|
||||
blocNom: blocsFonctionnels.nom,
|
||||
count: sql<number>`COUNT(DISTINCT ${logicielsEtablissements.etablissementId})`,
|
||||
})
|
||||
.from(logicielsEtablissements)
|
||||
.innerJoin(solutions, eq(logicielsEtablissements.solutionId, solutions.id))
|
||||
.leftJoin(blocsFonctionnels, eq(solutions.blocFonctionnelId, blocsFonctionnels.id))
|
||||
.groupBy(blocsFonctionnels.nom)
|
||||
.orderBy(sql`COUNT(DISTINCT ${logicielsEtablissements.etablissementId}) DESC`);
|
||||
|
||||
// Répartition par région
|
||||
const parRegion = await db
|
||||
.select({
|
||||
region: etablissements.region,
|
||||
count: sql<number>`COUNT(DISTINCT ${etablissements.id})`,
|
||||
})
|
||||
.from(etablissements)
|
||||
.groupBy(etablissements.region)
|
||||
.orderBy(sql`COUNT(DISTINCT ${etablissements.id}) DESC`);
|
||||
|
||||
// Répartition par état de déploiement
|
||||
const parEtat = await db
|
||||
.select({
|
||||
etat: logicielsEtablissements.etatDeploiement,
|
||||
count: sql<number>`COUNT(*)`,
|
||||
})
|
||||
.from(logicielsEtablissements)
|
||||
.groupBy(logicielsEtablissements.etatDeploiement)
|
||||
.orderBy(sql`COUNT(*) DESC`);
|
||||
|
||||
// Top 10 solutions les plus utilisées
|
||||
const topSolutions = await db
|
||||
.select({
|
||||
solutionNom: solutions.nom,
|
||||
editeurNom: editeurs.nom,
|
||||
count: sql<number>`COUNT(DISTINCT ${logicielsEtablissements.etablissementId})`,
|
||||
})
|
||||
.from(logicielsEtablissements)
|
||||
.innerJoin(solutions, eq(logicielsEtablissements.solutionId, solutions.id))
|
||||
.leftJoin(editeurs, eq(solutions.editeurId, editeurs.id))
|
||||
.groupBy(solutions.id, solutions.nom, editeurs.nom)
|
||||
.orderBy(sql`COUNT(DISTINCT ${logicielsEtablissements.etablissementId}) DESC`)
|
||||
.limit(10);
|
||||
/** Toutes ces agrégations sont indépendantes : les lancer ensemble évite huit allers-retours séquentiels. */
|
||||
const [
|
||||
[{ total: totalEtablissements }],
|
||||
[{ total: totalSolutions }],
|
||||
[{ total: totalFiches }],
|
||||
[{ total: etabAvecLogiciel }],
|
||||
parBloc,
|
||||
parRegion,
|
||||
parEtat,
|
||||
topSolutions,
|
||||
] = await Promise.all([
|
||||
db.select({ total: sql<number>`COUNT(*)` }).from(etablissements),
|
||||
db.select({ total: sql<number>`COUNT(DISTINCT solutionId)` }).from(logicielsEtablissements),
|
||||
db.select({ total: sql<number>`COUNT(*)` }).from(logicielsEtablissements),
|
||||
db.select({ total: sql<number>`COUNT(DISTINCT etablissementId)` }).from(logicielsEtablissements),
|
||||
db
|
||||
.select({
|
||||
blocNom: blocsFonctionnels.nom,
|
||||
count: sql<number>`COUNT(DISTINCT ${logicielsEtablissements.etablissementId})`,
|
||||
})
|
||||
.from(logicielsEtablissements)
|
||||
.innerJoin(solutions, eq(logicielsEtablissements.solutionId, solutions.id))
|
||||
.leftJoin(blocsFonctionnels, eq(solutions.blocFonctionnelId, blocsFonctionnels.id))
|
||||
.groupBy(blocsFonctionnels.nom)
|
||||
.orderBy(sql`COUNT(DISTINCT ${logicielsEtablissements.etablissementId}) DESC`),
|
||||
db
|
||||
.select({
|
||||
region: etablissements.region,
|
||||
count: sql<number>`COUNT(DISTINCT ${etablissements.id})`,
|
||||
})
|
||||
.from(etablissements)
|
||||
.groupBy(etablissements.region)
|
||||
.orderBy(sql`COUNT(DISTINCT ${etablissements.id}) DESC`),
|
||||
db
|
||||
.select({
|
||||
etat: logicielsEtablissements.etatDeploiement,
|
||||
count: sql<number>`COUNT(*)`,
|
||||
})
|
||||
.from(logicielsEtablissements)
|
||||
.groupBy(logicielsEtablissements.etatDeploiement)
|
||||
.orderBy(sql`COUNT(*) DESC`),
|
||||
db
|
||||
.select({
|
||||
solutionNom: solutions.nom,
|
||||
editeurNom: editeurs.nom,
|
||||
count: sql<number>`COUNT(DISTINCT ${logicielsEtablissements.etablissementId})`,
|
||||
})
|
||||
.from(logicielsEtablissements)
|
||||
.innerJoin(solutions, eq(logicielsEtablissements.solutionId, solutions.id))
|
||||
.leftJoin(editeurs, eq(solutions.editeurId, editeurs.id))
|
||||
.groupBy(solutions.id, solutions.nom, editeurs.nom)
|
||||
.orderBy(sql`COUNT(DISTINCT ${logicielsEtablissements.etablissementId}) DESC`)
|
||||
.limit(10),
|
||||
]);
|
||||
|
||||
// Taux de remplissage (% établissements avec au moins 1 logiciel)
|
||||
const tauxRemplissage = totalEtablissements > 0
|
||||
@@ -1024,17 +1051,22 @@ export async function setReferentForEtablissement(etablissementId: number, refer
|
||||
export async function setAdherentsForEtablissement(etablissementId: number, userIds: number[]) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
// Supprimer les affectations existantes pour cet établissement (seulement les adhérents)
|
||||
// On supprime toutes les lignes user_etablissements pour cet établissement
|
||||
await db
|
||||
.delete(userEtablissements)
|
||||
.where(eq(userEtablissements.etablissementId, etablissementId));
|
||||
// Réinsérer
|
||||
if (userIds.length > 0) {
|
||||
await db.insert(userEtablissements).values(
|
||||
userIds.map((uid) => ({ userId: uid, etablissementId }))
|
||||
);
|
||||
}
|
||||
const uniqueUserIds = Array.from(new Set(userIds));
|
||||
|
||||
/**
|
||||
* Le remplacement est atomique : une erreur d'insertion ne doit jamais laisser
|
||||
* l'établissement sans ses affectations précédentes.
|
||||
*/
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(userEtablissements)
|
||||
.where(eq(userEtablissements.etablissementId, etablissementId));
|
||||
if (uniqueUserIds.length > 0) {
|
||||
await tx.insert(userEtablissements).values(
|
||||
uniqueUserIds.map((userId) => ({ userId, etablissementId }))
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Canaux de Discussion ─────────────────────────────────────────────────────
|
||||
@@ -1055,23 +1087,28 @@ export async function createCanal(data: {
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("DB unavailable");
|
||||
const [result] = await db.insert(canauxDiscussion).values({
|
||||
titre: data.titre,
|
||||
description: data.description,
|
||||
type: data.type,
|
||||
visibilite: data.visibilite,
|
||||
etablissementId: data.etablissementId,
|
||||
demandeContactId: data.demandeContactId,
|
||||
demandeMiseEnRelationId: data.demandeMiseEnRelationId,
|
||||
creePar: data.creePar,
|
||||
const membreIds = Array.from(new Set(data.membreIds));
|
||||
|
||||
// Un canal sans ses membres ne doit jamais être observable : tout est validé ensemble.
|
||||
return db.transaction(async (tx) => {
|
||||
const [result] = await tx.insert(canauxDiscussion).values({
|
||||
titre: data.titre,
|
||||
description: data.description,
|
||||
type: data.type,
|
||||
visibilite: data.visibilite,
|
||||
etablissementId: data.etablissementId,
|
||||
demandeContactId: data.demandeContactId,
|
||||
demandeMiseEnRelationId: data.demandeMiseEnRelationId,
|
||||
creePar: data.creePar,
|
||||
});
|
||||
const canalId = (result as { insertId: number }).insertId;
|
||||
if (membreIds.length > 0) {
|
||||
await tx.insert(membresCanauxDiscussion).values(
|
||||
membreIds.map((userId) => ({ canalId, userId, role: "membre" as const }))
|
||||
);
|
||||
}
|
||||
return canalId;
|
||||
});
|
||||
const canalId = (result as any).insertId as number;
|
||||
if (data.membreIds.length > 0) {
|
||||
await db.insert(membresCanauxDiscussion).values(
|
||||
data.membreIds.map((uid) => ({ canalId, userId: uid, role: "membre" as const }))
|
||||
);
|
||||
}
|
||||
return canalId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1121,12 +1158,14 @@ export async function sendMessageCanal(data: {
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("DB unavailable");
|
||||
await db.insert(messagesCanaux).values(data);
|
||||
// Mettre à jour updatedAt du canal
|
||||
await db
|
||||
.update(canauxDiscussion)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(canauxDiscussion.id, data.canalId));
|
||||
// L'ajout du message et le rafraîchissement de l'ordre des conversations sont indissociables.
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(messagesCanaux).values(data);
|
||||
await tx
|
||||
.update(canauxDiscussion)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(canauxDiscussion.id, data.canalId));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { appRouter } from "./routers";
|
||||
import { COOKIE_NAME } from "../shared/const";
|
||||
import type { TrpcContext } from "./_core/context";
|
||||
@@ -247,3 +247,43 @@ describe("auth.loginLocal - validation", () => {
|
||||
).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tests : politiques d'écriture et validation des messages ─────────────────
|
||||
|
||||
describe("politique lecture seule", () => {
|
||||
it("bloque une mutation d'administration même pour un gestionnaire", async () => {
|
||||
const readonlyGestionnaire = makeUser({ sonumRole: "gestionnaire", role: "readonly" });
|
||||
const caller = appRouter.createCaller(makeCtx(readonlyGestionnaire));
|
||||
|
||||
await expect(
|
||||
caller.admin.updateRole({ userId: 2, sonumRole: "referent" })
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("bloque l'envoi de message pour un compte lecture seule avant tout accès aux données", async () => {
|
||||
const readonlyReferent = makeUser({ role: "readonly" });
|
||||
const caller = appRouter.createCaller(makeCtx(readonlyReferent));
|
||||
|
||||
await expect(
|
||||
caller.canaux.sendMessage({ canalId: 1, contenu: "Message de test" })
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation des contenus libres", () => {
|
||||
it("rejette un message de canal constitué uniquement d'espaces", async () => {
|
||||
const caller = appRouter.createCaller(makeCtx(makeUser()));
|
||||
|
||||
await expect(
|
||||
caller.canaux.sendMessage({ canalId: 1, contenu: " " })
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("rejette une demande de mise en relation dont le message est vide après normalisation", async () => {
|
||||
const caller = appRouter.createCaller(makeCtx(makeUser()));
|
||||
|
||||
await expect(
|
||||
caller.miseEnRelation.soumettre({ sujet: "Recherche partenaire", message: " " })
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user