Skip to content

feat(communication): #128 — chef d'unité par UnitLead en messagerie, modération et pédagogie - #184

Merged
mathiscapart merged 1 commit into
developfrom
feat/unitlead-decisions
Oct 8, 2026
Merged

mathiscapart merged 1 commit into
developfrom
feat/unitlead-decisions

Conversation

@mathiscapart

@mathiscapart mathiscapart commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

Origine

Refs #128 — étape 3 sur 4 (« chef de cette unité » passe par les unités encadrées). L'issue reste ouverte pour l'étape 4.

Ce qui change

  • Messagerie privée : un jeune de 15 à 17 ans peut échanger en privé avec un chef qui encadre son unité, qu'il en soit membre ou non. Un chef membre de l'unité sans l'encadrer ne le peut plus. La règle « pas de message privé entre jeunes de 15 à 17 ans » passe toujours avant : Lucas (17 ans, chef des Louveteaux) n'écrit en privé à aucun mineur.
  • Modération : un chef est notifié des signalements des unités qu'il encadre, et sa file ne liste qu'eux. Lucas reçoit les signalements des Louveteaux, plus ceux des Compagnons dont il est seulement membre.
  • Pédagogie : la demande de 2e validation d'une étape (qui contient le prénom du jeune) part aux chefs qui encadrent l'unité du jeune, plus aux chefs qui en sont seulement membres.
  • Administration : enregistrer les rôles d'un compte supprimé (anonymisé) ne lui redonne plus d'unité encadrée.
  • Les chefs actuels gardent exactement les mêmes droits : leur unité encadrée est leur unité d'appartenance.

Pourquoi

Après #183, le périmètre d'un chef repose sur UnitLead, mais la messagerie, la modération et la notification pédagogique comparaient encore User.unit. Un compagnon chef des Louveteaux recevait donc les signalements des Compagnons et pas ceux des Louveteaux.

