API Analytics (dashboard admin)
Cette page documente /api/v1/analytics/* : une API REST read-only, authentifiée par token machine-à-machine, destinée à un dashboard d'administration qui vit dans un dépôt séparé, sans accès au code de Kutsum. Le contrat ci-dessous doit donc être suffisant pour écrire un client typé sans jamais lire une ligne de source — types, nullabilité, exemples réels, codes d'erreur, tout est explicite.
Contrairement au reste d'api.md, cette API n'est pas authentifiée par cookie/JWT utilisateur : elle est consommée côté serveur par un job/route handler du dashboard, jamais depuis un navigateur (voir Authentification).
Base URL : /api/v1/analytics
Format des dates : toutes les dates sont des chaînes ISO-8601 en UTC (ex. 2026-08-05T14:02:11.483Z), jamais en heure locale. Un bucketStart / monthStart de série temporelle est toujours un minuit UTC exact.
Authentification
Chaque route de cette API exige un en-tête :
Authorization: Bearer <token>
- Absent, malformé, inconnu, expiré ou révoqué →
401avec le même codeANALYTICS_UNAUTHORIZEDdans tous les cas (voir Codes d'erreur — c'est délibéré : distinguer ces cas permettrait à un attaquant de sonder l'existence d'un token). - La portée (scope) de la réponse — globale (toutes organisations confondues) ou restreinte à un établissement — vient exclusivement du token présenté, jamais d'un en-tête ou d'un paramètre envoyé par le client. Un token d'établissement ne peut pas être élargi, un token global ne peut pas être restreint après coup.
- Un appel réussi met à jour
lastUsedAtsur le token (repérage d'un token dormant ou fuité) — cette écriture ne peut jamais faire échouer la requête.
Obtenir un token
Il n'existe pas de route pour créer un token (v1 volontairement fermée). La provision se fait par un script CLI, côté serveur Kutsum :
# Depuis app/backend/
npm run analytics:token -- create --label "dashboard prod"
npm run analytics:token -- create --label "lycée X" --org lycee-x
npm run analytics:token -- create --label "temporaire" --expires 2027-01-01T00:00:00Z
- Sans
--org, le token est global (agrégat toutes organisations). - Avec
--org <subdomain>, le token est restreint à cet établissement. - Le token en clair est affiché une seule fois, à la création. Seul son hash SHA-256 est stocké en base — il ne peut pas être récupéré après coup. Le perdre implique d'en créer un nouveau et de révoquer l'ancien.
npm run analytics:token -- list # liste les tokens (métadonnées uniquement, jamais le hash ni le token)
npm run analytics:token -- revoke <id>
Une fois révoqué, tout appel avec ce token renvoie immédiatement 401.
Expiration et révocation
expiresAtest optionnel (--expires <ISO-8601>à la création). Un token sans expiration reste valide jusqu'à révocation explicite.revokedAtest renseigné paranalytics:token -- revoke <id>— c'est la seule voie de révocation en v1 (pas de self-service).- Passé l'expiration ou après révocation, toute route de cette API renvoie
401 ANALYTICS_UNAUTHORIZED, immédiatement et sans délai de grâce.
⚠️ Le token ne doit jamais atteindre un navigateur
C'est un identifiant bearer : dans du JavaScript client, il devient un secret public. Le dashboard doit appeler cette API côté serveur (route handler / job), jamais depuis le navigateur de l'utilisateur final.
Limite de débit (rate limiting)
60 requêtes GET par minute, par token (comptées sur la clé = hash du token, pas sur l'IP — plusieurs dashboards derrière la même IP de sortie ne se pénalisent pas entre eux).
Au-delà, chaque route renvoie :
{
"error": "Too many requests. Please retry later.",
"code": "API_RATE_LIMIT_EXCEEDED"
}
avec un statut 429. Il n'y a pas d'en-tête Retry-After — la fenêtre est fixe (60 s) et redémarre à la première requête acceptée après expiration ; en cas de 429, espacez les nouvelles tentatives d'au moins quelques secondes plutôt que de réessayer en boucle serrée.
GET /api/v1/analytics/meta
Plus petit appel authentifié : indique la portée réelle du token présenté. Sert aussi de vérification de credentials au démarrage du dashboard.
Authentification : requise (voir ci-dessus)
Paramètres : aucun
Réponse 200 :
| Champ | Type | Nullable | Description |
|---|---|---|---|
scope | "global" | "organization" | non | Portée effective du token. |
organizationId | string | oui — null ssi scope === "global" | Identifiant de l'établissement, ou null en portée globale. |
computedAt | string (ISO-8601, UTC) | non | Instant de calcul de la réponse. /meta ne lit aucune table d'agrégat : c'est simplement "maintenant" (à distinguer de computedAt sur active-users, voir plus bas). |
Exemple (token d'établissement) :
curl -sS https://app.kutsum.org/api/v1/analytics/meta \
-H 'Authorization: Bearer <token>'
{
"scope": "organization",
"organizationId": "3f1b4b8a-2c4e-4b0a-9b7a-8e6f0f1a2b3c",
"computedAt": "2026-08-05T14:02:11.483Z"
}
GET /api/v1/analytics/active-users
Série temporelle du nombre d'utilisateurs actifs distincts, à une résolution choisie par le serveur — le client ne devine jamais la granularité, il la lit dans la réponse.
Authentification : requise
Paramètres de requête :
| Nom | Type | Obligatoire | Valeurs | Défaut |
|---|---|---|---|---|
range | string | oui | 30d | 1y | all | aucun — absent ou invalide → 400 ANALYTICS_INVALID_RANGE |
Résolution (granularity) choisie par le serveur
range demandé | Token global | Token d'établissement |
|---|---|---|
30d | day | week |
1y | week | week |
all | month | month |
Un token d'établissement ne reçoit jamais day : DailyActiveStats (la table qui alimente la granularité journalière) n'a pas de dimension établissement — c'est une propriété assumée du contrat, pas un manque tu. range=30d sur un token d'établissement est donc automatiquement rabattu sur week.
⚠️ Non-additivité — ne jamais sommer les points
Chaque point est un décompte d'utilisateurs distincts dans SON seau, et seulement dans son seau. Un enseignant actif lundi et mardi de la même semaine vaut 2 sur deux points day (1 chacun), et exactement 1 sur le point week correspondant. Ne sommez jamais les points d'une série (ex. pour reconstituer un "MAU" en additionnant des points day) — le résultat serait 3 à 5 fois trop haut, avec une courbe d'allure tout à fait plausible.
⚠️ Granularité month : points approximés
Quand granularity vaut month (systématique pour range=all), chaque point est marqué "approximate": true. Raison : la table source (ActorWeeklyActivity) est hebdomadaire, pas mensuelle. Une semaine à cheval sur deux mois calendaires est comptée dans les deux mois qu'elle touche — l'utilisateur actif la dernière semaine de juillet et la première d'août apparaît dans le total de juillet et dans celui d'août. C'est une légère sur-estimation assumée (arbitrage mainteneur), jamais une sous-estimation qui masquerait de l'activité réelle. Ne traitez pas un point approximate: true comme un chiffre exact.
Réponse 200
| Champ | Type | Nullable | Description |
|---|---|---|---|
scope | "global" | "organization" | non | Portée effective du token. |
organizationId | string | oui — null ssi scope === "global" | Établissement, ou null en portée globale. |
range | "30d" | "1y" | "all" | non | Fenêtre effectivement servie (écho du paramètre). |
granularity | "day" | "week" | "month" | non | Résolution choisie par le serveur — voir tableau ci-dessus. |
dataAvailableFrom | string (ISO-8601) | oui — null si aucune donnée n'existe encore pour ce scope | Plus ancien point pour lequel une donnée réelle existe, indépendamment de range demandé. Les tables d'agrégats n'existent que depuis le 2026-07-08 : sans ce champ, un dashboard tracerait une ligne plate jusqu'en 2025 au lieu de "pas de mesure". |
computedAt | string (ISO-8601, UTC) | non | max(updatedAt) des lignes d'agrégat effectivement lues pour construire cette réponse (fraîcheur réelle — pas une date de job planifié, il n'y en a pas : les tables sont alimentées par un flush horaire). |
points | array | non | Série continue — voir ci-dessous. |
Élément de points :
| Champ | Type | Nullable | Description |
|---|---|---|---|
bucketStart | string (ISO-8601, UTC) | non | Début du seau (jour / lundi de la semaine / 1er du mois selon granularity). |
activeUsers | number (entier ≥ 0) | non | Utilisateurs distincts actifs dans ce seau uniquement. |
approximate | boolean | non | true ssi granularity === "month" (voir caveat ci-dessus). |
partial | boolean | non | true pour le seau en cours (aujourd'hui / cette semaine / ce mois) — pas encore terminé, à ne pas lire comme une chute d'activité. |
La série est continue : chaque seau de la fenêtre est présent, y compris ceux sans aucune activité (activeUsers: 0) — jamais de trou à combler côté client.
Exemple — token global, range=30d (granularité day) :
curl -sS 'https://app.kutsum.org/api/v1/analytics/active-users?range=30d' \
-H 'Authorization: Bearer <token>'
{
"scope": "global",
"organizationId": null,
"range": "30d",
"granularity": "day",
"dataAvailableFrom": "2026-07-08T00:00:00.000Z",
"computedAt": "2026-08-05T13:00:04.221Z",
"points": [
{ "bucketStart": "2026-07-07T00:00:00.000Z", "activeUsers": 0, "approximate": false, "partial": false },
{ "bucketStart": "2026-07-08T00:00:00.000Z", "activeUsers": 6, "approximate": false, "partial": false },
{ "bucketStart": "2026-08-04T00:00:00.000Z", "activeUsers": 19, "approximate": false, "partial": false },
{ "bucketStart": "2026-08-05T00:00:00.000Z", "activeUsers": 12, "approximate": false, "partial": true }
]
}
Exemple — token d'établissement, range=30d (rabattu sur week) :
curl -sS 'https://app.kutsum.org/api/v1/analytics/active-users?range=30d' \
-H 'Authorization: Bearer <token-etablissement>'
{
"scope": "organization",
"organizationId": "3f1b4b8a-2c4e-4b0a-9b7a-8e6f0f1a2b3c",
"range": "30d",
"granularity": "week",
"dataAvailableFrom": "2026-07-06T00:00:00.000Z",
"computedAt": "2026-08-05T13:00:04.221Z",
"points": [
{ "bucketStart": "2026-07-06T00:00:00.000Z", "activeUsers": 4, "approximate": false, "partial": false },
{ "bucketStart": "2026-07-13T00:00:00.000Z", "activeUsers": 5, "approximate": false, "partial": false },
{ "bucketStart": "2026-07-20T00:00:00.000Z", "activeUsers": 3, "approximate": false, "partial": false },
{ "bucketStart": "2026-07-27T00:00:00.000Z", "activeUsers": 6, "approximate": false, "partial": false },
{ "bucketStart": "2026-08-03T00:00:00.000Z", "activeUsers": 4, "approximate": false, "partial": true }
]
}
Exemple — range=all (granularité month, approximée) :
{
"scope": "global",
"organizationId": null,
"range": "all",
"granularity": "month",
"dataAvailableFrom": "2026-07-08T00:00:00.000Z",
"computedAt": "2026-08-05T13:00:04.221Z",
"points": [
{ "bucketStart": "2026-07-01T00:00:00.000Z", "activeUsers": 41, "approximate": true, "partial": false },
{ "bucketStart": "2026-08-01T00:00:00.000Z", "activeUsers": 27, "approximate": true, "partial": true }
]
}
GET /api/v1/analytics/growth
Séries cumulatives mensuelles (enseignants inscrits, activités créées, questions créées), calculées en direct depuis User.createdAt / GameInstance.createdAt / Question.createdAt — jamais depuis les tables d'agrégats horaires que lit active-users.
C'est un endpoint volontairement séparé, avec un schéma séparé, de active-users. Un cumul ne fait que monter ; un décompte d'actifs oscille. Ne comparez jamais un champ de growth à un champ de active-users comme s'ils mesuraient la même chose.
Contrairement à active-users, growth a une profondeur historique complète dès le premier jour : User/GameInstance/Question existent depuis toujours, alors que les tables d'agrégats d'active-users ne remontent qu'au 2026-07-08.
Authentification : requise
Paramètres de requête :
| Nom | Type | Obligatoire | Valeurs | Défaut |
|---|---|---|---|---|
range | string | oui | 30d | 1y | all | aucun — absent ou invalide → 400 ANALYTICS_INVALID_RANGE |
Réponse 200
| Champ | Type | Nullable | Description |
|---|---|---|---|
scope | "global" | "organization" | non | Portée effective du token. |
organizationId | string | oui — null ssi scope === "global" | Établissement, ou null en portée globale. |
range | "30d" | "1y" | "all" | non | Fenêtre effectivement servie. |
computedAt | string (ISO-8601, UTC) | non | Instant de calcul — growth ne lit aucune table d'agrégat (requête live sur User/GameInstance/Question), donc c'est simplement "maintenant", comme sur /meta — pas la convention max(updatedAt) d'active-users. |
points | array | non | Un point par mois calendaire, toujours croissant ou stable — jamais décroissant. |
Élément de points :
| Champ | Type | Nullable | Description |
|---|---|---|---|
monthStart | string (ISO-8601, UTC) | non | 1er jour du mois. |
teachersRegistered | number (entier ≥ 0) | non | Nombre cumulé d'enseignants inscrits jusqu'à la fin de ce mois inclus. |
activitiesCreated | number (entier ≥ 0) | non | Nombre cumulé d'activités (GameInstance) créées jusqu'à la fin de ce mois inclus. |
questionsCreated | number (entier ≥ 0) | non | Nombre cumulé de questions créées jusqu'à la fin de ce mois inclus. |
partial | boolean | non | true pour le mois en cours (pas encore terminé). |
Exemple :
curl -sS 'https://app.kutsum.org/api/v1/analytics/growth?range=all' \
-H 'Authorization: Bearer <token-etablissement>'
{
"scope": "organization",
"organizationId": "3f1b4b8a-2c4e-4b0a-9b7a-8e6f0f1a2b3c",
"range": "all",
"computedAt": "2026-08-05T13:00:04.221Z",
"points": [
{ "monthStart": "2026-04-01T00:00:00.000Z", "teachersRegistered": 3, "activitiesCreated": 5, "questionsCreated": 12, "partial": false },
{ "monthStart": "2026-05-01T00:00:00.000Z", "teachersRegistered": 4, "activitiesCreated": 9, "questionsCreated": 18, "partial": false },
{ "monthStart": "2026-06-01T00:00:00.000Z", "teachersRegistered": 5, "activitiesCreated": 14, "questionsCreated": 27, "partial": false },
{ "monthStart": "2026-07-01T00:00:00.000Z", "teachersRegistered": 5, "activitiesCreated": 18, "questionsCreated": 33, "partial": false },
{ "monthStart": "2026-08-01T00:00:00.000Z", "teachersRegistered": 6, "activitiesCreated": 22, "questionsCreated": 40, "partial": true }
]
}
GET /api/v1/analytics/organizations/:organizationId/members
(et sa variante sans identifiant : GET /api/v1/analytics/organizations/members)
La seule route nominative de cette API — chaque autre route ci-dessus est strictement agrégée (aucun userId, username ni email n'y apparaît jamais). Celle-ci liste les enseignants rattachés à un seul établissement nommé, avec le statut de leur rattachement, leur dernière période d'activité et quelques compteurs simples d'adoption.
Authentification : requise (voir Authentification)
Deux formes d'URL, un seul comportement serveur :
| Route | Utilisation |
|---|---|
GET /organizations/:organizationId/members | Forme nommée. Pour un token d'établissement, le segment :organizationId de l'URL est ignoré — la portée du token gagne toujours (même règle que pour toutes les autres routes : la portée vient exclusivement du token, jamais d'une valeur envoyée par le client). Pour un token global, ce segment est obligatoire et utilisé tel quel. |
GET /organizations/members | Forme courte, réservée à un token d'établissement qui n'a pas besoin de connaître son propre identifiant. Un token global qui appelle cette forme ne nomme d'établissement nulle part (ni sa portée, ni l'URL) → 400 ANALYTICS_ORGANIZATION_REQUIRED. |
Paramètres :
| Nom | Type | Obligatoire | Description |
|---|---|---|---|
organizationId (segment d'URL) | string | selon la forme d'URL utilisée (voir le tableau ci-dessus) | Identifiant de l'établissement à consulter. Toujours ignoré si le token appelant est lui-même restreint à un établissement. |
Comptes exclus de la liste
- Seuls les comptes de rôle enseignant (
TEACHER) apparaissent — élèves et invités (GUEST) en sont exclus. - Les comptes enseignants éphémères (inscription MathALEA, démo page d'accueil) sont exclus : ils ne sont jamais rattachés à un établissement, donc absents par construction de cette vue (pas de filtre dédié à écrire ni à maintenir).
- Un enseignant rattaché à plusieurs établissements apparaît dans chaque vue où il est effectivement rattaché, avec les compteurs propres à cet établissement à chaque fois — jamais un mélange des deux.
Réponse 200
⚠️ Contrairement à /meta, active-users et growth, cette réponse n'a pas de champ scope — la table ci-dessous est exhaustive (schéma .strict() côté serveur : tout champ non listé ici n'est jamais émis).
| Champ | Type | Nullable | Description |
|---|---|---|---|
organizationId | string | non | Établissement effectivement consulté. Jamais null sur cette route — contrairement à /meta, active-users et growth, elle résout toujours vers un établissement précis, c'est son unique raison d'être. |
computedAt | string (ISO-8601, UTC) | non | max(updatedAt) des lignes de ActorWeeklyActivity lues pour ces enseignants (la seule table d'agrégat que consulte cette vue) ; "maintenant" si aucun enseignant de la liste n'a jamais été actif. Les compteurs d'adoption (activitiesCreated, personalQuestionsCreated) sont toujours calculés en direct — ils ne bornent jamais cette valeur, exactement comme sur growth. |
members | array | non | Un élément par enseignant rattaché — voir ci-dessous. |
Élément de members :
| Champ | Type | Nullable | Description |
|---|---|---|---|
userId | string | non | Identifiant stable de l'enseignant. |
username | string | non | Nom d'utilisateur. |
email | string | absent (jamais null) si l'enseignant n'a pas d'adresse email | Adresse institutionnelle. Un client doit tester la présence de la clé ('email' in member), jamais member.email === null — le champ est omis, jamais émis à null. |
membershipStatus | string | non | Statut brut du rattachement ("active" en pratique aujourd'hui — c'est une colonne texte libre côté serveur, pas une énumération fermée : traitez toute valeur inconnue comme non bloquante plutôt que de valider contre une liste fixe). |
lastActiveWeekStart | string (ISO-8601, UTC) | oui — null si l'enseignant n'a jamais été actif | Lundi de la semaine de la dernière activité recensée pour cet enseignant (précision hebdomadaire assumée — c'est un signal d'adoption, pas un outil de pointage). null est une valeur informative à part entière, jamais omise du payload. |
activitiesCreated | number (entier ≥ 0) | non | Activités (GameInstance) créées par cet enseignant au sein de cet établissement précisément — un enseignant rattaché à deux établissements peut avoir des valeurs différentes selon la vue consultée. |
personalQuestionsCreated | number (entier ≥ 0) | non | Nombre total de questions personnelles créées par cet enseignant. ⚠️ Ce compteur n'est PAS restreint par établissement : une question personnelle n'est jamais rattachée à un établissement dans ce système, donc ce nombre est identique quel que soit l'établissement depuis lequel on consulte cet enseignant — à la différence d'activitiesCreated. |
Exemple — token d'établissement, forme courte :
curl -sS https://app.kutsum.org/api/v1/analytics/organizations/members \
-H 'Authorization: Bearer <token-etablissement>'
{
"organizationId": "3f1b4b8a-2c4e-4b0a-9b7a-8e6f0f1a2b3c",
"computedAt": "2026-08-05T13:00:04.221Z",
"members": [
{
"userId": "7e2a1c3d-9f0b-4a5c-8d6e-1b2c3d4e5f60",
"username": "marie.dupont",
"email": "marie.dupont@lycee-x.example",
"membershipStatus": "active",
"lastActiveWeekStart": "2026-07-27T00:00:00.000Z",
"activitiesCreated": 12,
"personalQuestionsCreated": 34
},
{
"userId": "0a1b2c3d-4e5f-4061-8283-84858687888a",
"username": "j.martin",
"membershipStatus": "active",
"lastActiveWeekStart": null,
"activitiesCreated": 0,
"personalQuestionsCreated": 0
}
]
}
(le second enseignant, j.martin, n'a pas d'adresse email en base — le champ email est absent de l'objet, pas null ; il n'a par ailleurs jamais été actif, d'où lastActiveWeekStart: null.)
Exemple — token global, forme nommée :
curl -sS https://app.kutsum.org/api/v1/analytics/organizations/3f1b4b8a-2c4e-4b0a-9b7a-8e6f0f1a2b3c/members \
-H 'Authorization: Bearer <token-global>'
Réponse de forme identique à l'exemple précédent.
Exemple — token global, forme courte, sans établissement nommé (400) :
curl -sS https://app.kutsum.org/api/v1/analytics/organizations/members \
-H 'Authorization: Bearer <token-global>'
{
"error": "This view always requires a named organization: a global-scoped token must call GET /organizations/:organizationId/members",
"code": "ANALYTICS_ORGANIZATION_REQUIRED"
}
Erreurs spécifiques à cette route
| Code | Statut HTTP | Condition exacte |
|---|---|---|
ANALYTICS_ORGANIZATION_REQUIRED | 400 | Token global appelant la forme courte (GET /organizations/members) — aucun établissement n'est nommé, ni par la portée du token ni par l'URL. |
ANALYTICS_ORGANIZATION_NOT_FOUND | 404 | L'établissement effectivement résolu (après application de la règle "la portée du token gagne sur l'URL") n'existe pas, ou est inactif (isActive: false). Un établissement inactif est traité comme absent — même convention que la résolution de tenant sur les routes publiques. |
(Ces deux codes s'ajoutent à ANALYTICS_UNAUTHORIZED, API_RATE_LIMIT_EXCEEDED et ANALYTICS_INTERNAL_ERROR, communs à toutes les routes de cette API — voir Codes d'erreur ci-dessous.)
Codes d'erreur
Un client doit toujours distinguer sur code (machine-readable), jamais sur error (prose humaine, peut changer sans préavis).
| Code | Statut HTTP | Condition exacte |
|---|---|---|
ANALYTICS_UNAUTHORIZED | 401 | En-tête Authorization absent, malformé, ou token inconnu / expiré / révoqué. Un seul code pour tous ces cas, volontairement (ne pas laisser un client déduire si un token existe). |
ANALYTICS_INVALID_RANGE | 400 | Paramètre range absent, ou différent de 30d, 1y, all (active-users, growth). |
ANALYTICS_ORGANIZATION_REQUIRED | 400 | Token global appelant la vue nominative sans nommer d'établissement (GET /organizations/members — voir plus haut). Code volontairement distinct d'ANALYTICS_INVALID_RANGE : un client qui teste code ne doit jamais confondre "plage invalide" et "cette vue exige un établissement nommé". |
ANALYTICS_ORGANIZATION_NOT_FOUND | 404 | Établissement résolu inexistant ou inactif, sur la vue nominative (voir plus haut). |
API_RATE_LIMIT_EXCEEDED | 429 | Plus de 60 requêtes en 60 secondes pour ce token — voir Limite de débit. |
ANALYTICS_INTERNAL_ERROR | 500 | Erreur serveur inattendue. |
Forme d'un corps d'erreur (tous les codes ci-dessus) :
{
"error": "Human-readable message (peut changer)",
"code": "ANALYTICS_UNAUTHORIZED"
}