diff --git a/CLAUDE.md b/CLAUDE.md index ce039bc..b9a5693 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,5 +79,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS), - ✅ **R6.5 — Assistant mobile (chat sourcé + dictée)** : maquette dédiée validée (3 écrans, D1-D5 — D5 ajoutée en revue : question tapée OU dictée, même pipeline que la dictée déjà livrée en clôture, transcription remplit le champ, jamais d'envoi automatique). `api/assistant.ts` (503 géré comme le web). Écran Assistant : chat un échange à la fois, citations numérotées, sources avec extrait exact (« Voir le document » → métadonnées seules, R6.3 ; « Ouvrir l'OT » → fiche R4), refus honnête chiffré + Reformuler, avertissement permanent. Fiche document (`bibliotheque/[id].tsx`, nouveau) — la liste R6.3 y mène aussi désormais. **Le Menu R6 n'a plus d'entrée « à venir »** dans les 4 groupes (Catégories exceptée, admin, hors périmètre mobile). Typecheck propre, 17 tests Jest, lint 5/5, contrat non touché. - 🔄 **Recette R6 en cours (Gestionnaire, partie 1)** — 2 bugs réels trouvés et corrigés sur iPhone physique : Menu non scrollable (le dernier groupe, Pilotage, était strictement inaccessible une fois les 4 groupes pleinement câblés — présent depuis R6.1, révélé seulement maintenant) ; BC créé sans confirmation visible dans l'app (vérifié côté serveur : les BC étaient bien créés, juste aucun retour affiché). Amélioration sur retour direct : Tiers gagne une fiche détail (identité/contact/BC en cours/sites rattachés) — le lecture-seule sans aucune réaction au tap se lisait comme cassé. Trois signalements vérifiés et écartés (faux positifs) : approbation de demande (a fonctionné), bibliothèque vide (confirmé côté serveur — aucun document sur cette instance, pas un bug mobile), période statistiques (transmise et traitée correctement, les indicateurs affichés ne varient juste pas avec ce jeu de données). - ✅ **Assistant mobile — dictée confirmée sur iPhone physique**, après 3 corrections trouvées en conditions réelles (aucune n'aurait été vue par typecheck/tests/lint) : `expo-file-system` deleteAsync déprécié SDK 57 (import `/legacy`, appliqué aussi à `cloture.tsx`) ; texte transcrit pas entièrement visible (zone multiligne pleine largeur, Envoyer en geste séparé) ; bouton Envoyer chevauchant encore le texte (ScrollView du chat sans `style={flex:1}`, TextInput à hauteur fixe plutôt que `maxHeight` seul, pas fiable sur iOS). -- 🔄 **Reprise ici** : suite de la recette Gestionnaire (Sites/Ascenseurs/Fichiers restent à confirmer explicitement), puis passage Demandeur (permissions les plus étroites) — avant de considérer R6 close au même sens que R0→R5. `expo-sharing` (ouverture de documents) et `expo-clipboard` (copie du lien d'activation) en réserve pour une prochaine étape native. Restes non bloquants inchangés : redéploiement Dokploy de l'instance ENSET (`AI_SERVICE_TOKEN` à créer — runbook §2 — puis « Réindexer tout »), recette Android sur appareil physique, calibrage `AI_SEUIL_*` et qualité darija sur corpus SPELEV réel, secret `DOKPLOY_WEBHOOK_URL`, production client SPELEV (attend les accès serveur du partenaire). +- ✅ **R6.6 — Demandeur restreint à son site** : trou trouvé en recette — `GET /assets/options` n'avait aucun filtre (web ET mobile touchés, pas seulement mobile). Corrigé à la racine : relation `User↔Location` (`assignedSites`, migration `r6_demandeur_sites`, vide = aucune restriction pour les autres rôles) ; `AssetsService.allowedLocationIds()` réutilisée par `options()` et par `RequestsService.create` (défense en profondeur, 400 si hors périmètre) ; gestion des sites d'un Demandeur côté web (`personnes.tsx`, invitation + modale dédiée) ; raccourci scan QR côté mobile (`formulaire-demande.tsx`, résout uniquement contre les options déjà filtrées, jamais de repli sur le parc complet). Karim Doukkali (démo) rattaché à Tour Atlas. Bug trouvé en vérification avant tout commit (comparaison id-de-site vs id-d'appareil dans `create()`, aurait rejeté à tort tout signalement d'un Demandeur affecté) et corrigé, méthode renommée `allowedAssetIds` → `allowedLocationIds` pour que le nom dise ce qu'elle retourne. 79/80 tests API (le seul échec est le flake `monthCost` déjà connu, sans rapport) ; typecheck/tests/lint verts sur les 4 paquets. +- 🔄 **Reprise ici** : reste de la recette R6 (Sites/Ascenseurs/Fichiers côté Gestionnaire à confirmer explicitement, puis passage Demandeur avec le nouveau périmètre de site) — avant de considérer R6 close au même sens que R0→R5. `expo-sharing` (ouverture de documents) et `expo-clipboard` (copie du lien d'activation) en réserve pour une prochaine étape native. Restes non bloquants inchangés : redéploiement Dokploy de l'instance ENSET (`AI_SERVICE_TOKEN` à créer — runbook §2 — puis « Réindexer tout »), recette Android sur appareil physique, calibrage `AI_SEUIL_*` et qualité darija sur corpus SPELEV réel, secret `DOKPLOY_WEBHOOK_URL`, production client SPELEV (attend les accès serveur du partenaire). - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5 (v1) + R6 en cours. diff --git a/apps/api/prisma/migrations/20260802190326_r6_demandeur_sites/migration.sql b/apps/api/prisma/migrations/20260802190326_r6_demandeur_sites/migration.sql new file mode 100644 index 0000000..339f6b9 --- /dev/null +++ b/apps/api/prisma/migrations/20260802190326_r6_demandeur_sites/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "_LocationToUser" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL, + + CONSTRAINT "_LocationToUser_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE INDEX "_LocationToUser_B_index" ON "_LocationToUser"("B"); + +-- AddForeignKey +ALTER TABLE "_LocationToUser" ADD CONSTRAINT "_LocationToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Location"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_LocationToUser" ADD CONSTRAINT "_LocationToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 986119a..67e7c23 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -49,6 +49,8 @@ model User { activationToken String? @unique activationExpiresAt DateTime? teams Team[] + // R6.6 — sites autorisés en signalement (Demandeur) ; vide = aucune restriction + assignedSites Location[] // R2 — exploitation workOrdersAssigned WorkOrder[] @relation("WorkOrderAssignees") workOrdersCreated WorkOrder[] @relation("WorkOrderCreator") @@ -111,6 +113,7 @@ model Location { partnerId String? @db.Uuid partner Partner? @relation(fields: [partnerId], references: [id]) assets Asset[] + assignedUsers User[] // reverse de User.assignedSites (R6.6) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 38a60cc..6da3470 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -462,6 +462,19 @@ async function seedExploitation(prisma: PrismaClient): Promise { const karim = await parEmail('demandeur@demo.siop.ma'); const annee = new Date().getFullYear(); + // R6.6 — Karim (Demandeur) est rattaché à Tour Atlas (cohérent avec le + // guardianName déjà seedé pour ce site) : son signalement se limite à ce + // parc, ses demandes historiques sur d'autres sites restent visibles. + const tourAtlas = await prisma.location.findFirst({ + where: { name: 'Tour Atlas', parentId: null }, + }); + if (tourAtlas) { + await prisma.user.update({ + where: { id: karim }, + data: { assignedSites: { connect: [{ id: tourAtlas.id }] } }, + }); + } + const grilleLabels = TASK_TEMPLATES.filter((t) => t.periodMonths === 1).map( (t) => t.label, ); diff --git a/apps/api/src/assets/assets.controller.ts b/apps/api/src/assets/assets.controller.ts index 8b05f0a..2b3808f 100644 --- a/apps/api/src/assets/assets.controller.ts +++ b/apps/api/src/assets/assets.controller.ts @@ -17,6 +17,7 @@ import { type AssetCreate, type AssetUpdate, } from '@siop/shared'; +import { type AuthenticatedUser, CurrentUser } from '../auth/current-user.decorator'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { RequirePermission } from '../permissions/require-permission.decorator'; import { AssetsService } from './assets.service'; @@ -32,10 +33,11 @@ export class AssetsController { } /** Avant ':id' (ordre des routes) — authentification seule : le demandeur - * doit pouvoir désigner l'appareil qu'il signale. */ + * doit pouvoir désigner l'appareil qu'il signale (filtré à son site s'il + * en a un, R6.6). */ @Get('options') - options() { - return this.assetsService.options(); + options(@CurrentUser() user: AuthenticatedUser) { + return this.assetsService.options(user); } @Get(':id') diff --git a/apps/api/src/assets/assets.service.ts b/apps/api/src/assets/assets.service.ts index 33cdec0..0e33e09 100644 --- a/apps/api/src/assets/assets.service.ts +++ b/apps/api/src/assets/assets.service.ts @@ -15,6 +15,7 @@ import type { AssetsResponse, AssetUpdate, } from '@siop/shared'; +import type { AuthenticatedUser } from '../auth/current-user.decorator'; import { PrismaService } from '../prisma/prisma.service'; const assetInclude = { @@ -29,9 +30,30 @@ type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>; export class AssetsService { constructor(private readonly prisma: PrismaService) {} - /** Options minimales pour le signalement — ouvert à tout rôle authentifié. */ - async options(): Promise { + /** Sites + zones autorisés pour le signalement de `user`, ou `null` si aucune + * restriction (comportement historique — tous les rôles sauf un Demandeur + * affecté à un site, R6.6). Réutilisée par `options()` ET par + * `RequestsService.create` pour que les deux filtres ne divergent jamais. */ + async allowedLocationIds(user: AuthenticatedUser): Promise { + const me = await this.prisma.user.findUnique({ + where: { id: user.userId }, + select: { assignedSites: { select: { id: true } } }, + }); + const siteIds = (me?.assignedSites ?? []).map((s) => s.id); + if (siteIds.length === 0) return null; + const zones = await this.prisma.location.findMany({ + where: { parentId: { in: siteIds } }, + select: { id: true }, + }); + return [...siteIds, ...zones.map((z) => z.id)]; + } + + /** Options minimales pour le signalement — ouvert à tout rôle authentifié, + * filtré au périmètre du Demandeur s'il est affecté à un site (R6.6). */ + async options(user: AuthenticatedUser): Promise { + const allowed = await this.allowedLocationIds(user); const rows = await this.prisma.asset.findMany({ + where: allowed ? { locationId: { in: allowed } } : undefined, include: { location: { include: { parent: true } } }, orderBy: { reference: 'asc' }, }); diff --git a/apps/api/src/requests/requests.module.ts b/apps/api/src/requests/requests.module.ts index 7c2a763..2e3df0f 100644 --- a/apps/api/src/requests/requests.module.ts +++ b/apps/api/src/requests/requests.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; +import { AssetsModule } from '../assets/assets.module'; import { WorkOrdersModule } from '../work-orders/work-orders.module'; import { RequestsController } from './requests.controller'; import { RequestsService } from './requests.service'; @Module({ - imports: [WorkOrdersModule], + imports: [WorkOrdersModule, AssetsModule], controllers: [RequestsController], providers: [RequestsService], }) diff --git a/apps/api/src/requests/requests.service.ts b/apps/api/src/requests/requests.service.ts index 1ac987f..702d416 100644 --- a/apps/api/src/requests/requests.service.ts +++ b/apps/api/src/requests/requests.service.ts @@ -16,6 +16,7 @@ import type { import type { AuthenticatedUser } from '../auth/current-user.decorator'; import { PermissionsService } from '../permissions/permissions.service'; import { PrismaService } from '../prisma/prisma.service'; +import { AssetsService } from '../assets/assets.service'; import { WorkOrdersService } from '../work-orders/work-orders.service'; const requestInclude = { @@ -32,6 +33,7 @@ export class RequestsService { private readonly prisma: PrismaService, private readonly permissions: PermissionsService, private readonly workOrders: WorkOrdersService, + private readonly assets: AssetsService, ) {} private async scope(user: AuthenticatedUser): Promise { @@ -52,6 +54,13 @@ export class RequestsService { async create(dto: RequestCreate, user: AuthenticatedUser): Promise { const asset = await this.prisma.asset.findUnique({ where: { id: dto.assetId } }); if (!asset) throw new BadRequestException('Équipement inconnu'); + // Défense en profondeur (R6.6) : même filtre que /assets/options, pour + // qu'un Demandeur affecté à un site ne puisse pas contourner la liste + // en soumettant directement un assetId hors périmètre. + const allowed = await this.assets.allowedLocationIds(user); + if (allowed && !allowed.includes(asset.locationId)) { + throw new BadRequestException("Cet équipement n'est pas dans votre périmètre"); + } for (let essai = 0; ; essai++) { try { const created = await this.prisma.request.create({ diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts index 3bff0ad..ea1748b 100644 --- a/apps/api/src/users/users.service.ts +++ b/apps/api/src/users/users.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, ConflictException, Injectable, NotFoundException, @@ -20,6 +21,7 @@ import { PrismaService } from '../prisma/prisma.service'; const userInclude = { role: true, teams: { orderBy: { name: 'asc' } }, + assignedSites: { orderBy: { name: 'asc' } }, } satisfies Prisma.UserInclude; type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>; @@ -46,6 +48,7 @@ export class UsersService { async invite(dto: InvitationCreate): Promise { const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } }); if (!role) throw new NotFoundException('Rôle inconnu'); + if (dto.locationIds?.length) await this.assertTopLevelSites(dto.locationIds); try { const user = await this.prisma.user.create({ data: { @@ -56,6 +59,9 @@ export class UsersService { teams: dto.teamIds?.length ? { connect: dto.teamIds.map((id) => ({ id })) } : undefined, + assignedSites: dto.locationIds?.length + ? { connect: dto.locationIds.map((id) => ({ id })) } + : undefined, ...this.freshToken(), }, }); @@ -90,6 +96,7 @@ export class UsersService { } async update(userId: string, dto: UserUpdate): Promise { + if (dto.locationIds) await this.assertTopLevelSites(dto.locationIds); try { const updated = await this.prisma.user.update({ where: { id: userId }, @@ -102,6 +109,9 @@ export class UsersService { teams: dto.teamIds ? { set: dto.teamIds.map((id) => ({ id })) } : undefined, + assignedSites: dto.locationIds + ? { set: dto.locationIds.map((id) => ({ id })) } + : undefined, }, include: userInclude, }); @@ -114,6 +124,16 @@ export class UsersService { } } + /** R6.6 : un Demandeur n'est affecté qu'à des sites racine, jamais des zones + * — même invariant que la hiérarchie site/zone (LocationsService.assertDepth). */ + private async assertTopLevelSites(ids: string[]): Promise { + const sites = await this.prisma.location.findMany({ where: { id: { in: ids } } }); + if (sites.length !== ids.length) throw new BadRequestException('Site inconnu'); + if (sites.some((s) => s.parentId)) { + throw new BadRequestException("L'affectation d'un Demandeur se fait à un site, pas à une zone"); + } + } + private freshToken() { return { activationToken: randomBytes(32).toString('base64url'), @@ -138,6 +158,7 @@ export class UsersService { : 'invited', isDemo: row.isDemo, hourlyRate: row.hourlyRate === null ? null : Number(row.hourlyRate), + assignedSites: row.assignedSites.map((s) => ({ id: s.id, name: s.name })), }; } } diff --git a/apps/api/test/exploitation.e2e-spec.ts b/apps/api/test/exploitation.e2e-spec.ts index b24088b..b365b17 100644 --- a/apps/api/test/exploitation.e2e-spec.ts +++ b/apps/api/test/exploitation.e2e-spec.ts @@ -55,9 +55,10 @@ describe('Exploitation (e2e)', () => { const auth = (t: string) => ({ Authorization: `Bearer ${t}` }); it('recette : demande → approbation → OT assigné → bilan → clôture → suivi', async () => { - // 1. Karim (gardien) signale + // 1. Karim (gardien) signale — sur A2, dans son site rattaché (Tour + // Atlas, R6.6) : un Demandeur ne peut plus signaler hors périmètre. const { body: assets } = await http().get('/assets').set(auth(salma)); - const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A1'); + const a1 = assets.assets.find((a: { reference: string }) => a.reference === 'A2'); const demande = await http() .post('/requests') .set(auth(karim)) @@ -234,8 +235,9 @@ describe('Exploitation (e2e)', () => { }); it('rejet : motif obligatoire ; bilan : valeur hors champ refusée', async () => { + // B1 : dans le site rattaché de Karim (Tour Atlas, R6.6). const { body: assets } = await http().get('/assets').set(auth(salma)); - const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'C1'); + const c1 = assets.assets.find((a: { reference: string }) => a.reference === 'B1'); const demande = await http() .post('/requests') .set(auth(karim)) diff --git a/apps/mobile/src/api/schema.d.ts b/apps/mobile/src/api/schema.d.ts index fcf0831..2aa96db 100644 --- a/apps/mobile/src/api/schema.d.ts +++ b/apps/mobile/src/api/schema.d.ts @@ -1416,6 +1416,11 @@ export interface components { status: "active" | "invited" | "disabled"; isDemo: boolean; hourlyRate: number | null; + assignedSites: { + /** Format: uuid */ + id: string; + name: string; + }[]; }[]; }; RolesResponse: { @@ -1441,6 +1446,7 @@ export interface components { roleId: string; teamIds?: string[]; phone?: string; + locationIds?: string[]; }; UserAdmin: { /** Format: uuid */ @@ -1464,6 +1470,11 @@ export interface components { status: "active" | "invited" | "disabled"; isDemo: boolean; hourlyRate: number | null; + assignedSites: { + /** Format: uuid */ + id: string; + name: string; + }[]; }; UserUpdate: { displayName?: string; @@ -1473,6 +1484,7 @@ export interface components { teamIds?: string[]; isActive?: boolean; hourlyRate?: number | null; + locationIds?: string[]; }; DocumentsResponse: { documents: { diff --git a/apps/mobile/src/composants/formulaire-demande.tsx b/apps/mobile/src/composants/formulaire-demande.tsx index 921009a..b2abea8 100644 --- a/apps/mobile/src/composants/formulaire-demande.tsx +++ b/apps/mobile/src/composants/formulaire-demande.tsx @@ -1,13 +1,19 @@ -import { useState } from 'react'; -import { Pressable, Text, TextInput, View } from 'react-native'; +import { CameraView, useCameraPermissions } from 'expo-camera'; +import { useRef, useState } from 'react'; +import { Platform, Pressable, Text, TextInput, View } from 'react-native'; import { useAssetOptions, useCreateRequest } from '@/api/exploitation'; +import { analyseScan } from '@/lib/scan'; import { useTokens } from '@/theme/tokens'; import { BoutonTel, ChoixTel } from './ui'; /** Formulaire « Nouvelle demande » — mêmes champs que la modale de * signalement du web (asset via `/assets/options`, accessible même sans * ASSETS.view ; description ; personne bloquée). Utilisé à la fois par - * l'onglet du Demandeur et par le Menu des rôles gestion. */ + * l'onglet du Demandeur et par le Menu des rôles gestion. + * Scan QR (R6.6) : raccourci mobile pour resélectionner un équipement déjà + * dans son périmètre — résolution UNIQUEMENT contre les options déjà + * chargées (déjà filtrées par site pour un Demandeur affecté), jamais un + * repli sur le parc complet qui annulerait la restriction. */ export function FormulaireDemande({ surSucces }: { surSucces: (id: string) => void }) { const t = useTokens(); const { data: options } = useAssetOptions(); @@ -15,9 +21,31 @@ export function FormulaireDemande({ surSucces }: { surSucces: (id: string) => vo const [assetId, setAssetId] = useState(null); const [description, setDescription] = useState(''); const [personneBloquee, setPersonneBloquee] = useState(false); + const [scanOuvert, setScanOuvert] = useState(false); + const [erreurScan, setErreurScan] = useState(null); + const [permission, demanderPermission] = useCameraPermissions(); + const dernierScan = useRef(0); const asset = (options ?? []).find((a) => a.id === assetId) ?? null; const valide = !!assetId && description.trim().length >= 3; + const cameraUtilisable = Platform.OS !== 'web' && permission?.granted; + + const surScan = ({ data }: { data: string }) => { + const maintenant = Date.now(); + if (maintenant - dernierScan.current < 1500) return; // anti-rafale + dernierScan.current = maintenant; + const reference = analyseScan(data); + const trouve = reference + ? (options ?? []).find((a) => a.reference.toUpperCase() === reference) + : null; + if (!trouve) { + setErreurScan("Cet appareil n'existe pas ou n'est pas dans votre périmètre."); + return; + } + setErreurScan(null); + setAssetId(trouve.id); + setScanOuvert(false); + }; const envoyer = () => { if (!assetId) return; @@ -36,6 +64,62 @@ export function FormulaireDemande({ surSucces }: { surSucces: (id: string) => vo options={(options ?? []).map((a) => ({ id: a.id, label: `${a.reference} — ${a.siteName}` }))} surChoix={setAssetId} /> + {scanOuvert ? ( + + {cameraUtilisable ? ( + + + + ) : ( + + + {Platform.OS === 'web' + ? 'Caméra indisponible sur web.' + : "Visez le QR de l'étiquette de cabine."} + + {Platform.OS !== 'web' && !permission?.granted ? ( + void demanderPermission()} /> + ) : null} + + )} + setScanOuvert(false)} /> + + ) : ( + { + setErreurScan(null); + setScanOuvert(true); + }} + /> + )} + {erreurScan ? ( + + {erreurScan} + + ) : null} Description * diff --git a/apps/web/src/api/schema.d.ts b/apps/web/src/api/schema.d.ts index fcf0831..2aa96db 100644 --- a/apps/web/src/api/schema.d.ts +++ b/apps/web/src/api/schema.d.ts @@ -1416,6 +1416,11 @@ export interface components { status: "active" | "invited" | "disabled"; isDemo: boolean; hourlyRate: number | null; + assignedSites: { + /** Format: uuid */ + id: string; + name: string; + }[]; }[]; }; RolesResponse: { @@ -1441,6 +1446,7 @@ export interface components { roleId: string; teamIds?: string[]; phone?: string; + locationIds?: string[]; }; UserAdmin: { /** Format: uuid */ @@ -1464,6 +1470,11 @@ export interface components { status: "active" | "invited" | "disabled"; isDemo: boolean; hourlyRate: number | null; + assignedSites: { + /** Format: uuid */ + id: string; + name: string; + }[]; }; UserUpdate: { displayName?: string; @@ -1473,6 +1484,7 @@ export interface components { teamIds?: string[]; isActive?: boolean; hourlyRate?: number | null; + locationIds?: string[]; }; DocumentsResponse: { documents: { diff --git a/apps/web/src/pages/personnes.tsx b/apps/web/src/pages/personnes.tsx index e45f7ac..fbf47e4 100644 --- a/apps/web/src/pages/personnes.tsx +++ b/apps/web/src/pages/personnes.tsx @@ -1,8 +1,9 @@ import { useState, type FormEvent } from 'react'; -import type { InvitationResponse } from '@siop/shared'; +import type { InvitationResponse, UserAdmin } from '@siop/shared'; import { useCreateTeam, useInviteUser, + useLocations, useResendInvitation, useRoles, useTeams, @@ -46,6 +47,7 @@ export default function PagePersonnes() { Rôle Équipe Taux horaire + Sites Statut @@ -75,6 +77,13 @@ export default function PagePersonnes() { editable={can('PEOPLE_TEAMS', 'edit')} /> + + {u.role.name === 'Demandeur' ? ( + + ) : ( + '—' + )} + {u.status === 'active' ? ( Actif @@ -206,6 +215,90 @@ function CelluleTaux({ ); } +/** Sites autorisés en signalement (R6.6) — un Demandeur ne choisit son + * équipement que parmi ceux de ses sites rattachés (web + mobile, même + * filtre côté API : `/assets/options`). */ +function CelluleSites({ + utilisateur, + editable, +}: { + utilisateur: UserAdmin; + editable: boolean; +}) { + const [modale, setModale] = useState(false); + return ( + <> + + {utilisateur.assignedSites.map((s) => s.name).join(', ') || '—'} + {editable ? ( + + ) : null} + + + + ); +} + +function ModaleSites({ + utilisateur, + ouverte, + surFermeture, +}: { + utilisateur: UserAdmin; + ouverte: boolean; + surFermeture: (o: boolean) => void; +}) { + const { data: locations } = useLocations(); + const sites = (locations ?? []).filter((l) => l.parentId === null); + const maj = useUpdateUser(); + const [coches, setCoches] = useState(() => utilisateur.assignedSites.map((s) => s.id)); + + const surEnvoi = (e: FormEvent) => { + e.preventDefault(); + maj.mutate( + { id: utilisateur.id, locationIds: coches }, + { onSuccess: () => surFermeture(false) }, + ); + }; + + return ( + +
+
+ {sites.map((s) => ( + + ))} +
+

+ Le signalement se limite aux sites cochés ci-dessus. Aucun site coché = aucune + restriction (accès au parc complet, comportement historique). +

+ {maj.isError ?

{maj.error.message}

: null} +
+ + +
+
+
+ ); +} + function ModaleInvitation({ ouverte, surFermeture, @@ -217,7 +310,12 @@ function ModaleInvitation({ }) { const { data: roles } = useRoles(); const { data: teams } = useTeams(); + const { data: locations } = useLocations(); const invitation = useInviteUser(); + const [roleId, setRoleId] = useState(''); + const [sitesCoches, setSitesCoches] = useState([]); + const roleName = roles?.find((r) => r.id === roleId)?.name; + const sites = (locations ?? []).filter((l) => l.parentId === null); const surEnvoi = (e: FormEvent) => { e.preventDefault(); @@ -229,10 +327,13 @@ function ModaleInvitation({ displayName: String(donnees.get('displayName')), roleId: String(donnees.get('roleId')), teamIds: teamId ? [teamId] : undefined, + locationIds: sitesCoches.length ? sitesCoches : undefined, }, { onSuccess: (reponse) => { surFermeture(false); + setRoleId(''); + setSitesCoches([]); surLien(reponse); }, }, @@ -253,7 +354,13 @@ function ModaleInvitation({
- setRoleId(e.target.value)} + > {(roles ?? []).map((r) => ( @@ -270,6 +377,30 @@ function ModaleInvitation({
+ {roleName === 'Demandeur' ? ( +
+ +
+ {sites.map((s) => ( + + ))} +
+

+ Aucun site coché = aucune restriction (accès au parc complet). +

+
+ ) : null}

La personne recevra un lien d'activation valable 7 jours pour choisir son mot de passe. Aucun compte n'est actif avant cela. diff --git a/docs/journal/journal.md b/docs/journal/journal.md index 002761e..ae13e88 100644 --- a/docs/journal/journal.md +++ b/docs/journal/journal.md @@ -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** diff --git a/docs/openapi.json b/docs/openapi.json index 36dacbd..26b0d0d 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -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 diff --git a/packages/shared/src/schemas/users-admin.ts b/packages/shared/src/schemas/users-admin.ts index d9f05d0..415ed51 100644 --- a/packages/shared/src/schemas/users-admin.ts +++ b/packages/shared/src/schemas/users-admin.ts @@ -16,6 +16,8 @@ export const UserAdminSchema = z.object({ isDemo: z.boolean(), /** Taux horaire COURANT (MAD/h) — chaque saisie de main-d'œuvre fige le sien. */ hourlyRate: z.number().nullable(), + /** R6.6 — sites autorisés en signalement (Demandeur) ; vide = aucune restriction. */ + assignedSites: z.array(z.object({ id: z.uuid(), name: z.string() })), }); export type UserAdmin = z.infer; @@ -34,6 +36,7 @@ export const UserUpdateSchema = z.object({ teamIds: z.array(z.uuid()).optional(), // remplace l'affectation isActive: z.boolean().optional(), hourlyRate: z.number().positive().nullable().optional(), // R3 — taux courant + locationIds: z.array(z.uuid()).optional(), // R6.6 — remplace l'affectation (sites racine) }); export type UserUpdate = z.infer; @@ -45,6 +48,7 @@ export const InvitationCreateSchema = z.object({ roleId: z.uuid(), teamIds: z.array(z.uuid()).optional(), phone: z.string().max(40).optional(), + locationIds: z.array(z.uuid()).optional(), // R6.6 — sites racine (Demandeur) }); export type InvitationCreate = z.infer;