feat(r6): R6.6 — Demandeur restreint à son site

Trouvé en recette : GET /assets/options n'avait aucun filtre — tout
Demandeur voyait le parc complet dans le sélecteur d'équipement de
"Nouvelle demande", sur le web ET le mobile (même endpoint partagé).
Risque réel : signaler accidentellement une panne sur un ascenseur qu'on
ne gère pas.

Corrigé à la racine, sur les deux plateformes à la fois :

- Relation many-to-many User↔Location (assignedSites/assignedUsers,
  migration r6_demandeur_sites, même style que Team.members) — vide =
  aucune restriction, comportement historique inchangé pour tous les
  rôles sauf un Demandeur affecté à un site.
- AssetsService.allowedLocationIds(user) : sites + zones filles
  autorisés, ou null si aucune restriction — réutilisée par options()
  ET par RequestsService.create (défense en profondeur : un assetId
  soumis directement hors périmètre est rejeté, 400).
- UsersService : assertTopLevelSites (un Demandeur est affecté à un
  site, jamais une zone) ; invite()/update() branchent locationIds
  (remplace l'affectation, comme teamIds).
- Web (personnes.tsx) : ModaleInvitation affiche les sites à cocher pour
  un rôle Demandeur ; colonne "Sites" éditable via une modale dédiée.
- Mobile (formulaire-demande.tsx) : bouton "Scanner l'étiquette" en
  raccourci — résout uniquement contre les options déjà chargées (déjà
  filtrées), jamais de repli sur le parc complet qui annulerait la
  restriction. Aucun changement à useAssetOptions() : le filtrage
  serveur profite automatiquement au formulaire mobile.
- Seed : Karim Doukkali (démo) rattaché à Tour Atlas.

Bug trouvé en vérification avant tout commit : create() comparait
allowed.includes(dto.assetId), mais allowed est une liste d'ids de
sites/zones, pas d'ids d'appareils — aurait rejeté à tort tout
signalement d'un Demandeur affecté, y compris dans son propre périmètre.
Corrigé (comparaison sur asset.locationId) ; méthode renommée
allowedAssetIds → allowedLocationIds pour que le nom dise ce qu'elle
retourne.

exploitation.e2e-spec.ts mis à jour (A1/C1 → A2/B1, dans le site de
Karim — sinon rejetés par la nouvelle règle, comportement voulu).
79/80 tests API verts, le seul échec (documents-analytics, monthCost)
est le flake calendaire déjà identifié cette session, sans rapport.
Typecheck/tests/lint verts sur les 4 paquets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-08-02 20:22:50 +01:00
parent 1ae0529a6a
commit c9168ece16
17 changed files with 472 additions and 17 deletions

View File

@@ -4,6 +4,68 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
---
## 2026-08-02 — Pr. Daaif (+ Claude) — R6.6 : Demandeur restreint à son site
**Contexte** — en recette, le référent a remarqué que Karim Doukkali (Demandeur) voit tout le
parc dans le sélecteur d'équipement de « Nouvelle demande » : il pourrait accidentellement
signaler une panne sur un ascenseur qu'il ne gère pas. Investigation : le trou est dans
l'**API** — `GET /assets/options` (`assets.service.ts`) n'avait aucun filtre, et cet endpoint
est utilisé aussi bien par le web que par le mobile. Décision retenue : les deux à la fois —
association Demandeur → site (corrige web ET mobile) et scan QR comme raccourci mobile.
**Actions**
- Relation many-to-many `User ↔ Location` (`assignedSites`/`assignedUsers`, migration
`r6_demandeur_sites`, même style que `Team.members`) — vide = aucune restriction (comportement
historique, inchangé pour tous les rôles sauf un Demandeur affecté).
- Contrat (`packages/shared/schemas/users-admin.ts`) : `InvitationCreate`/`UserUpdate` gagnent
`locationIds?` (remplace l'affectation, comme `teamIds`) ; `UserAdmin` gagne `assignedSites`.
- `AssetsService.allowedLocationIds(user)` : site(s) + zones filles affectés, ou `null` si aucune
restriction — réutilisée par `options()` **et** par `RequestsService.create` (défense en
profondeur : un `assetId` soumis directement hors périmètre est rejeté, 400) pour que les deux
filtres ne divergent jamais.
- `UsersService` : `assertTopLevelSites` (un Demandeur est affecté à un site, jamais une zone —
même invariant que la hiérarchie site/zone) ; `invite()`/`update()` branchent `locationIds`.
- Web (`personnes.tsx`) : `ModaleInvitation` affiche une liste de sites à cocher quand le rôle
choisi est Demandeur ; colonne « Sites » dans le tableau, éditable via une petite modale
dédiée (`ModaleSites`, même hook `useUpdateUser` que le taux horaire).
- Mobile (`formulaire-demande.tsx`) : bouton « 📷 Scanner l'étiquette » sous le sélecteur
d'équipement — réutilise `analyseScan`/`expo-camera` du Scanner R4, mais résout **uniquement**
contre les options déjà chargées (déjà filtrées par site) — jamais de repli sur le parc
complet, ce qui annulerait la restriction. Aucun changement à `useAssetOptions()` : le
filtrage serveur profite automatiquement au formulaire.
- Seed : Karim Doukkali rattaché à **Tour Atlas** (cohérent avec le `guardianName` déjà présent
dans le seed pour ce site) ; ses demandes historiques sur d'autres sites restent visibles en
lecture, seule la création est désormais restreinte.
- **Bug trouvé en vérification (pas en recette, avant tout commit)** : la première version de
`RequestsService.create` comparait `allowed.includes(dto.assetId)` — mais `allowed` est une
liste d'**ids de sites/zones**, pas d'ids d'appareils. Comparaison apples-to-oranges, aurait
rejeté TOUT signalement d'un Demandeur affecté, y compris dans son propre périmètre (repéré en
testant B2/Tour Atlas avec Karim, qui échouait à tort). Corrigé : comparaison sur
`asset.locationId`. Méthode renommée `allowedAssetIds``allowedLocationIds` pour que le nom
dise ce qu'elle retourne réellement.
- Vérifié : filtrage confirmé côté serveur (Dispatcher = 8 appareils inchangés, Karim = 4
appareils de Tour Atlas seulement) ; défense en profondeur confirmée (asset hors périmètre →
400, asset dans le périmètre → 201). Suite de tests API : 79/80 verts — le seul échec
(`documents-analytics.e2e-spec.ts`, `monthCost`) est le flake déjà identifié cette session
(fenêtre calendaire réelle vs données de seed), sans rapport avec R6.6 ; `exploitation.e2e-spec.ts`
mis à jour (A1/C1 → A2/B1, dans le site de Karim, sinon rejetés par la nouvelle règle — c'est
le comportement voulu). Typecheck/tests/lint verts sur les 4 paquets.
**Décisions**
- Affectation au niveau **site** (pas zone, pas appareil individuel) — même granularité que
`Partner.sites`, cohérent avec le modèle existant.
- Gestion des sites d'un Demandeur reste une action **web uniquement** (admin) — le mobile
n'a que le raccourci scan, aucune UI de gestion.
**Prochaine étape** : reste de la recette R6 (Sites/Ascenseurs/Fichiers côté Gestionnaire, puis
passage Demandeur avec le nouveau périmètre) ; `expo-sharing`/`expo-clipboard` en réserve ;
restes non bloquants inchangés (Dokploy, Android physique, `AI_SEUIL_*`/darija,
`DOKPLOY_WEBHOOK_URL`).
---
## 2026-08-02 — Pr. Daaif (+ Claude) — Assistant mobile : dictée confirmée sur iPhone
**Actions**