Le champ users.niveau représente la progression scolaire d'un lycéen. Les passages sont loggés dans user-niveau-history (userId, oldNiveau, newNiveau, reason, createdAt).

Pourquoi ce champ existe

parcours-lyceens.niveau est déclaratif : il ne change que si le lycéen ressoumet le questionnaire. users.niveau est calculé et avance tout seul via les crons décrits dans « Passage de niveaux », même si le lycéen ne se reconnecte jamais.

Cet écart est voulu : à la reconnexion, l'app compare le dernier parcours validé (lastValidatedNiveau) au niveau courant (users.niveau) pour détecter qu'une ou plusieurs années scolaires se sont écoulées sans nouveau questionnaire, et déclenche le bon comportement (redemander le questionnaire, afficher un bandeau, rediriger vers la page post-bac...) — voir « Comportement à la reconnexion selon le décalage niveau » plus bas.

Valeurs possibles

Seconde → Première → Terminale → En transition → Post-bac

null = utilisateur non lycéen, ou lycéen dont le niveau n'a pas encore été renseigné.


Passage de niveaux

Automatisation du champ users.niveau : trois crons annuels, un backfill one-shot, et une mise à jour en temps réel via le questionnaire.

Algo 1 — progressAnnualNiveaux (27/08 chaque année)

Cron : 0 0 27 7 * (lib cron v2, mois 0-indexé : 7 = août)

Date décalée depuis le 01/08 (2026) pour étaler les migrations de rentrée scolaire sur plusieurs jours et éviter une charge serveur simultanée. Voir docs/cron-tasks.md pour le planning complet.

Population ciblée : tous les lycéens (roles @> ['lyceen']) avec un niveau renseigné.

Transitions :

Seconde       → Première
Première      → Terminale
Terminale     → En transition

Ordre d'exécution : décroissant (Terminale en premier, Seconde en dernier). Sans cet ordre, un lycéen en Seconde serait promu Première puis Terminale dans la même passe.

Chaque transition est atomique : UPDATE + INSERT dans user-niveau-history dans une transaction. Reason : cron_aout.


Algo 2 — progressEnTransitionToPostbac 01/12 (non-MVLS MATCHE)

Cron : 0 0 1 11 * (lib cron v2, mois 0-indexé : 11 = décembre)

Population ciblée : lycéens avec niveau = 'En transition' qui ne sont pas MVLS inscrits avec un binôme actif.

Condition d'exclusion MVLS :

NOT EXISTS (
  SELECT 1 FROM "mvls-lyceens" ml
  WHERE ml."userId" = "users"."id"
    AND ml."charte" = true
    AND ml."statut" = 'MATCHE'
    AND ml."deletedAt" IS NULL
)

Transition : En transition → Post-bac

Reason : cron_decembre.


Algo 3 — progressEnTransitionToPostbac 10/01 (MVLS MATCHE)

Cron : 0 0 10 0 * (lib cron v2, mois 0-indexé : 0 = janvier)

Population ciblée : lycéens avec niveau = 'En transition' qui sont MVLS inscrits avec un binôme actif, c'est-à-dire ceux qui ont un enregistrement dans mvls-lyceens avec charte = true ET statut = 'MATCHE'.

charte = true = soumission finale du questionnaire MVLS effectuée. statut = 'MATCHE' = binôme trouvé et actif (alimenté par dema1n via RabbitMQ). Ne pas confondre avec users.mvls = 'mvls' qui est posé dès le début du questionnaire — voir « MVLS » plus bas.

Transition : En transition → Post-bac

Reason : cron_janvier.


Backfill (one-shot, déclenché manuellement)

Endpoint admin : POST /admin/niveau/backfill

Deux étapes exécutées dans l'ordre, idempotentes :

Étape 1 — depuis parcours-lyceen

Pour les lycéens avec niveau IS NULL, copie le niveau de la ligne parcours-lyceens la plus récente avec niveau IS NOT NULL.

UPDATE "users" u
SET "niveau" = pl."niveau"
FROM (
  SELECT DISTINCT ON ("userId") "userId", "niveau"
  FROM "parcours-lyceens"
  WHERE "niveau" IS NOT NULL
  ORDER BY "userId", "createdAt" DESC
) pl
WHERE u."id" = pl."userId"
  AND u."niveau" IS NULL

Reason : backfill.

Étape 2 — pré-plateforme

Pour les lycéens encore sans niveau après l'étape 1, créés avant le 31/07/2023 : force Post-bac. Ces utilisateurs existaient avant la mise en production de la feature niveau ; ils sont considérés comme post-bac par défaut.

Reason : backfill.


Mise à jour en temps réel (questionnaire)

À chaque soumission ou mise à jour de parcours-lyceens (via /formulaire ou mise à jour partielle), si le champ niveau est présent et valide, users.niveau est synchronisé immédiatement.

Reason : questionnaire.


Traçabilité

Chaque modification de users.niveau crée une ligne dans user-niveau-history :

Colonne Description
userId ID de l'utilisateur
oldNiveau Valeur avant (null si premier passage)
newNiveau Valeur après
reason Source : questionnaire, cron_aout, cron_decembre, cron_janvier, backfill, admin
createdAt Horodatage

Dashboard admin disponible sur /admin/niveau : répartition actuelle + historique des exécutions.

