feat(r1.1): socle backend du référentiel — modèle, contrat, API, seed, tests

- migration r1_referentiel : Category (EQUIPMENT/COMPONENT_TYPE), Location
  (site → zone, lat/lng + colonne PostGIS générée geography(Point,4326)
  + index GIST), Asset (statut d'équipement), AssetComponent (organe sans
  emplacement PAR CONSTRUCTION), Team, invitation sur User ; migration
  autosuffisante (CREATE EXTENSION IF NOT EXISTS postgis)
- contrat : 21 nouvelles opérations (26 total), générateur OpenAPI étendu
  aux paramètres de chemin ; spec + client web régénérés dans ce commit
- API : modules categories/locations/assets/teams + gestion des personnes
  (liste, rôles, invitation lien 7 j à usage unique, activation publique
  qui connecte directement, mise à jour rôle/équipes) — tout sous
  @RequirePermission ; invariants en service (profondeur 2, kinds,
  catégorie jamais supprimée)
- seed : parc de la maquette validée (5 sites + 8 zones, 8 appareils,
  organes A1/B2, 9 catégories, 2 équipes) — idempotent
- 36 tests verts (couverture 96 % stmts / 85 % branches) : recette
  site→zone→appareil→organes, matrice vivante, invitation→activation ;
  smoke test sur build de prod
- CI : postgres → postgis/postgis:18-3.6 (la migration R1 l'exige)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-16 12:27:48 +01:00
parent 6d9aafdab4
commit 266ffaaf1b
36 changed files with 6016 additions and 13 deletions

View File

@@ -0,0 +1,44 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
LocationCreateSchema,
LocationUpdateSchema,
type LocationCreate,
type LocationUpdate,
} from '@siop/shared';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { LocationsService } from './locations.service';
@Controller('locations')
export class LocationsController {
constructor(private readonly locationsService: LocationsService) {}
@Get()
@RequirePermission('LOCATIONS', 'view')
list() {
return this.locationsService.list();
}
@Post()
@RequirePermission('LOCATIONS', 'create')
create(@Body(new ZodValidationPipe(LocationCreateSchema)) body: LocationCreate) {
return this.locationsService.create(body);
}
@Patch(':id')
@RequirePermission('LOCATIONS', 'edit')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(LocationUpdateSchema)) body: LocationUpdate,
) {
return this.locationsService.update(id, body);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LocationsController } from './locations.controller';
import { LocationsService } from './locations.service';
@Module({
controllers: [LocationsController],
providers: [LocationsService],
exports: [LocationsService],
})
export class LocationsModule {}

View File

@@ -0,0 +1,113 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
LocationCreate,
LocationDto,
LocationsResponse,
LocationUpdate,
} from '@siop/shared';
import { PrismaService } from '../prisma/prisma.service';
type LocationRow = {
id: string;
name: string;
parentId: string | null;
address: string | null;
city: string | null;
guardianName: string | null;
guardianPhone: string | null;
latitude: number | null;
longitude: number | null;
_count: { assets: number };
};
@Injectable()
export class LocationsService {
constructor(private readonly prisma: PrismaService) {}
/** Liste plate ; l'assetCount d'un SITE inclut les appareils de ses zones. */
async list(): Promise<LocationsResponse> {
const rows: LocationRow[] = await this.prisma.location.findMany({
include: { _count: { select: { assets: true } } },
orderBy: { name: 'asc' },
});
const childAssets = new Map<string, number>();
for (const row of rows) {
if (row.parentId) {
childAssets.set(
row.parentId,
(childAssets.get(row.parentId) ?? 0) + row._count.assets,
);
}
}
return {
locations: rows.map((row) =>
this.toDto(row, row._count.assets + (childAssets.get(row.id) ?? 0)),
),
};
}
async create(dto: LocationCreate): Promise<LocationDto> {
await this.assertDepth(dto.parentId);
const created = await this.prisma.location.create({
data: dto,
include: { _count: { select: { assets: true } } },
});
return this.toDto(created, 0);
}
async update(id: string, dto: LocationUpdate): Promise<LocationDto> {
const existing = await this.prisma.location.findUnique({
where: { id },
include: { _count: { select: { children: true, assets: true } } },
});
if (!existing) throw new NotFoundException('Emplacement inconnu');
if (dto.parentId) {
if (dto.parentId === id) {
throw new BadRequestException('Un emplacement ne peut pas être son propre parent');
}
if (existing._count.children > 0) {
throw new BadRequestException(
'Ce site a des zones : il ne peut pas devenir une zone (hiérarchie limitée à 2 niveaux)',
);
}
await this.assertDepth(dto.parentId);
}
const updated = await this.prisma.location.update({
where: { id },
data: dto,
include: { _count: { select: { assets: true } } },
});
return this.toDto(updated, updated._count.assets);
}
/** Invariant R1 : site → zone, jamais plus profond. */
private async assertDepth(parentId?: string): Promise<void> {
if (!parentId) return;
const parent = await this.prisma.location.findUnique({ where: { id: parentId } });
if (!parent) throw new BadRequestException('Emplacement parent inconnu');
if (parent.parentId) {
throw new BadRequestException(
'Hiérarchie limitée à 2 niveaux : une zone ne peut pas contenir demplacement',
);
}
}
private toDto(row: Omit<LocationRow, '_count'>, assetCount: number): LocationDto {
return {
id: row.id,
name: row.name,
parentId: row.parentId,
address: row.address,
city: row.city,
guardianName: row.guardianName,
guardianPhone: row.guardianPhone,
latitude: row.latitude,
longitude: row.longitude,
assetCount,
};
}
}