Skip to main content

Démarches — détails techniques

Fiche fonctionnelle

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 unitairesDocumentModeleTest (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 :

  1. Conception — création d'une campagne depuis le modèle « Autorisation de voyage / sortie scolaire » (sortie du territoire / CERFA, attestation sanitaire, renseignements famille).
  2. Personnalisation du workflow — édition graphique des transitions de la démarche.
  3. Publication — ouverture de la démarche.
  4. 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.
  5. Réponse du parent — la famille complète et envoie son dossier personnel.
  6. Suivi du taux de réponse — l'émetteur voit qui a répondu / reste à répondre.
  7. Décision — prise en charge puis acceptation du dossier (sans provision de comptes).
  8. 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èlegarde-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 + affectationaffectation 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_scolaire par 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 vaut EN_COURS tant que l'établissement n'a pas tranché, puis ACCORDE ; 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_PARTIEL et le carnet nomme qui reste attendu ; reparapher renvoie dejaSigne sans redater la signature ; le dossier ne passe à ENVOYE qu'une fois les deux paraphes réunis ; un personnel est refusé (403) ; un childId hors 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 / _PASS et E2E_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, cachetSerial et documentId pré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-scelle est 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 type champ:<chemin> plutôt que le type libre justificatif ;
  • 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.
Nouvelles routes @SecuredAction : rôles à compléter au déploiement

GET /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é.

Ce que le rattachement a révélé sur les rôles existants

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 :

  1. Workflows prédéfinis (collecte / décisionnel / provisionnant) — couvrent l'essentiel des besoins et sont disponibles d'emblée ;
  2. 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.recipients ne garde que la première par eleveId.
  • Une rediffusion. createDiffusedDraft est un INSERT sec : 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.diffusedEleveIds relit les élèves déjà destinataires sur la campagne et DiffusionCibles.aServir les retire de la cible. Le compte rendu renvoie ignores pour que l'écran le dise, plutôt que de laisser croire à une diffusion partiellement perdue.
Le rattachement ne peut pas vivre dans le payload

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.sqldiffusion_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.of lit 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-scolaire n'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_scolaire n'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 bus workflowhub.autorisations sous l'action parapher-reglement), jamais déclaré dans le formulaire public. Une case cochée anonymement n'engagerait personne : on ne saurait pas qui a signé. Les champs paraphes.* sont donc ui:widget: hidden — ce qui a demandé de traiter hidden avant les objets et les tableaux dans SchemaForm, faute de quoi le bloc entier restait affiché.
  • Il en faut deux. Paraphes (testé unitairement, ParaphesTest) porte seul cette règle : apposer est idempotent — reparapher ne redate pas la signature, un second clic ne peut pas déplacer la date d'engagement — et etat distingue AUCUN, PARTIEL et COMPLET. Le dossier ne passe de BROUILLON à ENVOYE qu'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'établissementDémarche de réseau
campagne.porteeETABLISSEMENTRESEAU
campagne.structure_idl'établissementNULL
Qui la créegestionnaire / ADMLsuper-administrateur seul
Qui la voitson établissementtous (le texte s'adresse à eux)
Qui la modifieson établissementsuper-administrateur seul
L'établissement concernécelui de la démarchecelui de chaque demande (dossier.structure_id)
Le suivi listedes famillesdes é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) — le IS NULL n'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'est manquants() 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é ent ne 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) :

