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

@@ -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 R0R5. `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 R0R5. `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 R0R5 (v1) + R6 en cours.

View File

@@ -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;

View File

@@ -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

View File

@@ -462,6 +462,19 @@ async function seedExploitation(prisma: PrismaClient): Promise<void> {
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,
);

View File

@@ -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')

View File

@@ -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<AssetOptionsResponse> {
/** 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<string[] | null> {
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<AssetOptionsResponse> {
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' },
});

View File

@@ -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],
})

View File

@@ -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<Prisma.RequestWhereInput> {
@@ -52,6 +54,13 @@ export class RequestsService {
async create(dto: RequestCreate, user: AuthenticatedUser): Promise<RequestSummary> {
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({

View File

@@ -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<InvitationResponse> {
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<UserAdmin> {
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<void> {
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 })),
};
}
}

View File

@@ -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))

View File

@@ -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: {

View File

@@ -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<string | null>(null);
const [description, setDescription] = useState('');
const [personneBloquee, setPersonneBloquee] = useState(false);
const [scanOuvert, setScanOuvert] = useState(false);
const [erreurScan, setErreurScan] = useState<string | null>(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 ? (
<View style={{ gap: 8 }}>
{cameraUtilisable ? (
<View style={{ height: 220, borderRadius: 12, overflow: 'hidden' }}>
<CameraView
style={{ flex: 1 }}
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={surScan}
/>
</View>
) : (
<View
style={{
height: 220,
borderRadius: 12,
backgroundColor: '#131c2c',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
padding: 16,
}}
>
<Text
style={{
color: '#dfe7f2',
fontFamily: 'Manrope_600SemiBold',
fontSize: 12.5,
textAlign: 'center',
}}
>
{Platform.OS === 'web'
? 'Caméra indisponible sur web.'
: "Visez le QR de l'étiquette de cabine."}
</Text>
{Platform.OS !== 'web' && !permission?.granted ? (
<BoutonTel libelle="Autoriser la caméra" surAppui={() => void demanderPermission()} />
) : null}
</View>
)}
<BoutonTel libelle="Annuler le scan" variante="gris" surAppui={() => setScanOuvert(false)} />
</View>
) : (
<BoutonTel
libelle="📷 Scanner l'étiquette"
variante="contour"
surAppui={() => {
setErreurScan(null);
setScanOuvert(true);
}}
/>
)}
{erreurScan ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
{erreurScan}
</Text>
) : null}
<View style={{ gap: 4 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
Description <Text style={{ color: t.danger }}>*</Text>

View File

@@ -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: {

View File

@@ -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() {
<th>Rôle</th>
<th>Équipe</th>
<th>Taux horaire</th>
<th>Sites</th>
<th>Statut</th>
<th></th>
</tr>
@@ -75,6 +77,13 @@ export default function PagePersonnes() {
editable={can('PEOPLE_TEAMS', 'edit')}
/>
</td>
<td>
{u.role.name === 'Demandeur' ? (
<CelluleSites utilisateur={u} editable={can('PEOPLE_TEAMS', 'edit')} />
) : (
'—'
)}
</td>
<td>
{u.status === 'active' ? (
<span className="st termine">Actif</span>
@@ -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 (
<>
<span className="num">
{utilisateur.assignedSites.map((s) => s.name).join(', ') || '—'}
{editable ? (
<Button
variant="ghost"
className="ml-1 px-2 py-0.5 text-[12px]"
aria-label="Modifier les sites rattachés"
onClick={() => setModale(true)}
>
</Button>
) : null}
</span>
<ModaleSites utilisateur={utilisateur} ouverte={modale} surFermeture={setModale} />
</>
);
}
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<string[]>(() => utilisateur.assignedSites.map((s) => s.id));
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
maj.mutate(
{ id: utilisateur.id, locationIds: coches },
{ onSuccess: () => surFermeture(false) },
);
};
return (
<Modale titre={`Sites de ${utilisateur.displayName}`} ouverte={ouverte} surFermeture={surFermeture}>
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
{sites.map((s) => (
<label key={s.id} className="flex items-center gap-2 text-[13px]">
<input
type="checkbox"
checked={coches.includes(s.id)}
onChange={(e) =>
setCoches((c) => (e.target.checked ? [...c, s.id] : c.filter((id) => id !== s.id)))
}
/>
{s.name}
</label>
))}
</div>
<p className="note-douce">
Le signalement se limite aux sites cochés ci-dessus. Aucun site coché = aucune
restriction (accès au parc complet, comportement historique).
</p>
{maj.isError ? <p className="erreur-form" role="alert">{maj.error.message}</p> : null}
<div className="pied">
<Button onClick={() => surFermeture(false)}>Annuler</Button>
<Button type="submit" variant="prim" disabled={maj.isPending}>Enregistrer</Button>
</div>
</form>
</Modale>
);
}
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<string[]>([]);
const roleName = roles?.find((r) => r.id === roleId)?.name;
const sites = (locations ?? []).filter((l) => l.parentId === null);
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
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({
<div className="form-grille">
<div className="champ">
<label htmlFor="inv-role">Rôle *</label>
<select id="inv-role" name="roleId" required defaultValue="">
<select
id="inv-role"
name="roleId"
required
value={roleId}
onChange={(e) => setRoleId(e.target.value)}
>
<option value="" disabled>Choisir</option>
{(roles ?? []).map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
@@ -270,6 +377,30 @@ function ModaleInvitation({
</select>
</div>
</div>
{roleName === 'Demandeur' ? (
<div className="champ">
<label>Sites autorisés en signalement (optionnel)</label>
<div className="flex flex-col gap-1">
{sites.map((s) => (
<label key={s.id} className="flex items-center gap-2 text-[13px]">
<input
type="checkbox"
checked={sitesCoches.includes(s.id)}
onChange={(e) =>
setSitesCoches((c) =>
e.target.checked ? [...c, s.id] : c.filter((id) => id !== s.id),
)
}
/>
{s.name}
</label>
))}
</div>
<p className="note-douce">
Aucun site coché = aucune restriction (accès au parc complet).
</p>
</div>
) : null}
<p className="note-douce">
La personne recevra un <b>lien d'activation valable 7 jours</b> pour choisir
son mot de passe. Aucun compte n'est actif avant cela.

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**

View File

@@ -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

View File

@@ -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<typeof UserAdminSchema>;
@@ -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<typeof UserUpdateSchema>;
@@ -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<typeof InvitationCreateSchema>;