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:
@@ -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')
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 })),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user