RouteRôle
GET /workflowhub/campagnes/:id/documentmodèle + ses marqueurs ; {absent:true} si la démarche n'en produit pas
POST /workflowhub/campagnes/:id/documentcrée et rattache un modèle (contenu, ou sourceKey d'un gabarit livré)
PUT /workflowhub/campagnes/:id/documentremplace le texte, en archivant la version précédente
DELETE /workflowhub/campagnes/:id/documentdétache et supprime le modèle
GET /workflowhub/campagnes/:id/document/revisionsversions successives du texte
POST /workflowhub/campagnes/:id/document/apercurend le texte en cours d'édition avec un payload d'exemple (HTML)
GET /workflowhub/dossiers/:id/document.html · .pdfdocument 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 :

RouteDroitRôle
POST /campagnes/:id/certificats/diffusionworkflowhub.manage{classeIds[]|eleveIds[]} → un dossier + un PDF par élève, envoyé par courriel
POST /campagnes/:id/certificats/mienworkflowhub.viewla 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.joinjoin 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 croisement s.type (LYCEE/COLLEGE/ECOLE → Proviseur/Principal/ Directeur) et u.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_ETABLISSEMENT existe 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 échouer parapher() : la signature du maître de stage est acquise indépendamment du sort du cachet. scellerSiBesoin est réappelée à chaque parapher() tant que scellement.scelle n'est pas true, ce qui sert aussi de nouvelle tentative (le bouton « Relancer le scellement électronique » de ConventionsPage.tsx rappelle 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 Storage du module (writeBuffer) ; seule sa référence (payload.scellement.documentId) rejoint la colonne JSONB. GET /dossiers/:id/pdf (établissement) et GET /conventions/:reference/pdf-scelle (maître de stage, sans accès back-office) servent ce fichier via Storage.sendFile — en lui passant explicitement {"content-type": "application/pdf"} en métadonnées : sendFile ne pose l'en-tête Content-Type que si on le lui fournit, il ne l'infère pas du contenu.
  • Le gabarit déclare le sous-objet. payload.scellement est explicitement ajouté à form.schema.json (additionalProperties: false sur la racine) — la même règle que pour paraphes.*, 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.recapPdf suppose un payload responsables[]/eleves[] (formulaires famille) ; la convention porte eleve, entreprise, periode, tuteur en objets singuliers. PdfService.conventionPdf est 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/:id avec 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 un fileId dans 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, que fetch suit en silence — un 200 porteur de HTML. Un test sur le seul res.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 » que SchemaForm rendait en champ texte d'une ligne (même défaut que ui:widget: textarea avant sa correction). Le widget existe désormais : la pièce est classée sous le type champ:<chemin du champ> (colonne type_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 que required sait 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 par WorkflowHubGateway (eventbus/WorkflowHubGateway.java), appelée côté WorkflowHub par AppointmentsBus.appeler (core/AppointmentsBus.java) — un point d'accès unique, utilisé à la fois par DefaultReferentielService (lecture : grilles-enseignant, creneaux-grille) et DefaultRendezVousService (écriture : reserver). Chaque action rejoue exactement les contrôles qu'Appointments applique à un utilisateur connecté (GridService#getAvailableUserMinimalGrids, TimeSlotService#getAvailableTimeSlotsByDates, AppointmentService#checkIfUserCanAccessTimeSlot puis #checkIfTimeSlotIsAvailable avant #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-rdv et creneaux-rdv (Appointments, par le bus). Les deux dernières utilisent des widgets dédiés côté frontend (grille-enseignant-select, creneau-select dans SchemaForm.tsx) plutôt que le referentiel-select générique : ils lisent leur paramètre (enseignantId, gridId) sur un champ frère du formulaire, exactement comme VilleFromCpField lit codePostal — 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#referentiel ne peut pas s'appuyer sur une session du navigateur pour les groupes Neo4j du déposant : pour grilles-rdv et creneaux-rdv, il exige un paramètre reference (dossier), en retrouve le owner_user_id, et résout ses groupes via ReferentielService#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.reference porte cette référence (draftRef du lien personnel ?ref=, cf. PublicFormPage.tsx).
  • Réservation avant bascule en ENVOYE. PublicController#submit compose validationService.validate(...) puis rendezVousService.reserver(reference) avant d'appeler dossierService.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#concerneUnRendezVous détecte la démarche par campagne.template_key (même mécanisme que ConsentementService pour le droit à l'image) — les autres démarches traversent reserver() sans effet (objet vide).
  • Trois colonnes de suivi (020-rendez-vous.sql) : rendez_vous_id, rendez_vous_statut, rendez_vous_reserve_at sur workflowhub.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 userId pour le déposant : le owner_user_id du dossier (posé par createDraftOwned/createDiffusedDraft) sert directement d'identité pour la réservation. Une démarche remplie depuis un lien public générique n'a pas de owner_user_id : reserver() échoue alors sur workflowhub.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 attenduesfiche fonctionnelle
📄 Cadrage fonctionnel & techniquenote de cadrage du module workflowhub
🧪 Tests réalisésunitaires 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)

➡️ Statut dans le dashboard E2E · Résultats détaillés