mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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:
@@ -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**
|
||||
|
||||
@@ -4587,6 +4587,27 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"assignedSites": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -4598,7 +4619,8 @@
|
||||
"teams",
|
||||
"status",
|
||||
"isDemo",
|
||||
"hourlyRate"
|
||||
"hourlyRate",
|
||||
"assignedSites"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -4704,6 +4726,14 @@
|
||||
"phone": {
|
||||
"type": "string",
|
||||
"maxLength": 40
|
||||
},
|
||||
"locationIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -4808,6 +4838,27 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"assignedSites": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -4819,7 +4870,8 @@
|
||||
"teams",
|
||||
"status",
|
||||
"isDemo",
|
||||
"hourlyRate"
|
||||
"hourlyRate",
|
||||
"assignedSites"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -4862,6 +4914,14 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"locationIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
Reference in New Issue
Block a user