Preuves

  • pnpm lint : ✅ 0 erreur (3 warnings déjà présents sur develop, TicketsSection.tsx et place-actions.ts)
  • pnpm typecheck : ✅
  • pnpm test : ✅ 602 tests (584 sur develop après feat(admin): #128 — unités encadrées (UnitLead), périmètre d'un chef #183). Tests ajoutés, rouges avant le code (6 échecs constatés, pour la bonne raison) :
    • src/modules/communication/dm-policy.test.ts, bloc chef d'unité par UnitLead (#128) :
      • compagnon mineur CHEF des Louveteaux ↔ Louveteau de 16 ans : interdit dans les deux sens, motif « entre jeunes de 15 à 17 ans » ;
      • adulte chef des Louveteaux ↔ même jeune : autorisé, inchangé ;
      • chef adulte qui encadre l'unité du jeune sans y appartenir : autorisé ;
      • chef membre de l'unité du jeune sans l'encadrer : refusé ;
      • CHEF sans ledUnits (vide ou absent) : refusé ;
      • ledUnits sans rôle CHEF : refusé.
    • src/modules/communication/moderation-policy.test.ts, bloc routage par unités encadrées (#128) : Lucas notifié pour les Louveteaux et pas pour les Compagnons, avec le traitement symétrique ; chef de deux unités ; CHEF sans encadrement ; encadrement sans CHEF ; l'auteur du contenu reste exclu (fix(moderation): le chef visé par un signalement le reçoit, voit qui l'a signalé et peut le rejeter #91).
    • src/lib/permissions.test.ts : propagation déjà en place. pedago.manage, pedago.referential, member.family.manage, announcement.publish, event.manage et budget.manage suivent ledUnits pour Lucas, et un chef repris (UnitLead = unit) garde les mêmes droits. Ces tests sont verts dès l'écriture : c'est la vérification demandée, sans changement de code.
    • Aucune attente (expect) existante n'a changé. Les fixtures CHEF de dm-policy.test.ts (chief()) et du bloc selectReportRecipients reçoivent ledUnits = leur unit, comme le fait la migration.
  • Critère « plus aucune décision d'accès ne lit user.unit pour un CHEF » : grep -rnE "\b(user|me|actor|viewer|adult|u)\??\.unit\b" src --include=*.ts --include=*.tsx (hors tests) donne 36 lignes, classées une par une :
    • Affichage (20) : admin/inscriptions, admin/utilisateurs (×2), planning/[id]/page.tsx:388-390, AttendanceList.tsx, registrations/export, membres/[id]/page.tsx:168-169, FamilySection.tsx, compte, dashboard/page.tsx:81, UserMenu.tsx, pedagogie/attribuer, family/queries.ts, planning/queries.ts:160.
    • Unité du membre visé, pas de l'utilisateur connecté : membres/[id]/page.tsx:90-100 (canActOnUnit sur l'unité du jeune) et planning/stats.ts:99 (stats du jeune).
    • Appartenance : dm-queries.ts:27 (unité du jeune), audience.ts:43 et announcement-queries.ts:58 (audience des annonces), communication/access.ts:46,54 (salons d'unité), planning/actions.ts:337 (branche de la personne qui s'inscrit), planning/[id]/page.tsx:116 (un CHEF passe par isStaff), api/calendar/[token]/route.ts:110 (iCal), dashboard/queries.ts:181 (prochain événement, non borné pour qui a event.manage).
    • Choix par défaut, borné par le périmètre : planning/presences/page.tsx:31 (unité présélectionnée parmi scopedUnits).
    • Hors CHEF : permissions.ts:292 (prêt d'un jeune Pionnier ou Compagnon) et permissions.ts:335 (rôles non CHEF, TODO(ROLES-06)).
    • Les seules comparaisons « chef de l'unité » restantes passaient par dm-policy, selectReportRecipients, listReports et progression-actions : elles sont migrées. Plus aucun roles contains CHEF + unit dans src/.
  • Sites demandés, non audités jusqu'ici :
    • src/modules/camp/places.ts:228 : affichage de l'unité de l'événement d'un avis de lieu. Ni accès ni appartenance, rien à faire.
    • src/modules/planning/event-hooks.ts:46 (resolveUnitAudience) : appartenance. Les notifications d'événement vont aux membres de l'unité. Conséquence produit : Lucas n'est pas notifié des événements des Louveteaux. À trancher à l'étape 4.
    • src/modules/planning/reminders.ts:37 : appartenance (relances d'inscription aux membres et à leurs parents). Correct tel quel.
  • Parcours réel : Playwright (Chromium) sur pnpm dev:worktree (port 3101), base du worktree re-seedée, état vérifié en base. Deux exécutions :
    • Par le sous-agent verifier (script all.js) :
      • Lucas, /messages → « Nouveau » : seuls « Admin Piloti » et « Éléonore Lefebvre » (cheffe des Compagnons, son unité d'appartenance) sont proposés, aucun jeune.
      • Lucas, /messages/<id d'une Compagnon de 17 ans> : 404.
      • Thomas Martin (chef adulte des Pionniers, UnitLead = unit), /moderation : il voit le signalement Pionniers, pas celui des Louveteaux.
      • Thomas, contacts privés : Élise Vasseur (Pionniers, 16 ans) et Blandine Wallez (Pionniers, 15 ans) sont proposées. Clara Vandenberghe (Compagnons, 17 ans) et Louise Mercier (Pionniers, 14 ans) ne le sont pas. Les autres « Jeune » listés sont des Compagnons de 18-19 ans. Le verifier n'avait pas pu rattacher les noms aux âges : je l'ai fait en base, dans le fil principal. Comportement inchangé.
    • Dans le fil principal (script s3.js), pour les points que le verifier n'a pas pu prouver, sur une base re-seedée :
      • Signalements créés par l'app. L'admin signale dans #compagnons un message du chef des Louveteaux (→ concernedUnit LOUVETEAUX) et un message de la cheffe des Compagnons (→ COMPAGNONS).
        • REPORT_CREATED du signalement LOUVETEAUX : admin, RG, Lucas.
        • REPORT_CREATED du signalement COMPAGNONS : admin et RG, pas Lucas. Avant ce changement, il l'aurait reçu (membre des Compagnons).
      • Lucas, /moderation : les deux signalements Louveteaux (celui du seed et le nouveau), pas celui des Compagnons.
      • Compte supprimé : l'admin supprime chef.farfadets par l'UI → DELETED, unit FARFADETS conservée, 0 UnitLead. J'envoie ensuite setUserRoles (rôle CHEF) sur ce compte, en réécrivant l'identifiant de la requête envoyée par le dialogue « Rôles » d'un autre chef, comme feat(admin): #128 — unités encadrées (UnitLead), périmètre d'un chef #183 l'a fait pour createEvent → toujours 0 UnitLead, audit USER_ROLE_CHANGED avec leadUnits: []. Le chef dont la requête a été détournée garde [SCOUTS]. Je n'ai pas rejoué ce scénario sur le code d'avant : selon leadUnitsForRoles, il aurait recréé [FARFADETS].
    • Non exécuté : notification de 2e validation pédagogique (envoyée dans after()). Couverte seulement par le typecheck et la relecture.
    • Captures jointes en commentaire (données du seed uniquement).
  • Agents utilisés :
    • security-auditor : 1 passe, sur le commit 19c7226 (diff HEAD~1..HEAD, develop n'existant pas en local dans son contexte : même contenu). Aucun point bloquant ni élevé. 3 points relevés, 0 corrigé, 3 laissés (ci-dessous, « Risques »). Le premier est moyen : salons, annonces, iCal et événements encore sur User.unit. Les deux autres sont faibles : ledUnits optionnel dans DmParticipant et ModeratorCandidate, et roles contains "CHEF" dans progression-actions, code d'avant le diff.
    • reviewer : 1 passe. Il a atteint sa limite de tours et je l'ai relancé une fois pour qu'il conclue : c'est la même relecture. Il a lancé lint, typecheck et tests (602 ✅). Aucun point bloquant, élevé ni moyen. 3 points faibles relevés, 0 corrigé, 3 laissés :
      • un jeune sans unité ne déclenche plus aucune notification de 2e validation. Avant, unit: null notifiait les chefs sans unité ;
      • garde DELETED de setUserRoles peut-être inutile. Vérifié : aucune garde de statut en amont, et le parcours l'a atteinte, donc je l'ai gardée ;
      • role sélectionné mais jamais lu dans notifyModerators, code d'avant le diff.
    • verifier : 1 passe. Il a atteint sa limite de tours et je l'ai relancé une fois. Messagerie de Lucas ✅, modération de Lucas et de Thomas ✅. Notification de Lucas et compte DELETED non prouvés : faits ensuite dans le fil principal (ci-dessus). Il avait créé un signalement par l'app et en avait inséré un autre en base ; la base a été re-seedée ensuite.
    • Aucun correctif de code n'a suivi les relectures. Il n'y a donc pas eu de seconde passe, et aucun correctif n'est resté sans seconde passe.
    • Non lancés :
      • architect : conception tranchée par Mathis (option E, règles de la tâche) ;
      • test-engineer : tests écrits dans le fil principal, rouges avant le code ;
      • debugger : pas de bug à diagnostiquer ;
      • feature : code écrit dans le fil principal ;
      • refactorer : pas de refactor ;
      • doc-writer : amendement de D-045 écrit dans le fil principal ;
      • git-manager : commit et PR dans le fil principal ;
      • explorer / Explore : recherches faites dans le fil principal ;
      • project-manager : sans objet.

Invariants

  • Mutations sous withAudit() : la seule mutation touchée est setUserRoles, déjà sous withAudit. Son audit porte leadUnits: [] pour un compte supprimé.
  • Actions/pages sensibles gardées par can(user, "…") : gardes inchangées. Seuls changent le périmètre d'unité (ledUnits) et le calcul des destinataires.
  • Migration Prisma : Sans objet. Aucune nouvelle migration, celle de feat(admin): #128 — unités encadrées (UnitLead), périmètre d'un chef #183 n'est pas touchée.
  • CSP Traefik mise à jour si nouveau domaine externe : Sans objet
  • Données de mineurs / RGPD touchées :
    • messagerie privée des 15-17 ans (SAFE-01) : ouverte aux chefs encadrants de leur unité, fermée aux chefs seulement membres. La règle entre mineurs passe avant, donc un chef mineur n'écrit à aucun mineur ;
    • le prénom du jeune, dans la notification pédagogique, ne va plus qu'aux encadrants ;
    • un compte anonymisé n'encadre plus rien, même après setUserRoles.

Décisions prises

  1. « Chef de l'unité du jeune » = CHEF dont ledUnits contient l'unité d'appartenance du jeune. L'unité d'appartenance du chef ne compte plus, et le rôle CHEF reste exigé : un UnitLead sans CHEF n'ouvre rien.
  2. File de modération : concernedUnit IN ledUnits, et une liste vide renvoie [] avant toute requête (fail-closed). Elle est suivie du filtre canModerateReport existant.
  3. Notification pédagogique : aucun envoi si le jeune n'a pas d'unité. Avant, la requête unit: null notifiait les chefs sans unité.
  4. sendDirectMessage passe directement l'utilisateur connecté (qui porte ledUnits) à evaluateDmPolicy.
  5. D-045 amendée dans DECISIONS.md.

Risques et points à vérifier

  • À trancher à l'étape 4 (relevé par le security-auditor, MOYEN) : ces surfaces restent sur User.unit comme appartenance :

    • accès aux salons d'unité (communication/access.ts) ;
    • audiences d'annonces ;
    • notifications et rappels d'événements ;
    • iCal ;
    • tableau de bord.

    Conséquences pour Lucas : il lit le salon des Compagnons, pas celui des Louveteaux, sauf si le salon est ouvert au rôle Chef, et il n'est pas notifié des événements des Louveteaux. Réciproquement, retirer l'encadrement d'un chef ne lui retire pas l'accès aux salons de son unité d'appartenance. Rien n'a changé ici par rapport à develop.

  • FAIBLE (auditor) : ledUnits est optionnel dans DmParticipant et ModeratorCandidate. Un futur appelant qui l'oublierait compilerait, et les chefs perdraient l'accès sans erreur (fail-closed). Tous les appelants actuels le fournissent : sendDirectMessage, getThread, listContacts, notifyModerators. Le rendre obligatoire toucherait toutes les fixtures non CHEF des tests.

  • FAIBLE (auditor), code d'avant le diff : roles: { contains: "CHEF" } dans progression-actions.ts est une recherche de sous-chaîne. Aucun autre rôle ne contient « CHEF » aujourd'hui.

  • Choix A (rappel) : un RG ou une secrétaire qui s'attribue CHEF encadre son unité, et peut alors écrire en privé à ses 15-17 ans. Décision validée, tracée en audit.

  • À relire en priorité : dm-policy.ts (isUnitChiefOf) et moderation-queries.ts (listReports).

🤖 Generated with Claude Code

…modération et pédagogie

- dm-policy : « chef de l'unité du jeune » = CHEF qui encadre son unité
  (ledUnits) ; la règle entre mineurs de 15 à 17 ans passe avant.
- Signalements : notification et file des CHEF par unités encadrées,
  fail-closed sans encadrement.
- 2e validation pédagogique : notifie les chefs qui encadrent l'unité.
- setUserRoles ne recrée plus d'UnitLead sur un compte DELETED.
- D-045 amendée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mathiscapart

Copy link
Copy Markdown
Owner Author

Captures du parcours réel (données du seed uniquement).

Lucas, /moderation : signalements des Louveteaux seulement, pas celui des Compagnons

Lucas, nouveau message privé : aucun jeune proposé

Lucas, fil privé avec une Compagnon de 17 ans : 404

Thomas (chef adulte des Pionniers), /moderation : signalement Pionniers seulement

Thomas, nouveau message privé : jeunes 15-17 ans des Pionniers proposés

@mathiscapart
mathiscapart merged commit 9e62bdb into develop Oct 8, 2026
5 of 6 checks passed
@mathiscapart
mathiscapart deleted the feat/unitlead-decisions branch October 8, 2026 12:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant