Démarches — détails techniques
Description et fonctionnalités de ce module : Démarches.
Couverture de tests
Les modèles de document (CGU, chartes, accessibilité) sont couverts par des tests unitaires
— DocumentModeleTest (16 cas : rendu, échappement, sections, filtres, contexte ent.) et
GabaritsDocumentsTest (5 cas : marqueurs confrontés au formulaire, rendu complet des cinq
gabarits, validité des workflows livrés) — et par le scénario e2e
12_documents_reglementaires.spec.ts (création d'une démarche de réseau, recopie du modèle,
édition + archivage de la version précédente, diffusion aux établissements et son rejeu sans
doublon, approbation, PDF). Ce scénario exige un super-administrateur — lui seul crée une
démarche de réseau — et s'ignore proprement sinon :
E2E_SUPERADMIN_USER=… E2E_SUPERADMIN_PASS=… ./run-tests.sh --project=superadmin.
Le certificat de scolarité, l'identité de l'établissement et le changement de régime
sont couverts par des tests unitaires — CachetTest (16 cas : qualité déduite de la nature de
l'établissement et de la civilité, accord, échappement, cachet et en-tête composés), RegimesTest
(nomenclature complète, codes historiques dépréciés mais lisibles), EcheanceTest (12 cas :
ouverture/fermeture aux bornes, borne illisible sans effet, distinction délai/fermeture manuelle) —
et par deux specs e2e écrites sur le même modèle que 12_documents_reglementaires.spec.ts :
13_certificat_scolarite.spec.ts (création, production par classe, contenu du PDF, rejeu sans
doublon, demande individuelle et son refus hors périmètre) et
14_changement_regime_cantine.spec.ts (nomenclature DP4/DP5, refus après l'échéance). La totalité
du parcours — création de la campagne, composition et lecture du branding sur Structure, envoi du
courriel avec le PDF en pièce jointe (vérifié dans MailHog), rendu final comparé à la capture de
référence — a été rejouée manuellement en local (launcher.sh --server local deploy-file,
rattacher_actions_certificats_workflowhub.py) ; les deux specs Playwright elles-mêmes n'ont pas
encore été exécutées via run-tests.sh sur cette instance — à faire avant mise en recette.
Le reste de la suite 45_workflowhub a été rejoué en local après le changement de portée
(2026-09-09) : 14 scénarios verts, et deux échecs antérieurs et étrangers à ce changement,
vérifiés en rejouant les mêmes specs sur l'artefact précédent — 04_administration (le compte de
test ne porte pas l'action workflowhub.admin sur cette instance : 401 à la promotion) et
08_reglement_double_paraphe (l'élève de test a déjà paraphé, dejaSigne — résidu de données).
Dix scénarios e2e couvrent les cycles de bout en bout des profils de démarche, les
fonctions d'administration, le classement par étiquettes, le préremplissage nominatif, les
autorisations permanentes du carnet de correspondance, le scellement électronique de la
convention de stage et les documents réglementaires — dossier
apps/open-ent-e2e/src/modules/45_workflowhub.
10 tests e2e — voir le détail
WorkflowHub — pré-inscription en ligne (cycle complet) 01_preinscription.spec.ts (profil
provisionnant)
- concepteur (création d'une campagne depuis le modèle + publication) → édition graphique du workflow (personnalisation des transitions) → diffusion (lien public / QR, invitation par e-mail, démarche ciblée) → famille (dépôt et envoi anonymes d'un dossier responsable + élève) → suivi (prise en charge → acceptation, export Word / CSV) → provision (création des comptes élève + responsable dans l'ENT) → trousseau d'activation (login + code des comptes créés).
WorkflowHub — sondage / consultation des familles 02_sondage.spec.ts (profil collecte)
- concepteur (création d'une campagne depuis le modèle « Sondage / consultation des familles » + publication) → diffusion (lien public / QR) → famille (consultation du sondage et envoi anonyme d'une réponse) → suivi (la réponse apparaît à l'état « Envoyé », export CSV / Excel). Vérifie l'absence d'action de décision et de provision — le profil collecte se limite au recueil et à l'export des réponses. Reproduit l'expérience « Formulaires et sondages » d'EcoleDirecte.
WorkflowHub — invitation à la réunion parents-professeurs 15_reunion_parents_professeurs.spec.ts
(profil collecte)
- concepteur (création d'une campagne depuis le modèle « Invitation à la réunion
parents-professeurs de rentrée » + publication) → famille (le formulaire public expose la
question de modalité — sur place / visioconférence / indisponible — comme un
<select>, vérifié par ses options plutôt que par un texte visible, le paragraphe d'invitation citant déjà les trois formules) → deux dépôts anonymes, l'un en présentiel, l'autre en visioconférence → suivi (les deux dossiers apparaissent à l'état « Envoyé », sans action de décision ni de provision) ; - assertion propre au modèle : le détail d'une réponse affiche la modalité choisie en toutes lettres (« Je participerai à la réunion → À distance, en visioconférence ») avec le seul bloc correspondant renseigné — le bloc « sur place » d'une réponse en visioconférence reste vide.
- Rejoué en local le 2026-09-11, vert.
WorkflowHub — voyage scolaire (diffusion ENT + suivi prof / vie-scolaire) 03_voyage_scolaire.spec.ts
(profil décisionnel)
Les étapes du scénario, dans l'ordre :
- Conception — création d'une campagne depuis le modèle « Autorisation de voyage / sortie scolaire » (sortie du territoire / CERFA, attestation sanitaire, renseignements famille).
- Personnalisation du workflow — édition graphique des transitions de la démarche.
- Publication — ouverture de la démarche.
- Diffusion ciblée à une classe — envoi aux parents d'une classe (utilisateurs ENT) : un dossier pré-rempli par destinataire + notification fil de nouveautés et e-mail avec un lien personnel.
- Réponse du parent — la famille complète et envoie son dossier personnel.
- Suivi du taux de réponse — l'émetteur voit qui a répondu / reste à répondre.
- Décision — prise en charge puis acceptation du dossier (sans provision de comptes).
- Rediffusion à toutes les classes — « Tout cocher » puis diffusion : le compte rendu doit annoncer les destinataires déjà servis et le suivi ne doit contenir aucun élève en double. C'est le garde-fou de la diffusion multi-classes (ignoré si l'établissement de test n'a qu'une classe).
Exécuté pour les deux modes : vie-scolaire (chef / personnel) et enseignant (le professeur gère ses propres démarches).
WorkflowHub — administration : modèles partagés 04_administration.spec.ts (profil ADML)
- promotion en modèle d'établissement (le modèle apparaît au choix d'une nouvelle démarche,
marqué « Mon établissement ») → suppression du modèle → garde-fous de la copie
inter-établissement (copie sans cible refusée 400 ; cible non administrée interdite 403 ;
copie réelle si l'ADML administre ≥ 2 établissements). Réservé aux profils disposant d'un périmètre
ADML / super-admin (droit
workflowhub.admin) ; ignoré sinon.
WorkflowHub — étiquettes administrables par établissement 05_etiquettes.spec.ts
(profil gestionnaire)
- création d'une étiquette depuis l'éditeur d'une démarche + affectation → affectation multiple (plusieurs étiquettes par démarche) → badges + filtres par étiquette dans la liste → administration (renommage et suppression d'une étiquette, reflétés sur les démarches). Les étiquettes sont cloisonnées par établissement.
WorkflowHub — préremplissage nominatif (vue famille) 06_prefill_nominatif.spec.ts
(profil gestionnaire)
- complète
03_voyage_scolairepar une visite navigateur du lien personnel reçu par une famille après diffusion à une classe : le formulaire public s'ouvre déjà rempli de ce que l'ENT connaît (élève rattaché, nom/prénom/date de naissance ; coordonnées du responsable — nom, prénom, e-mail, téléphone, adresse), à l'exception du consentement et des informations propres à la démarche. Parmi les destinataires diffusés, retient celui dont le dossier est le mieux renseigné par l'annuaire, pour rester robuste face à des données de test incomplètes.
WorkflowHub — autorisations permanentes & régime scolaire 07_autorisations_permanentes.spec.ts
(profil gestionnaire)
- cycle complet du modèle régime scolaire : concepteur → publication → diffusion à une classe → réponse de la famille par l'API publique → décision (prise en charge, acceptation) ;
- assertions propres à l'intégration avec le carnet de correspondance : le brouillon diffusé porte
payload.eleve.userId(sans lui la démarche existe mais reste invisible du carnet) ; le statut vautEN_COURStant que l'établissement n'a pas tranché, puisACCORDE; enfin le carnet lui-même (GET /schoolbook/api/fiches, qui passe par le bus) voit le même état, et un personnel n'y reçoit pas le détail de la fiche d'urgence ; - un second test vérifie que les trois modèles du carnet sont proposés au concepteur, aux côtés de l'autorisation de voyage ponctuelle, qu'ils ne remplacent pas.
Règlement intérieur — double paraphe élève + responsable 08_reglement_double_paraphe.spec.ts
(profil gestionnaire + deux comptes famille)
- cycle complet avec trois comptes réels : l'établissement crée et diffuse la démarche à la classe de l'élève de test, l'élève parafe depuis son compte, puis le responsable depuis le sien, puis l'établissement vise ;
- ce que le scénario verrouille : un seul paraphe laisse le règlement en
PARAPHE_PARTIELet le carnet nomme qui reste attendu ; reparapher renvoiedejaSignesans redater la signature ; le dossier ne passe àENVOYEqu'une fois les deux paraphes réunis ; un personnel est refusé (403) ; unchildIdhors périmètre est refusé (403 et non 404, pour ne pas confirmer qu'un élève existe). - Comptes de démo par défaut (mot de passe = login) surchargeables par
E2E_REGLEMENT_ELEVE_USER/_PASSetE2E_REGLEMENT_PARENT_USER/_PASS; le test s'ignore proprement s'ils n'existent pas sur l'instance visée.
Convention de stage — signature électronique (esign) 11_convention_stage_signature.spec.ts
(profil gestionnaire)
- concepteur → publication d'une campagne depuis le modèle « Convention de stage (PFMP) » →
provisionnement (au besoin) du cachet d'établissement via
POST /esign/cachets/:structureId→ dépôt et envoi d'une convention désignant le compte de test comme maître de stage (tuteur.userId) → signature (POST /conventions/:reference/parapher) ; - assertions sur le scellement réel produit par l'appel
esign.sceau:scellement.scelle === true,cachetSerialetdocumentIdprésents, niveau PAdES-BASELINE-T (horodaté) ; reparapher est sans effet sur la signature (dejaSigne) et laisse le scellement acquis ; - le PDF servi par
GET /conventions/:reference/pdf-scelleest bien le document scellé (Content-Type: application/pdf) ; le suivi établissement affiche le badge de cachet apposé. - Prérequis d'environnement : le module esign doit être accessible au compte de test et son
autorité de certification configurée (
ESIGN_CA_KEYSTORE_*,ESIGN_CACHET_KEK) — sans quoi le scénario s'ignore proprement en le signalant, plutôt que d'échouer.
Pièce jointe depuis l'espace documentaire 16_piece_jointe_espace_documentaire.spec.ts
(profil gestionnaire)
- dépose un document de test dans le workspace du compte (
POST /workspace/document), démarre une démarche ciblée, puis choisit ce document dans le sélecteur du formulaire : la pièce est rattachée au dossier (GET /pub/:token/dossier/:reference/pieces) et le document d'origine reste dans l'espace documentaire — c'est une copie qui part au dossier ; - un second scénario pose une démarche jetable dont l'ui-schema déclare
ui:widget: file, et vérifie que le champ rend bien un dépôt (et non un champ texte, cf. plus bas) et que la pièce porte le typechamp:<chemin>plutôt que le type librejustificatif; - non couvert : le dépôt depuis le disque, qui ouvre le sélecteur de fichiers natif de l'OS — déjà
exercé par injection dans
01_preinscription.
@SecuredAction : rôles à compléter au déploiementGET /workflowhub/autorisations/eleves, /autorisations/demarches et les huit routes du modèle
de document (campagnes/:id/document*, dossiers/:id/document.*) sont des actions nouvelles.
Le module les enregistre bien à son démarrage — les nœuds Action existent, avec le bon
displayName — mais entcore ne rattache pas une action nouvelle aux rôles déjà créés : sans
intervention, elles répondent 401 aux comptes qui ont pourtant déjà « WorkflowHub — Lecture » ou
« Gestion », et le symptôme ne dit rien de sa cause (on soupçonne le corps de la requête, ou le
module). Vécu en local le 2026-09-09 : l'onglet « Document » répondait 401 à un chef d'établissement.
Deux scripts idempotents, un par lot d'actions :
NEO4J_URL=http://localhost:7474 ./scripts/rattacher_actions_autorisations_workflowhub.py
NEO4J_URL=http://localhost:7474 ./scripts/rattacher_actions_documents_workflowhub.py # --dry-run d'abord
Le second rattache chaque action au périmètre de son propre droit (workflowhub.view aux rôles
de lecture, workflowhub.manage à ceux de gestion) : un rôle de lecture ne reçoit pas le droit de
réécrire le texte d'une charte. La session porte les actions autorisées : se reconnecter après.
Le carnet de correspondance, lui, passe par le bus et n'est pas concerné.
En local (miroir de la production), les rôles nommés « WorkflowHub — Lecture » et « Démarches —
Lecture » portent déjà des actions workflowhub.manage (4 et 9 respectivement), et « Démarches —
Lecture » porte même deux actions workflowhub.admin. Leur nom ne décrit donc pas leur contenu :
un compte à qui l'on croit n'accorder que la consultation peut concevoir et décider. Les scripts
ci-dessus ne font que suivre ce d écoupage existant — ils n'élargissent rien — mais cet écart
mérite d'être repris pour lui-même dans la gestion des accès.
Les deux modèles livrés le 11/09/2026 — « Fiche de renseignements et d'urgence »
(fiche-urgence-sante) et « Demande de rendez-vous avec un enseignant »
(demande-rendez-vous-enseignant) — sont couverts par GabaritsFormulairesTest (structure
form-schema/ui-schema/jeu d'exemple) et par les tests unitaires du module Appointments pour la
passerelle bus (19 tests, inchangés — la passerelle est un nouveau consommateur, elle ne modifie
aucun service existant). Aucun scénario e2e Playwright n'a encore été écrit pour ces deux
démarches : à faire avant mise en recette, en particulier le parcours de réservation
(choix enseignant → grille → créneau → envoi → créneau réservé dans Appointments), qui suppose un
compte de test avec au moins une grille de disponibilité ouverte côté Appointments.
Le modèle livré le même jour, « Invitation à la réunion parents-professeurs de rentrée »
(reunion-parents-professeurs), est lui couvert à la fois par GabaritsFormulairesTest et par
un scénario e2e complet, 15_reunion_parents_professeurs.spec.ts (détaillé ci-dessus) — à la
différence des deux précédents, il ne dépend d'aucun système tiers (pas d'Appointments, pas de
référentiel santé) : rien n'empêchait d'écrire le parcours de bout en bout dès sa livraison.
Architecture & choix de conception
Terminologie : type de workflow vs libellé UI
Côté code et base de données, chaque démarche embarque l'un de trois profils de workflow :
COLLECTE, DECISIONNEL ou PROVISIONNANT (ce dernier ajoute la création de comptes ENT à la
décision). Le sélecteur de modèle (NewCampaignPanel.tsx, KIND_INFO) affiche ces profils sous des
libellés plus parlants pour l'utilisateur — Collecte, Décision, Provision de comptes —
c'est ce vocabulaire que reprend la fiche fonctionnelle plutôt que les noms d'enum.
Champs référentiel : des API de catalogue réutilisables
Les données de référence (communes, codes postaux, matières, formations…) sont exposées comme des API de catalogue réutilisables, indépendantes d'un formulaire donné. Ajouter un nouveau type de champ revient à « brancher une source » plutôt qu'à coder un formulaire spécifique.
Le moteur de workflow : une machine à états progressive
Faut-il un moteur d'états générique ou des workflows figés ? L'approche retenue est progressive, du plus simple au plus ouvert :
- Workflows prédéfinis (collecte / décisionnel / provisionnant) — couvrent l'essentiel des besoins et sont disponibles d'emblée ;
- Workflow configurable — l'établissement personnalise les transitions et les états d'une campagne par édition graphique (livré) : il ajoute ses propres états intermédiaires et route les transitions sur mesure, au-dessus du socle canonique.
Cette progressivité évite d'imposer un moteur complexe avant d'en avoir l'usage, tout en gardant la trajectoire vers des workflows sur mesure. Techniquement, un dossier reste une machine à états : c'est le degré de configuration offert à l'administrateur qui s'ouvre par étapes.
Pré-remplissage : filtré par le schéma de la campagne
Le pré-remplissage de diffusion est générique (responsable + élève tels que l'annuaire les
connaît), les gabarits ne le sont pas : chacun déclare les champs qu'il accepte, la plupart en
additionalProperties: false. Une clé écrite dans le brouillon qu'un gabarit ne déclare pas passe
inaperçue — aucune validation n'a lieu à la création — et fait échouer l'envoi du dossier par la
famille, des jours plus tard, sur une erreur qu'elle ne peut pas corriger.
PrefillSchema.retainDeclared (testé unitairement, PrefillSchemaTest) élague donc le
pré-remplissage aux seuls champs du form_schema de la campagne, en suivant les $ref locaux vers
definitions. Un bloc sans properties, ou un gabarit qui accepte des propriétés libres, est laissé
intact : on n'efface que ce qu'on sait refusé.
C'est ce mécanisme qui permet d'ajouter eleve.userId au pré-remplissage — indispensable au carnet
de correspondance — sans casser les gabarits qui ne déclarent pas ce champ (voyage-scolaire,
sondage).
Diffusion multi-classes : rejouable, sans doublon
POST /workflowhub/campagnes/:id/diffusion accepte classeIds (tableau) ; classeId (une seule
classe) reste accepté pour les appels existants. DiffusionCibles.classeIds lit les deux formes,
écarte les valeurs vides et dédoublonne — testé unitairement (DiffusionCiblesTest).
Diffuser à plusieurs classes fait apparaître deux façons de servir deux fois la même famille, sur lesquelles reposent deux garde-fous :
- Un élève inscrit dans deux des classes visées. La requête Neo4j (
c.id IN {classeIds}) renvoie une ligne par appartenance ;DefaultReferentielService.recipientsne garde que la première pareleveId. - Une rediffusion.
createDiffusedDraftest unINSERTsec : sans filtre, rediffuser parce qu'une classe avait été oubliée recréerait un brouillon vierge et une seconde notification chez toutes les familles déjà servies.DossierService.diffusedEleveIdsrelit les élèves déjà destinataires sur la campagne etDiffusionCibles.aServirles retire de la cible. Le compte rendu renvoieignorespour que l'écran le dise, plutôt que de laisser croire à une diffusion partiellement perdue.
La première version lisait payload->'eleve'->>'userId'. Elle ne dédoublonnait rien : le
pré-remplissage est élagué au schéma du gabarit juste avant l'écriture (PrefillSchema,
ci-dessus), et voyage-scolaire déclare eleve en additionalProperties: false sans
userId — la clé était donc effacée du brouillon, et la rediffusion repartait aveugle. Le test
e2e l'a attrapé : 22 dossiers rediffusés en double.
Savoir pour qui un dossier a été diffusé relève du suivi de diffusion, pas des réponses de
la famille : c'est donc hors payload, dans deux colonnes dédiées ajoutées par
014-diffusion-cible.sql — diffusion_eleve_id et diffusion_classe_id. Les lectures gardent un
COALESCE(...) vers le payload pour les dossiers antérieurs à la migration.
La classe enregistrée est celle du destinataire, pas un paramètre global de la requête — sinon tous les dossiers d'une diffusion multi-classes hériteraient de la même classe.
Enfin les créations ne partent plus toutes de front : diffuseParLots les déroule par lots de 25.
Une classe ouvrait une trentaine d'écritures et d'e-mails simultanés ; un établissement entier en
ouvrirait plusieurs centaines, au détriment du pool SQL et du relais SMTP.
Le suivi (GET .../diffusion) rapproche diffusion_classe_id des libellés du référentiel pour
afficher une colonne Classe : sur une diffusion multi-classes, la liste des destinataires est
sinon illisible.
Autorisations permanentes : la vue du carnet de correspondance
AutorisationService expose, par élève et par année scolaire, l'état des trois démarches suivies
(AutorisationService.SUIVIS), sur HTTP et sur l'adresse de bus clusterisée
workflowhub.autorisations — c'est cette dernière que consomme le module schoolbook.
- L'état du dossier ne suffit pas :
AutorisationStatuts.oflit aussi la réponse de la famille (autorisations.participation,urgences.transportHopital). Un dossier accepté portant un refus explicite est un refus, pas une autorisation.regime-scolairen'a pas de booléen — la réponse y est un choix de régime — le dossier accepté fait alors foi. Testé unitairement (AutorisationStatutsTest). - Quatre statuts, pas deux :
ACCORDE,REFUSE,EN_COURS,ABSENT. Distinguer un refus d'une absence de réponse est nécessaire : seule la seconde appelle une relance. DISTINCT ON (élève, template): une démarche peut être refaite (correction, changement de régime en cours d'année) ; c'est la dernière décision qui vaut. Les brouillons sont exclus.- Année scolaire : une campagne sans
annee_scolairen'est pas hors périmètre, elle n'est simplement pas millésimée — le concepteur ne renseigne pas ce champ par défaut, et l'exclure rendait la démarche invisible du carnet sans que personne puisse comprendre pourquoi. Quand la campagne est millésimée, en revanche, l'année demandée doit correspondre.
Même modèle que ConsentementService pour le droit à l'image, généralisé à plusieurs modèles.
Règlement intérieur : un document signé, pas un formulaire rempli
Le template reglement-interieur s'écarte du modèle des autres démarches sur deux points, et
c'est ce qui fait toute sa valeur juridique.
- Le paraphe est posé par le serveur, au nom d'un compte ENT authentifié
(
ReglementService.parapher, exposé sur le busworkflowhub.autorisationssous l'actionparapher-reglement), jamais déclaré dans le formulaire public. Une case cochée anonymement n'engagerait personne : on ne saurait pas qui a signé. Les champsparaphes.*sont doncui:widget: hidden— ce qui a demandé de traiterhiddenavant les objets et les tableaux dansSchemaForm, faute de quoi le bloc entier restait affiché. - Il en faut deux.
Paraphes(testé unitairement,ParaphesTest) porte seul cette règle :apposerest idempotent — reparapher ne redate pas la signature, un second clic ne peut pas déplacer la date d'engagement — etetatdistingueAUCUN,PARTIELetCOMPLET. Le dossier ne passe deBROUILLONàENVOYEqu'au passage àCOMPLET: avant, l'établissement n'a rien à instruire.
Deux conséquences moins évidentes :
- Les brouillons ne peuvent plus être ignorés. Pour toutes les autres démarches un brouillon
est un formulaire abandonné ; ici il est le document en cours de signature. La lecture du
carnet les inclut donc dès qu'ils portent des paraphes
(
jsonb_exists(d.payload, 'paraphes')). - Un élève peut avoir plusieurs dossiers — la diffusion à une classe en crée un par
responsable. Lecture et écriture doivent donc s'accorder sur le même : c'est le rôle de
AutorisationService.ORDRE_DOSSIER_ELEVE, partagé mot pour mot par les deux requêtes. Le désaccord a été observé en test — un paraphe posé sur un dossier, un état lu sur un autre, et une famille qui signe un document en voyant l'état d'un second. Un dossier déjà paraphé l'emporte.
L'élève majeur parafe seul : la marque paraphes.eleveSeul est écrite dans le payload au
moment du paraphe, et pas seulement passée en paramètre — à la lecture, personne ne sait si l'élève
est majeur, et sans elle un dossier signé par un majeur resterait « en attente du responsable »
indéfiniment.
Portée réseau : une démarche adressée aux établissements
Les documents réglementaires ont demandé une seconde chose que le module ne savait pas faire : une
démarche sans établissement. Une campagne appartenait toujours à un établissement
(structure_id NOT NULL), et toutes les lectures étaient filtrées par les établissements de
l'appelant — un super-administrateur, qui n'en a aucun, ne pouvait donc rien créer ni voir. Le
symptôme s'est présenté comme un 400 au corps vide sur POST /campagnes (cf. la note plus bas).
Le patron existait déjà pour les modèles réutilisables (workflowhub.template, scope RESEAU,
structure_id NULL, visibles de tous) : la migration 017-portee-reseau.sql le reprend pour les
campagnes plutôt que d'inventer un second mécanisme.
| Démarche d'établissement | Démarche de réseau | |
|---|---|---|
campagne.portee | ETABLISSEMENT | RESEAU |
campagne.structure_id | l'établissement | NULL |
| Qui la crée | gestionnaire / ADML | super-administrateur seul |
| Qui la voit | son établissement | tous (le texte s'adresse à eux) |
| Qui la modifie | son établissement | super-administrateur seul |
| L'établissement concerné | celui de la démarche | celui de chaque demande (dossier.structure_id) |
| Le suivi liste | des familles | des établissements et leurs responsables |
Une contrainte CHECK (campagne_portee_structure) tient les deux colonnes d'accord : une
campagne « RESEAU » qui conserverait un structure_id serait invisible de tous les autres
établissements sans que rien ne le dise.
Les périmètres sont des prédicats SQL, pas des vérifications préalables. Trois helpers, portés par la requête qui lit ou qui écrit :
perimetre(...)(lecture d'une campagne) —structure_id IN (…) OR structure_id IS NULL;perimetreEcriture(…, superAdmin)— leIS NULLn'est ouvert qu'au super-administrateur : un gestionnaire d'un seul établissement ne doit pas pouvoir réécrire les CGU de tout le réseau, et voir un texte n'autorise pas à y toucher ;perimetreDossier(…, superAdmin)—COALESCE(d.structure_id, c.structure_id) IN (…): un chef d'établissement ne voit que la demande qui le concerne, le porteur du réseau les voit toutes.
Une vérification préalable suivie d'une écriture laisserait un intervalle où la portée peut changer ; le prédicat rend la règle indissociable de l'opération.
dossier.structure_id n'est pas rétro-rempli : sur une démarche d'établissement,
l'établissement du dossier est celui de la démarche, et le recopier créerait deux vérités à tenir
d'accord. La colonne ne porte donc que ce que la campagne ne dit pas — d'où le COALESCE partout.
Elle est posée par la diffusion aux établissements, ou dérivée de payload.etablissement.structureId
quand la demande est saisie directement.
POST /campagnes/:id/diffusion-etablissements crée une demande par établissement. Le
responsable est résolu dans l'annuaire : « chef d'établissement » s'y lit de deux façons selon
l'alimentation AAF — un DirectionGroup natif (filter: "Direction") ou un FunctionGroup de
sous-type DIRECTION-Func — on interroge les deux, en pratique c'est le second qui est peuplé.
Un établissement sans responsable identifié reçoit quand même sa demande, mais la réponse le
signale (sansResponsable) : c'est une donnée d'annuaire à corriger, pas un échec de la diffusion.
L'appel est rejouable (structuresServies), à l'image de la diffusion à une classe.
Modèles de document : ce que la démarche produit
Le module savait éditer ce qu'une démarche demande (form-schema + ui-schema, concepteur
graphique) ; les documents réglementaires ont demandé d'éditer ce qu'elle produit. Ce que le
code fournissait jusque-là — DefaultPdfService.recapPdf, recuPdf, conventionPdf — est du HTML
écrit en Java : suffisant pour un accusé de réception, inapplicable à un texte dont chaque
paragraphe est arrêté par un juriste et qui doit changer sans redéploiement.
Le modèle est une donnée, pas du code. Table workflowhub.document_modele (migration
016-document-modele.sql) : du HTML porteur de marqueurs {{chemin}}, propre à un établissement,
rattaché à une démarche par campagne.document_modele_id (ON DELETE SET NULL — supprimer un
modèle n'emporte ni la démarche ni ses dossiers). Chaque enregistrement archive l'état précédent
dans document_modele_revision, sur la convention de dossier_revision (015) : une ligne = le
texte avant la modification qui l'a remplacé.
Le texte livré n'est qu'une valeur initiale. resources/templates/<clé>/document.html est
recopié en base à la création de la démarche (CampagneController.attacheDocument, best-effort
après le 201), jamais référencé : le référencer ferait changer les CGU d'un établissement à la
première montée de version du module. DefaultTemplateService expose donc, en plus du form-schema,
documentTemplate, documentVariables, sampleData et workflowDef (ce dernier était déjà
attendu par le front, mais n'était pas servi).
Le moteur de rendu (core/export/DocumentModele, 15 tests unitaires) est un moustache réduit,
sans dépendance : {{valeur}} échappée, {{{fragment}}} brut, {{#section}}…{{/section}} répétée
sur un tableau, {{^section}} inversée, {{valeur|date}} pour écrire une date de formulaire en
clair. Trois choix méritent d'être connus :
- un marqueur inconnu rend le vide, jamais son propre nom — un document imprimé ne doit pas
laisser fuir
{{dpo.contact}}au milieu d'une phrase ; c'estmanquants()qui sert à prévenir l'utilisateur, et l'éditeur qui l'affiche ; - une section jamais fermée ne fait pas tomber le rendu : le texte est saisi à la main dans un navigateur, une accolade oubliée ne doit pas rendre le document impossible à produire ;
- le contexte serveur vit sous le préfixe réservé
ent.(ent.etablissement.*lu dans Neo4j — celui de la demande sur une démarche de réseau —,ent.responsable.*,ent.demarche.*,ent.dossier.*,ent.date,ent.approuve) et il est écrit après le payload : un champ de formulaire nomméentne peut pas usurper l'identité de l'établissement.
L'invariant qui compte est testé. Le modèle et son formulaire sont deux fichiers séparés :
renommer un champ du form-schema sans toucher au document ne se verrait qu'à l'impression, sous la
forme d'un blanc au milieu d'une mention légale obligatoire. GabaritsDocumentsTest confronte donc,
pour les cinq gabarits livrés, chaque marqueur du document au champ correspondant du formulaire
— en suivant la même pile de contextes que le moteur, pour que {{contenu}} à l'intérieur de
{{#nonConformites}} soit résolu sur les éléments du tableau — puis rend chaque document avec son
sample-data.json et vérifie qu'il ne reste ni marqueur, ni « [À COMPLÉTER ». Il vérifie enfin que
chaque workflow.json livré est accepté par Workflows.validate et mène effectivement à ACCEPTE.
Routes (lecture au droit workflowhub.view, écriture à workflowhub.manage) :
| Route | Rôle |
|---|---|
GET /workflowhub/campagnes/:id/document | modèle + ses marqueurs ; {absent:true} si la démarche n'en produit pas |
POST /workflowhub/campagnes/:id/document | crée et rattache un modèle (contenu, ou sourceKey d'un gabarit livré) |
PUT /workflowhub/campagnes/:id/document | remplace le texte, en archivant la version précédente |
DELETE /workflowhub/campagnes/:id/document | détache et supprime le modèle |
GET /workflowhub/campagnes/:id/document/revisions | versions successives du texte |
POST /workflowhub/campagnes/:id/document/apercu | rend le texte en cours d'édition avec un payload d'exemple (HTML) |
GET /workflowhub/dossiers/:id/document.html · .pdf | document produit par un dossier (PDF via PdfService.documentPdf) |
Les écritures passent par des CTE modifiantes plutôt que par plusieurs requêtes enchaînées
(DefaultDocumentModeleService) : le droit d'écrire — la démarche doit appartenir à un
établissement de l'appelant — est porté par la requête qui écrit, et l'archivage de la version
précédente ne peut pas se retrouver dissocié de la modification.
Le workflow livré (resources/templates/<clé>/workflow.json) réutilise les états canoniques
avec un autre sens : ENVOYE = soumis au responsable, EN_COURS = en relecture, COMPLEMENT =
correction demandée au rédacteur, ACCEPTE = approuvé, REFUSE = rejeté. Il ajoute un état
personnalisé — AVIS_DPO (documents juridiques) ou AVIS_REFERENT (documents d'accessibilité).
ACCEPTE n'est pas un état parmi d'autres : c'est lui que le document interroge
({{#ent.approuve}}) pour cesser de s'annoncer comme projet, ce qui interdit de renommer cet état
sans revoir les cinq modèles.
Effet de bord assumé côté front : le widget ui:widget: textarea était employé par des
ui-schemas livrés (observations du règlement intérieur) mais n'avait jamais été implémenté dans
SchemaForm — il retombait silencieusement sur un champ d'une ligne, où l'on saisissait un
paragraphe à l'aveugle. Il l'est désormais (ui:rows pour la hauteur).
Certificat de scolarité : un quatrième type de workflow, sans formulaire
Les trois profils historiques (COLLECTE, DECISIONNEL, PROVISIONNANT) supposent tous qu'une
famille remplit quelque chose. Le certificat de scolarité n'en a pas besoin : l'ENT connaît déjà
tout ce qu'il contient (état civil, classe, régime — u.accommodation porte le libellé exact que
l'établissement attend, ex. DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT 4). D'où un quatrième type,
DOCUMENT (migration 019-workflow-kind-document.sql, ALTER TYPE … ADD VALUE), déclaré sans
transitions dans core/workflow/Workflows.BY_KIND : un dossier de ce type naît déjà ACCEPTE,
il n'y a rien à décider.
CertificatController (nouveau) expose deux routes, toutes deux @SecuredAction neuves — donc
soumises au même piège que les routes de document (voir plus bas), à rattacher via
scripts/rattacher_actions_certificats_workflowhub.py :
| Route | Droit | Rôle |
|---|---|---|
POST /campagnes/:id/certificats/diffusion | workflowhub.manage | {classeIds[]|eleveIds[]} → un dossier + un PDF par élève, envoyé par courriel |
POST /campagnes/:id/certificats/mien | workflowhub.view | la famille réclame le certificat de l'élève lui-même ou d'un enfant dont elle est responsable |
La production, pas la collecte. CertificatService.elevesDesClasses / .eleves / .mesEleves
lisent l'annuaire (aucune table propre au module), .payload construit le payload du gabarit —
année scolaire par défaut à l'année courante si la campagne n'en porte pas (anneeScolaireCourante,
bascule en septembre, même règle que DefaultReferentielService.anneesScolaires), puisqu'un
certificat sans année n'a pas de sens. Chaque élève produit un dossier via
DossierService.createDocumentDelivre (statut='ACCEPTE', submitted_at/decided_at posés à
l'insertion), rend le document (DocumentModele.render), génère le PDF
(PdfService.documentPdf) et l'envoie en pièce jointe
(NotificationService.envoyerDocument, nouveau — voir ci-dessous). Traitement par lots de 10
(CertificatController.LOT, CompositeFuture.join — join et non all : l'échec d'un certificat
ne doit pas interrompre les suivants, les compteurs du bilan disent ce qui n'est pas passé).
Rejouable. dossier.diffusion_eleve_id (migration 014) sert de clé de dédoublonnage : un
élève déjà servi par cette campagne est ignoré et compté à part (ignores), sur le même principe
que DiffusionCibles.aServir pour les démarches ordinaires. La demande individuelle (/mien)
reproduit volontairement, pour qu'une famille obtienne toujours un exemplaire à jour.
Garde-fou de périmètre appliqué dans la requête, pas après coup. CertificatService.eleves
filtre -[:BELONGS]->(:Structure {id: structureId}) dans le MATCH, et .mesEleves n'admet que
u.id = {userId} OR (u)-[:RELATED]->(:User {id: {userId}}) — un compte qui n'est ni l'élève ni
l'un de ses responsables légaux obtient une liste vide, traduite en 403
(workflowhub.certificat.hors_perimetre) plutôt qu'un document.
NotificationService.envoyerDocument (nouveau) est la première utilisation, dans ce module, de
la surcharge EmailSender#sendEmail à pièces jointes
(fr.wseduc.webutils.email.NotificationHelper, JsonArray attachments = [{name, content}]
base64). Piège rencontré en vérification locale : cette surcharge recopie le paramètre from tel
quel (json.put("from", from)), sans se rabattre sur l'expéditeur de la plate-forme comme le font
les surcharges plus courantes du module — un appel avec from=null produit un message sans
expéditeur que le relais refuse silencieusement (aucun log d'erreur applicatif, le message
n'apparaît simplement jamais chez le destinataire). Corrigé en passant explicitement
emailSender.getSenderEmail(). Expéditeur volontairement celui de la plate-forme, jamais
l'adresse de l'établissement : le relais SMTP de production (OVH) ne signe (DKIM) que si
l'expéditeur est le compte authentifié — un envoi au nom de l'établissement échouerait cette
signature. L'établissement est nommé dans le corps du message, pas dans l'en-tête From.
Identité de l'établissement : lue dans l'annuaire, pas dans le module
Le logo, l'en-tête, le cachet et la signature du chef d'établissement qui habillent le certificat
ne vivent pas dans une table de WorkflowHub. Ce sont des données d'établissement, au même titre
que son adresse ou son UAI, et d'autres modules (bulletins, futures attestations) en ont ou en
auront besoin. Elles ont donc été ajoutées au nœud Structure de l'annuaire — extension de
PUT|GET /directory/structure/:id/branding (StructureController, entcore-v2), qui portait déjà
brandingLogo/brandingPrimaryColor/brandingAccentColor (D9, personnalisation graphique) sans
qu'aucun écran ne les alimente. Nouvelles propriétés, toutes facultatives, toutes des ids de
document du workspace pour les images (même mécanisme que l'avatar) :
s.brandingEntete · brandingCachet · brandingSignature (ids de document workspace)
s.brandingCachetPourtour · brandingCachetMention (chaînes, défauts déduits)
s.brandingSignataireCivilite/Nom/Prenom/Fonction/Libelle (chaînes)
setBranding n'écrit que les clés présentes dans le corps — un écran de thème et un écran
d'identité documentaire peuvent enregistrer chacun sa part sans effacer l'autre — et valide les ids
de document par motif (^[0-9a-zA-Z_-]{16,64}$) plutôt que de les faire confiance.
Lecture côté WorkflowHub : IdentiteEtablissementService.pourDocument (nouveau) étend la
requête Cypher déjà faite par DocumentController.etablissement aux propriétés branding*, résout
chaque image en data: URI via WorkspaceHelper.readDocument (jamais une URL : le générateur
de PDF rend le HTML dans un Chromium sans session ENT, une <img src="/workspace/…"> y
recevrait la page de connexion), et compose ce qui manque :
- cachet (
core/Cachet.java, classe pure, 16 tests) — un SVG sans dépendance : deux<textPath>sur des arcs de cercle (pourtour haut = nom de l'établissement, bas = ville), mention centrale déduite du croisements.type(LYCEE/COLLEGE/ECOLE → Proviseur/Principal/ Directeur) etu.title(civilité, avec accord —estFeminin) ; la signature s'y superpose en<image href="data:…">si elle existe. Une image de cachet déposée remplace le tout ; - en-tête (
Cachet.entete) — logo à gauche, pavé de coordonnées à droite, chaque ligne omise si la donnée manque (mieux qu'un « Tél. » suivi de rien) ; une image de papier à en-tête déposée le remplace entièrement.
Nouvelles clés du contexte réservé ent. (DocumentModele.contexte, surcharge à 4 arguments,
écrites après le payload comme le reste de ent.*) : ent.entete, ent.cachet (fragments HTML
prêts à insérer via triple moustache {{{…}}}), ent.signataire.{civilite,nom,prenom,fonction, libelle,signature,feminin}. Le drapeau feminin existe parce que le moteur de rendu n'a pas de
conditionnelle sur une valeur : l'accord de « Je soussignée » est décidé côté serveur, pas
déductible d'une civilité par le template seul.
Écran de paramétrage : /admin/configuration/identite-etablissement dans le dashboard, gardé
côté serveur par layout.tsx (ADML/super-admin) — pas dans WorkflowHub. Les appels au workspace et
à /directory/structure/:id/branding partent directement du navigateur (ces deux segments sont
réécrits par next.config.mjs), sans route Next à traverser : aucun proxy à écrire, aucune limite
de taille de corps à relever, aucune action @SecuredAction supplémentaire à rattacher pour cet
écran. Composant notable : SignaturePad.tsx (tracé au pointeur, sans dépendance, ~200 lignes) —
canevas adossé au devicePixelRatio, touch-action: none (sinon le geste de signature fait
défiler la page sur mobile), export au double de la résolution après rognage des marges
transparentes (getImageData), pour qu'un trait fin ne paraisse pas filiforme une fois étiré dans
le document imprimé. ApercuCachet reproduit à l'identique le SVG composé côté serveur — un aperçu
qui divergerait du document final serait pire qu'une absence d'aperçu.
Changement de régime de cantine : écriture directe dans l'annuaire, et sa limite
u.accommodation porte le régime tel que l'AAF le libelle — 20 valeurs distinctes en usage
(DEMI-PENSIONNAIRE DANS L'ETABLISSEMENT 4, EXTERNE LIBRE, INTERNE 5 JOURS…), que
reference/regimes.json reprend telles quelles (RegimesTest) plutôt que les 4 codes
génériques d'origine (EXTERNE/DEMI_PENSION/INTERNE/INTERNE_EXTERNE, conservés en entrées
deprecated: true — lisibles pour les dossiers déjà envoyés, filtrées de la liste servie par
DefaultReferentielService.sansDepreciees). Chaque entrée porte un jours (demi-pension) utilisé
pour distinguer DP4 de DP5, la seule granularité qui compte pour la facturation.
Le gabarit changement-regime-cantine ne fait que demander. L'écriture effective —
RegimeService.appliquer (nouveau), appelée par DossierController à chaque transition vers
ACCEPTE, best-effort et détaché de la réponse HTTP (comme la notification famille) — est un
SET u.accommodation = …, u.accommodationManual = true en Cypher direct, avec le rattachement
à l'établissement du dossier posé dans le MATCH (-[:BELONGS]->(:Structure {id: structureId})) plutôt que vérifié après la lecture.
Cette écriture directe est un choix contraint : accommodation n'est modifiable ni par
PUT /directory/user/:id (absent de updateUser.json, additionalProperties: false) ni par le
feeder (absent de la liste modifiable de dictionary/schema/Student.json). Conséquence assumée
et documentée dans le RGPD du gabarit : accommodationManual n'est lu par aucun import — le
prochain passage de l'AAF peut rétablir la valeur précédente si le changement n'a pas aussi été
reporté dans l'application de gestion de l'établissement. L'export CSV du suivi donne au
secrétariat la liste exacte à y saisir.
Défaut corrigé au passage : l'échéance n'était pas appliquée. campagne.date_ouverture /
date_fermeture existent depuis la migration 001 et s'enregistrent correctement
(DefaultCampagneService.publish), mais n'étaient exposées par aucune requête de lecture
(absentes de COLS) et le contrôle d'ouverture ignorait tout bonnement les deux colonnes :
// avant — PublicController.isOpen
return "OUVERTE".equals(campagne.getString("statut")); // date_fermeture jamais lue
Une démarche échue restait donc soumissible jusqu'à ce qu'un humain clique « Fermer » — inoffensif
pour un sondage, faux pour un changement de régime dont la facturation est établie derrière la date
annoncée. core/Echeance.java (nouveau, classe pure, 12 tests) centralise la règle : ouverte()
teste le statut et les deux bornes (bornes absentes ou illisibles = sans effet, pour qu'une
date mal saisie ne condamne pas une démarche plutôt que de la laisser ouverte à tort), fermeture
exclusive à l'instant de l'échéance, echue() distingue le délai dépassé d'une fermeture
manuelle (pour un message d'erreur qui dise lequel — workflowhub.campagne.echeance_depassee vs
…fermee). Branché sur PublicController.isOpen et les trois contrôles de CampagneController
(ciblee, invitation, diffusion).
Fermeture automatique. CampagneService.fermerEchues (UPDATE … SET statut = 'FERMEE' WHERE statut = 'OUVERTE' AND date_fermeture <= now(), comparaison faite côté base pour ne pas
dépendre du fuseau du pod) est planifiée par un second CronTrigger dans WorkflowHub.java
(echeances-cron, défaut toutes les heures — même mécanisme que la purge RGPD déjà en place), pour
que le suivi cesse d'afficher « Ouverte » sur une démarche qui refuse déjà les réponses.
Convention de stage : signature électronique via esign
Le paraphe du maître de stage (ConventionService.parapher) ne s'arrête plus à l'écriture de
payload.paraphes.tuteur : DefaultConventionService.scellerSiBesoin génère ensuite le PDF de la
convention (PdfService.conventionPdf) et le fait sceller par le module esign, sur l'adresse
de bus esign.sceau — même idiome que l'appel existant à entcore.feeder (request avec
DeliveryOptions.setSendTimeout), pas de dépendance Maven entre les deux modules.
- Un seul signataire, donc un seul scellement. Le gabarit ne déclare qu'un maître de stage
(
SIGNATAIRE_TUTEUR) —SIGNATAIRE_ETABLISSEMENTexiste dans le schéma mais n'est apposé par aucun code : la question « sceller à chaque paraphe ou une fois à la fin » (posée par le propre TODO d'esign,note-de-cadrage.md) se résout donc d'elle-même pour cette démarche. - Dégradation gracieuse. Un échec du scellement (cachet absent/expiré, esign indisponible,
document manquant) est capturé et écrit dans
payload.scellement = {scelle: false, erreur, tentativeLe}— il ne fait jamais échouerparapher(): la signature du maître de stage est acquise indépendamment du sort du cachet.scellerSiBesoinest réappelée à chaqueparapher()tant quescellement.scellen'est pastrue, ce qui sert aussi de nouvelle tentative (le bouton « Relancer le scellement électronique » deConventionsPage.tsxrappelle simplement la même route). - Le PDF scellé n'est pas stocké dans le payload. Le document (bytes base64 renvoyés par
esign) est écrit dans le
Storagedu module (writeBuffer) ; seule sa référence (payload.scellement.documentId) rejoint la colonne JSONB.GET /dossiers/:id/pdf(établissement) etGET /conventions/:reference/pdf-scelle(maître de stage, sans accès back-office) servent ce fichier viaStorage.sendFile— en lui passant explicitement{"content-type": "application/pdf"}en métadonnées :sendFilene pose l'en-têteContent-Typeque si on le lui fournit, il ne l'infère pas du contenu. - Le gabarit déclare le sous-objet.
payload.scellementest explicitement ajouté àform.schema.json(additionalProperties: falsesur la racine) — la même règle que pourparaphes.*, cf. la section règlement intérieur ci-dessus : une clé non déclarée passerait inaperçue jusqu'à une validation ultérieure du payload. - Le PDF envoyé à esign n'est pas le récapitulatif générique.
PdfService.recapPdfsuppose un payloadresponsables[]/eleves[](formulaires famille) ; la convention porteeleve,entreprise,periode,tuteuren objets singuliers.PdfService.conventionPdfest un rendu HTML dédié, réutilisant le même connecteur (PdfGenerator).
Pièces jointes : deux origines, un seul chemin d'écriture
Le formulaire accepte un justificatif venu du disque de l'usager ou de son espace documentaire.
Les deux empruntent la même route (POST /pub/:token/dossier/:reference/piece-jointe,
multipart, 10 Mo) : le module ne connaît qu'une façon d'écrire une pièce.
- Le transfert depuis le workspace passe par le navigateur. Le front télécharge
GET /workspace/document/:idavec la session de l'usager, puis renvoie le fichier en multipart. C'est le workspace qui applique les droits de lecture : le module n'a rien à rejuger, et le contrôleur public — ouvert à l'anonyme — n'acquiert aucune capacité de piocher unfileIddans le stockage. Une copie serveur par le bus aurait imposé de reconstruire ce contrôle d'accès sur une route non authentifiée. - C'est une copie, pas une référence. Le document d'origine reste dans l'espace documentaire, et le retirer du dossier ne le supprime pas de l'espace de l'usager.
- Le bouton ne s'affiche que si le workspace répond. La sonde (
GET /workspace/folders/list) est mise en cache pour la page, et tranche sur le content-type : sans session, entcore ne répond pas 401 mais redirige vers la page de connexion, quefetchsuit en silence — un 200 porteur de HTML. Un test sur le seulres.ok()conclurait que le workspace est accessible et offrirait un sélecteur vide à une famille sans compte ENT. ui:widget: fileétait déclaré mais pas implémenté. Le concepteur posait des champs « Pièce jointe » queSchemaFormrendait en champ texte d'une ligne (même défaut queui:widget: textareaavant sa correction). Le widget existe désormais : la pièce est classée sous le typechamp:<chemin du champ>(colonnetype_piece,varchar(128)— le chemin est tronqué à cette largeur), et le payload ne retient que le nom du fichier, ce que le schéma déclare (type: string), ce querequiredsait contrôler et ce qu'un récapitulatif imprime. Le bloc « Pièces justificatives » filtre ces types pour ne pas afficher deux fois la même pièce — ni permettre de la retirer d'un endroit où le payload garderait son nom.
Rendez-vous avec un enseignant : intégration Appointments par bus applicatif
WorkflowHub ne tient aucun calendrier propre pour la démarche demande-rendez-vous-enseignant :
enseignant, grille et créneau sont résolus en direct auprès du module Appointments, sur le
modèle déjà établi par workflowhub.autorisations (carnet de correspondance → WorkflowHub), mais
dans l'autre sens (WorkflowHub → Appointments).
- Passerelle bus, adresse
appointments.rendezvous. Enregistrée côté Appointments parWorkflowHubGateway(eventbus/WorkflowHubGateway.java), appelée côté WorkflowHub parAppointmentsBus.appeler(core/AppointmentsBus.java) — un point d'accès unique, utilisé à la fois parDefaultReferentielService(lecture :grilles-enseignant,creneaux-grille) etDefaultRendezVousService(écriture :reserver). Chaque action rejoue exactement les contrôles qu'Appointments applique à un utilisateur connecté (GridService#getAvailableUserMinimalGrids,TimeSlotService#getAvailableTimeSlotsByDates,AppointmentService#checkIfUserCanAccessTimeSlotpuis#checkIfTimeSlotIsAvailableavant#create) — WorkflowHub ne contourne aucune vérification, il transmet l'identité et les groupes Neo4j de l'appelant au lieu d'une session HTTP. - Trois sources de référentiel supplémentaires (
SourceCatalog) :enseignants-rdv(Neo4j,HEAD(u.profiles) = 'Teacher'sur la structure),grilles-rdvetcreneaux-rdv(Appointments, par le bus). Les deux dernières utilisent des widgets dédiés côté frontend (grille-enseignant-select,creneau-selectdansSchemaForm.tsx) plutôt que lereferentiel-selectgénérique : ils lisent leur paramètre (enseignantId,gridId) sur un champ frère du formulaire, exactement commeVilleFromCpFieldlitcodePostal— un mécanisme de dépendance déjà présent dans le moteur, réutilisé plutôt que réinventé. - Pas de session ENT sur le flux public.
PublicController#referentielne peut pas s'appuyer sur une session du navigateur pour les groupes Neo4j du déposant : pourgrilles-rdvetcreneaux-rdv, il exige un paramètrereference(dossier), en retrouve leowner_user_id, et résout ses groupes viaReferentielService#groupsIdsDuCompte(nouvelle requête Neo4j,(u:User)-[:IN]->(g:Group)). Sans dossier rattaché à un compte (démarche remplie depuis un lien public générique), ces deux sources échouent explicitement plutôt que de renvoyer une liste vide silencieuse. Côté frontend,ReferenceContext.referenceporte cette référence (draftRefdu lien personnel?ref=, cf.PublicFormPage.tsx). - Réservation avant bascule en ENVOYE.
PublicController#submitcomposevalidationService.validate(...)puisrendezVousService.reserver(reference)avant d'appelerdossierService.submit(reference): un créneau devenu indisponible entre l'ouverture du formulaire et l'envoi fait échouer l'envoi en 409 (workflowhub.rendezvous.*/appointments.rendezvous.*, distingués du 400 de non-conformité au schéma), pas un dossier « transmis » sans rendez-vous derrière.RendezVousService#concerneUnRendezVousdétecte la démarche parcampagne.template_key(même mécanisme queConsentementServicepour le droit à l'image) — les autres démarches traversentreserver()sans effet (objet vide). - Trois colonnes de suivi (
020-rendez-vous.sql) :rendez_vous_id,rendez_vous_statut,rendez_vous_reserve_atsurworkflowhub.dossier. Ce ne sont que des pointeurs, écrits une fois à la réservation (idempotents — un second appel sur le même dossier renvoie l'existant sans réserver un second créneau) : l'état qui fait foi (accepté, refusé, annulé par l'enseignant) continue de vivre dans Appointments, WorkflowHub ne le resynchronise pas en continu. - Réservé aux comptes ENT. Le formulaire ne porte aucun champ
userIdpour le déposant : leowner_user_iddu dossier (posé parcreateDraftOwned/createDiffusedDraft) sert directement d'identité pour la réservation. Une démarche remplie depuis un lien public générique n'a pas deowner_user_id:reserver()échoue alors surworkflowhub.rendezvous.sans_compte, avant même d'atteindre Appointments.
Provisioning des comptes : garde-fous
La création de comptes (type provisionnant) réutilise le mécanisme d'alimentation officiel d'OpenENT (voir Import de données AAF pour la gestion du référentiel). Garde-fous : idempotence (un dossier déjà provisionné n'est pas rejoué), détection des comptes existants (un élève ou responsable déjà présent est rattaché plutôt que recréé), lien de parenté responsable ↔ élève, et journalisation de cette action sensible.
Conformité
| Maillon de la chaîne qualité | Référence |
|---|---|
| 🎯 Fonctionnalités attendues | fiche fonctionnelle |
| 📄 Cadrage fonctionnel & technique | note de cadrage du module workflowhub |
| 🧪 Tests réalisés | unitaires DocumentModeleTest + GabaritsDocumentsTest (modèles de document et gabarits réglementaires) ; e2e 45_workflowhub (10 scénarios) — pré-inscription (provision), sondage (collecte), voyage scolaire (diffusion à une classe ENT + suivi + décision), administration (modèles d'établissement/réseau + garde-fous de copie inter-établissement), étiquettes (multiples, administrables par établissement), préremplissage nominatif (vue famille du lien personnel déjà rempli), autorisations permanentes (carnet de correspondance), règlement intérieur (double paraphe élève + responsable) et convention de stage (signature électronique scellée par le module esign) |