Draft: ✨ feat(stats): add aggregated stats routes for articles, authors and crossed treemap
refacto/dashboard-routes — Stats agrégées & treemap croisée
Contexte
Ajout des routes backend pour alimenter le dashboard de dataviz bibliométrique (ReviewPilot). Le dashboard expose trois colonnes : statistiques auteurs, statistiques articles, et statistiques croisées (treemap imbriquée à 2 ou 3 niveaux).
Fichiers ajoutés
routes/
aggregatedStats.articles.route.ts — stats agrégées de la collection articles
aggregatedStats.authors.route.ts — stats agrégées de la collection authors
aggregatedStats.crossed.route.ts — treemap croisée article × author
utils/
aggregatedStats.fieldMap.ts — whitelist et métadonnées des champs autorisés
aggregatedStats.toTree.ts — reshape du résultat MongoDB en arbre imbriqué
aggregatedStats.SimpleCache.ts — cache mémoire générique avec TTL
Routes exposées
| Méthode | Endpoint | Description |
|---|---|---|
| GET | /api/stats/articles |
Stats agrégées articles via $facet
|
| GET | /api/stats/authors |
Stats agrégées authors via $facet
|
| GET | /api/stats/crossed |
Treemap croisée avec jointure article |
| POST | /api/stats/articles/refresh |
Invalide le cache articles du projet |
| POST | /api/stats/authors/refresh |
Invalide le cache authors du projet |
| POST | /api/stats/crossed/refresh |
Invalide tous les caches croisés du projet |
Logique métier
Routes articles et authors
Chaque route exécute un pipeline MongoDB $match + $facet en une seule requête.
Le $facet calcule toutes les répartitions en parallèle sur le même ensemble de documents filtrés par projectId.
Route crossed
Pipeline MongoDB dynamique construit à partir des paramètres de la requête :
-
$matchsur leprojectId -
$lookuppour joindre le first author de chaque article — uniquement si au moins un des champs demandés appartient à la collection authors -
$matchdes filtres (valeurs sélectionnées par l'utilisateur sur les chips) - Dénormalisation des champs multivalués avec pondération : un article avec N valeurs pour un champ tableau contribue
1/Nà chacune — la somme des counts reste toujours égale au nombre d'articles -
$groupdynamique selon les niveaux demandés - Reshape du résultat plat en arbre imbriqué via
toTree()
Pondération des champs multivalués
Les champs barriers, methodology, dataTypesDiscussed, positionOnDataOpenAccess sont des tableaux.
Sans pondération, un article avec 3 barrières pèserait 3 fois plus qu'un article avec 1 barrière.
La pondération normalise : chaque article pèse toujours 1, réparti équitablement entre ses valeurs.
Query params — route crossed
| Param | Requis | Exemple | Description |
|---|---|---|---|
projectId |
oui | abc123 |
ObjectId du projet |
levels |
oui | barriers,gender |
2 ou 3 champs séparés par virgule |
filters[field] |
non | filters[language]=FR,EN |
Valeurs à inclure, séparées par virgule |
refresh |
non | true |
Force le recalcul en bypassant le cache |
Champs disponibles pour la treemap croisée
Articles — language, openAccess, dataTypesDiscussed, methodology, barriers, pubyear, selectedSubfield, objectFocus, positionOnDataOpenAccess
Authors — gender, status
isArray est inféré directement depuis le schéma Mongoose — pas déclaré manuellement.
Cache
Cache mémoire in-process avec TTL d'1 heure, partagé entre les trois routes via SimpleCache<T>.
Chaque combinaison projectId + levels + filters est cachée séparément pour la route crossed.
Nettoyage automatique des entrées expirées toutes les 10 minutes.
⚠️ Cache in-process : non partagé entre instances. Si scaling horizontal → migrer vers Redis.
Réponse — route crossed
{
totalCorpus: number // total articles du projet sans aucun filtre
totalFiltered: number // total articles après application des filters
levels: string[] // champs dans l'ordre des niveaux
tree: TreeNode[] // arbre imbriqué pour le rendu de la treemap
}
type TreeNode = {
label: string | null // null si la valeur était absente dans MongoDB
count: number // arrondi à 2 décimales, peut être fractionnaire
children: TreeNode | null // null aux feuilles (dernier niveau)
}