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

@@ -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));
});
}
/**