Outils de recette (/admin/niveau)

  • Inspecter un utilisateur (GET /admin/niveau/user/:userId/inspect) : niveau courant, niveau du dernier parcours-lycéen validé, statut MVLS (charte/statut MATCHE), historique des passages, transitions simulées (août + post-bac) et comportement attendu à la reconnexion. Cette prédiction utilise predictLyceenReconnexion (front/src/services/users/reconnexion.util.ts), la même fonction que AuthCallback.vue — source unique de vérité. Le "dernier parcours validé" (le plus récent avec un algoResult complet) est calculé par getLastValidatedParcours, exporté par le même fichier et utilisé aussi bien par AuthCallback.vue que par AccueilBandeaux.vue, pour éviter toute divergence de tri.

Comportement à la reconnexion selon le décalage niveau

Dernier parcours validé users.niveau Comportement
Seconde Première / Terminale Questionnaire LY, étape 1
Seconde En transition / Post-bac Page reconnexion post-bac (Devenir Éclaireur / Dema1n / Indiquer redoublement)
Première Terminale Accueil + bandeau "Terminale" (pousse à refaire le questionnaire LY)
Première En transition Accueil + bandeau "En transition" (CTA "j'ai redoublé", fermable via la croix)
Première Post-bac Page reconnexion post-bac
Terminale En transition Accueil + bandeau "En transition"
Terminale Post-bac Page reconnexion post-bac

Les bandeaux "Terminale" et "En transition" sont rendus par AccueilBandeaux.vue, qui recalcule predictLyceenReconnexion en continu à partir de l'utilisateur courant (pas seulement au moment de la redirection) : ils disparaissent automatiquement dès que parcours-lyceens.niveau rejoint users.niveau (après un nouveau questionnaire). Le bandeau "En transition" a en plus une croix de fermeture définitive (localStorage, clé inspire_bandeau_transition_dismissed) ; le bandeau "Terminale" n'en a pas — il ne se ferme qu'en refaisant le questionnaire.

Le CTA "j'ai redoublé" (depuis le bandeau "En transition" ou depuis la page reconnexion post-bac) grise toujours Seconde et Première sur la première page du questionnaire LY (disabledNiveaux=Seconde,Première), quel que soit le niveau d'origine : arriver sur ces écrans signifie que le niveau courant est déjà considéré "en transition" ou "post-bac", donc la seule position encore possible pour un lycéen qui redouble est Terminale. - Lancer un passage sur un utilisateur : les boutons rejouent les crons (POST /admin/niveau/run-cron/:cron?userId=) sur le seul utilisateur inspecté, puis rechargent l'inspection pour afficher l'avant/après. - Créer un profil lycéen de test (POST /admin/niveau/test-user) : crée un compte lycéen local Inspire (username/password) avec un niveau courant et, en option, un parcours validé (qui pilote la reconnexion). ⚠ Le compte n'est pas créé sur Article 1 Connect : l'inscription réelle est un flux hébergé côté A1C (redirection front), non déclenchable depuis le back — il n'existe pas d'API A1C de création côté Inspire. Option « MVLS matché » : crée le mvls-lyceen via le flux canonique MvlsLyceenService.createOrUpdate (qui émet emitUserCreatedMvlsUSER_CREATED vers Dema1n) puis force statut = MATCHE pour tester le passage post-bac de janvier. - Se connecter en tant que (POST /admin/niveau/user/:userId/login-token, hors production) : comme il n'existe pas de formulaire de connexion local (login = SSO A1C uniquement), cet endpoint émet un jeton via UserJwtService.login que le front consomme sur /auth/callback?loginToken=... pour ouvrir l'app connecté au compte, sans passer par A1Connect. Permet d'observer le vrai comportement à la reconnexion sur un compte de test.

MVLS

Les lycéens MVLS sont inscrits sur deux plateformes : Inspire (niveau scolaire) et Dema1n (programme de mentorat). C'est ce statut MVLS qui détermine, dans « Passage de niveaux » ci-dessus, si un lycéen bascule en Post-bac le 1er décembre (Algo 2, non-MVLS) ou le 10 janvier (Algo 3, MVLS MATCHE).

Passage Postbac — flux cross-platform

Quand un LY MVLS passe en Postbac sur Inspire, il doit quitter le programme MVLS côté Dema1n (HORS_PROGRAMME) car le programme ne couvre pas le post-bac.

Deux crons, deux comportements

Date Cron Périmètre Notif cross-platform
01/12 progressEnTransitionNonMvls Tous sauf MVLS (charte=true + MATCHE) Aucune
10/01 progressEnTransitionMvls MVLS uniquement (charte=true + MATCHE) USER_POSTBAC → Dema1n

Le décalage de date (décembre vs janvier) laisse aux LY MVLS le temps de finaliser leur suivi avant d'être basculés.

Convention email mvls_

Sur Inspire, les comptes MVLS ont un username de la forme mvls_prenom@email.com. Le préfixe est strippé avant l'envoi RabbitMQ — Dema1n le ré-ajoute de son côté pour retrouver l'utilisateur (findByEmail('mvls_' + body.email)).

Ce que ça fait côté Dema1n

USER_POSTBAC reçu → trouve le jeune → changeStatusJeune(HORS_PROGRAMME). Les binômes actifs ne sont pas annulés automatiquement.

Débugger un LY MVLS non basculé

  1. Vérifier les logs Inspire : ✅ progressEnTransitionMvls : N lycéens MVLS passés en Post-bac
  2. Si N > 0, vérifier les logs Dema1n : [MVLS] Traitement USER_POSTBAC pour email: ...
  3. Si absent, vérifier que RabbitMQ est up — exchange dema1n, routing key user___mvls, queue Dema1n_mvls
  4. Si l'utilisateur n'est pas trouvé côté Dema1n, vérifier que son email Inspire correspond bien à mvls_<email> en base Dema1n