Établissements & structures — détails techniques
Description et usage : Établissements & structures.
Rangement des structures par académie
La vue arborescente s'appuie sur la hiérarchie de sous-structures d'Open ENT (relation Neo4j
(:Structure)-[:HAS_ATTACHMENT]->(:Structure)). La mise en place de ce rangement est assurée par le
script scripts/organize-structures-by-academy.mjs (dépôt open-ent-mods, Node ESM, sans
dépendance).
Ce que fait le script
Constat : les établissements sont déjà regroupés par type via des conteneurs globaux (COLLEGE,
LYCEE, PRIMAIRE, MATERNELLE) mais pas par académie. Le script crée le niveau académique
manquant et y rattache ces conteneurs, sans déplacer le moindre établissement :
Académie de <Nom>
├── Collèges – <Nom> (= conteneur COLLEGE renommé)
├── Lycées – <Nom> (= conteneur LYCEE renommé)
└── DSDEN – <Nom> (niveau départemental, écoles)
├── Écoles élémentaires – <Nom> (= conteneur PRIMAIRE renommé)
└── Écoles maternelles – <Nom> (= conteneur MATERNELLE renommé)
Les collèges et lycées relèvent directement de l'académie (rectorat) ; les écoles maternelles et élémentaires relèvent de la DSDEN (DASEN).
Mécanismes (API officielle de l'annuaire, super-admin)
| Opération | Endpoint |
|---|---|
| Créer un conteneur | POST /directory/school {name} (le feeder génère id/externalId, pose source=MANUAL et crée les ProfileGroups) |
| Renommer une structure | PUT /directory/structure/:id {name} |
| Rattacher une sous-structure | PUT /directory/structure/:enfant/parent/:parent (crée HAS_ATTACHMENT, avec garde anti-boucle) |
Les libellés d'académie (academy) et les types sont lus dans Neo4j. Le script est idempotent
(re-jouable sans effet de bord) et tourne en DRY-RUN par défaut ; ajouter --apply pour exécuter.
node scripts/organize-structures-by-academy.mjs # simulation
node scripts/organize-structures-by-academy.mjs --apply # exécution
Variables d'environnement : ENT_URL, ENT_USER, ENT_PASSWORD, NEO4J_URL, ONLY_ACADEMY.
Ce mode suppose une académie unique (réutilisation des conteneurs de type existants). Pour plusieurs académies, un mode « recréer par académie + déplacer les établissements » serait nécessaire. Les structures purement administratives (rectorat, inspections, services) restent au niveau racine.
Actions groupées sur une sélection d'établissements rattachés
Écran apps/dashboard/src/app/structure-info (StructureChildrenTable.tsx pour la sélection,
BulkStructureActionsDialog.tsx pour les quatre actions).
Périmètre côté serveur
Aucune route ne se fie à la liste de structureId envoyée par le client. Chacune vérifie le
périmètre réel de l'appelant via GET /directory/structure/admin/list (tous les établissements
pour un SUPER_ADMIN, le scope de la fonction — hérité aux rattachés — pour un ADMIN_LOCAL),
grâce au helper partagé src/pages/api/_perimeter.ts (resolveAdminPerimeter,
validateBulkTargets) — le même que celui du
paramétrage en masse des droits applicatifs : tout structureId hors périmètre est rejeté (403), et le nombre
d'établissements par requête est plafonné à MAX_STRUCTURES = 40 (au-delà, l'appelant doit
découper sa sélection).
ADMIN_COLLECTIVITE n'apparaît dans aucune condition CASL de ce périmètre : cette fonction
n'accorde par elle-même aucun droit d'écriture entcore (mesuré : 0 rôle appregistry pour un
compte ne portant que cette fonction). Le panneau d'actions groupées reste néanmoins visible pour
ce profil côté frontend (page-view.tsx : canBulkManage) — c'est le 403 serveur qui protège
réellement, pas l'affichage. Un compte de collectivité opérationnel doit porter aussi
ADMIN_LOCAL avec héritage sur la structure de tête du territoire.
Les quatre actions
| Action | Route | Détail |
|---|---|---|
| Application | POST /api/admin/access-bulk (existante, réutilisée telle quelle) | Boucle un appel par profil coché, levels: { [displayName]: niveau } |
| Blocage | PUT /api/users/block (étendue) | Accepte structureId (historique) ou structureIds: string[] ; proxifie PUT /directory/structure/:id/profile/block par établissement |
| Export CSV | POST /api/users/export-bulk (nouvelle) | Boucle GET /directory/export/users?format=csv par établissement, fusionne en un seul tableau (colonnes Établissement/UAI ajoutées) via un analyseur CSV tolérant aux guillemets ; bascule sur un fichier à sections séparées si les en-têtes diffèrent d'un établissement à l'autre |
| RGPD | DELETE /api/users/reset-preference (étendue) | Accepte structureId: string | 'all' (historique) ou structureIds: string[] ; boucle la requête Cypher REMOVE uac.<application> par établissement |
Chaque route renvoie un résultat par établissement (applied / skipped / error), affiché
tel quel dans le dialogue — jamais un simple booléen global.
Ouverture d'un établissement et compte de direction
L'assistant /structure-info/new-etablissement écrit par deux appels seulement.
Création de la structure
POST /api/structures (Pages Router, pages/api/structures/index.ts) : relit la fiche du
référentiel national, refuse un UAI déjà présent, crée l'école (POST /directory/school) puis
ses classes (POST /directory/class/:schoolId), et enrichit le nœud Structure de sa position
(latitude/longitude/uaiRef) avec un GeoJSON minimal poussé côté admin-dashboard.
Le name n'est transmis que si l'administrateur a modifié le nom pré-rempli ; sinon le
serveur applique la norme <nom>-<commune>, et renvoie le nom réellement retenu.
Création du chef d'établissement
POST /api/structures/:id/direction (App Router,
app/api/structures/[id]/direction/route.ts), corps
{ firstName, lastName, email, sendMail }.
| # | Sous-étape | Appel entcore |
|---|---|---|
| 1 | Fonction « Chef d'établissement » | GET/POST /directory/positions (UserPosition, source MANUAL) |
| 2 | Compte | POST /directory/api/user — type=Personnel, structureId, positionIds |
| 3 | Adresse email | PUT /directory/user/:id |
| 4 | Administrateur local | POST /directory/user/function/:id — ADMIN_LOCAL, scope:[structureId], inherit:"s" |
| 5 | Groupe Direction (si présent) | GET /directory/group/admin/list puis POST /directory/user/group/:userId/:groupId |
| 6 | Courriel de bienvenue | transporteur du dashboard (createTestTransporter) |
Pourquoi une route d'orchestration. Ces six opérations existent déjà séparément dans le dashboard, mais n'ont de sens qu'ensemble : un compte sans ADML est inutile, un ADML qui n'a jamais reçu son code ne se connectera pas. Les enchaîner côté serveur évite qu'un onglet fermé au mauvais moment laisse un établissement à moitié ouvert.
Pourquoi ce n'est pas tout-ou-rien. Seule la création du compte est bloquante. Les autres
sous-étapes rendent compte de leur sort dans un tableau steps: { key, label, ok, skipped?, note?, error? }[] sans faire échouer l'appel : l'échec le plus probable — le SMTP injoignable —
ne doit pas annuler un compte déjà créé, qu'entcore ne sait pas défaire proprement (son
identifiant resterait pris et le prochain essai buterait sur l'unicité). L'interface affiche
alors identifiant et code d'activation à recopier.
Deux pièges du modèle entcore
createUserne prend pas d'adresse email.DirectoryController#createUserne lit quefirstname,lastname,type,birthDate,childrenIds,positionIdset le rattachement (classId/structureId) : l'adresse ne peut être posée qu'en second appel.- Le groupe « Direction » n'existe pas sur une structure créée à la main. Le
DirectionGroupnatif et leFunctionGroup« DIRECTION-Func » sont tous deux produits par l'import AAF. D'où le recours à uneUserPosition, portée par la structure et créable depuis l'interface, pour dire que ce compte dirige l'établissement ; le rattachement au groupe n'est tenté que là où il existe.
Reprise de l'étape sur un compte existant
POST /api/users/:id/invitation (app/api/users/[id]/invitation/route.ts), corps
{ structureId, recipient?, grantAdml? }. Même forme de réponse que la route ci-dessus — le
tableau steps s'affiche avec le même composant.
Les sous-étapes vivent dans utils/directionAccount.ts, partagé par les deux routes :
ensureChefPosition, addPositionToUser, grantAdminLocal, attachToDirectionGroup,
sendDirectionWelcomeMail, plus le contrôle d'accès callerMayActOn (repris de
/api/users/[id]/activation-mail, qui l'importe désormais au lieu d'en garder une copie).
Un GET sur la même route prépare le dialogue : le compte est-il encore activable, quelle
adresse porte-t-il, dans quels établissements est-il, et y est-il déjà administrateur
local. La fiche ne répond à aucune de ces questions de façon sûre, et l'IHM ne doit pas
proposer d'accorder un droit déjà accordé.
Trois garde-fous côté serveur :
- Compte encore activable — sans code d'activation, le courriel n'a rien à transmettre : 409, avec renvoi vers « Code renouvellement ».
- Établissement rattaché au compte — le courriel le nomme, et les droits y seraient sinon posés hors de tout rattachement : 400.
callerMayActOn— ce chemin n'envoie pas le courriel via entcore, il reproduit donc son filtreAnyAdminOfUser; sans quoi la route serait la porte dérobée par laquelle n'importe quel compte connecté ferait envoyer un code d'activation. L'attribution de l'ADML, elle, reste filtrée par entcore (AddFunctionFilter).
Deux différences de comportement avec la création :
- L'adresse n'est écrite sur la fiche que si le compte n'en avait aucune. L'administrateur qui écrit ponctuellement ailleurs (un secrétariat, le temps d'une prise de fonction) ne demande pas pour autant à remplacer celle du compte.
- Les positions sont fusionnées, jamais remplacées.
PUT /directory/user/:idécrase l'ensemble despositionIds:addPositionToUserrepart deuserPositionsrelu sur la fiche, et abandonne l'étape si cette liste n'est pas lisible — écraser les fonctions déjà portées par le compte serait pire que de ne rien faire.
Le bouton, lui, est réservé au super-administrateur (currentUser.permissions.isSuperAdmin)
et n'apparaît que sur un compte non activé.
Courriel de bienvenue
Gabarit dédié utils/directionWelcomeMail.ts, et non massmail.mail.txt du thème : le chef
d'établissement reçoit ce message une seule fois, au moment de l'ouverture de son établissement,
et doit y lire le nom de la structure et son rôle d'administrateur local — pas un simple rappel
d'identifiant. Le gabarit vit dans le dashboard plutôt que dans assets/themes/<skin>/template/
pour n'être à recopier dans aucun thème ; en contrepartie il part par le transporteur du
dashboard (Administration ▸ Configuration ▸ Email), comme le fait déjà
/api/users/[id]/activation-mail pour une adresse hors compte.
L'adresse publique de l'ENT est reconstruite depuis l'hôte du navigateur (X-Forwarded-Host) :
l'appel serveur à serveur ne connaît que l'hôte interne du cluster, et un courriel invitant à se
connecter sur http://springboard:8090 n'aiderait personne.
Gabarit éditable. Le texte du courriel — objet, titre, accroche, paragraphe sur le rôle,
libellé du bouton, signature, mention de pied de page — et son habillage — couleur d'accent,
nom de marque, logo — sont réglés dans Administration ▸ Configuration ▸ ENT ▸ Emails,
onglet « Invitation chef d'établissement ». Ils sont stockés par admin-dashboard dans la
collection Mongo email_templates sous la clé direction-invitation
(EmailTemplateResource.defaultsFor), et lus par fetchInvitationTemplate.
Les champs texte acceptent les jetons {{etablissement}}, {{prenom}}, {{nom}},
{{identifiant}}, {{code}} et {{url}}, substitués par applyMailTokens. Un jeton inconnu
est laissé tel quel plutôt que vidé : l'administrateur qui s'est trompé de nom le voit dans
l'aperçu, au lieu d'un trou silencieux dans le courriel envoyé.
Deux champs vont par paire avec leur variante « sans droits » — subjectNoAdmin et
introNoAdmin : l'invitation peut partir sans accorder l'ADML, et annoncer un rôle que le
compte n'a pas mènerait son titulaire à un écran d'administration vide. Le paragraphe
adminNote disparaît alors lui aussi.
Trois niveaux de repli, pour qu'un courriel porteur d'un code d'activation parte toujours :
- le gabarit enregistré ;
- les champs de marque du gabarit
demo-request— celui-ci existe depuis plus longtemps, et le courriel garde ainsi les couleurs de la plateforme pendant toute la fenêtre de déploiement, avant qu'admin-dashboardne connaisse la nouvelle clé (il répond alors un objet vide). Les champs de texte ne sont jamais empruntés àdemo-request, qui parle d'une demande de découverte ; DIRECTION_INVITATION_DEFAULTS, copie côté dashboard des valeurs par défaut du serveur.
Un champ enregistré vide est une valeur voulue (une signature effacée) et n'est pas remplacé par le défaut ; seul un champ absent l'est.
Résolution du nom d'établissement. GET /directory/structure/:id n'est pas fiable : sa
réponse dépend du vhost servi, et c'est ce qui faisait tomber le courriel sur son repli
« votre établissement » en production. resolveStructureName retombe donc sur
GET /directory/structure/admin/list, celle qu'utilise déjà l'annuaire des structures. Mieux :
les deux appelants passent le nom qu'ils connaissent déjà — la route d'invitation le lit dans les
structureNodes de la fiche, l'assistant transmet le nom retenu à la création.
Le rôle annoncé est conditionnel (isAdml) : l'invitation peut partir sans que les droits
d'administrateur local soient accordés, et annoncer un rôle que le compte n'a pas mènerait son
titulaire à un écran d'administration vide.
Couverture de tests
La vue arborescence est couverte par les tests e2e du dashboard — dossier
apps/dashboard-e2e/src/modules/03_admin.
Scénario e2e — voir le détail
Admin — gestion des structures 03_gestion_structures.spec.ts
- liste des structures, recherche, détail, métriques
- vue arborescence par académie (
?view=tree)
Admin — opérations de masse 07_operations_masse.spec.ts
- blocage/déblocage, export CSV, réinitialisation RGPD (établissement courant)
- actions groupées sur une sélection d'établissements rattachés (
structure-info) : découverte dynamique d'un établissement de tête dans le jeu de données, sélection par case à cocher, ouverture des quatre onglets — non destructif (aucune action confirmée) - cas négatif : un administrateur local simple ne voit ni case à cocher ni bouton « Actions groupées »
Admin — ouverture d'un établissement et compte de direction
16_creation_chef_etablissement.spec.ts
- lien pré-rempli : UAI non modifiable, effectif et chef d'établissement repris de l'URL
- parcours complet jusqu'à la confirmation : identifiant et code d'activation affichés
- étape facultative passée : l'établissement reste créé, aucun compte de direction
- courriel en échec : le compte existe et son code reste lisible
- les deux écritures (
POST /api/structuresetPOST /api/structures/:id/direction) sont interceptées : elles ne se défont pas proprement, et ce test a vocation à être rejoué pour régénérer les captures de la documentation
Admin — invitation depuis la fiche utilisateur (même fichier)
- présence du bouton et pré-remplissage du dialogue (établissement, destinataire, case des droits cochée quand le compte n'est pas encore ADML)
- envoi et compte rendu par action, code d'activation toujours affiché
- cas négatif : aucun bouton sur un compte déjà activé
- la fiche et les deux appels d'invitation sont simulés ;
/api/mene l'est pas — c'est de lui que dépend l'affichage du bouton, et le simuler reviendrait à tester le décor plutôt que la règle. Le test se passe quand le profil connecté n'est pas super-administrateur.