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,73 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
AssetComponentCreateSchema,
AssetCreateSchema,
AssetUpdateSchema,
type AssetComponentCreate,
type AssetCreate,
type AssetUpdate,
} from '@siop/shared';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { AssetsService } from './assets.service';
@Controller('assets')
export class AssetsController {
constructor(private readonly assetsService: AssetsService) {}
@Get()
@RequirePermission('ASSETS', 'view')
list() {
return this.assetsService.list();
}
@Get(':id')
@RequirePermission('ASSETS', 'view')
get(@Param('id', ParseUUIDPipe) id: string) {
return this.assetsService.get(id);
}
@Post()
@RequirePermission('ASSETS', 'create')
create(@Body(new ZodValidationPipe(AssetCreateSchema)) body: AssetCreate) {
return this.assetsService.create(body);
}
@Patch(':id')
@RequirePermission('ASSETS', 'edit')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(AssetUpdateSchema)) body: AssetUpdate,
) {
return this.assetsService.update(id, body);
}
@Post(':id/components')
@RequirePermission('ASSETS', 'edit')
addComponent(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(AssetComponentCreateSchema)) body: AssetComponentCreate,
) {
return this.assetsService.addComponent(id, body);
}
@Delete(':id/components/:componentId')
@RequirePermission('ASSETS', 'edit')
@HttpCode(204)
removeComponent(
@Param('id', ParseUUIDPipe) id: string,
@Param('componentId', ParseUUIDPipe) componentId: string,
) {
return this.assetsService.removeComponent(id, componentId);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AssetsController } from './assets.controller';
import { AssetsService } from './assets.service';
@Module({
controllers: [AssetsController],
providers: [AssetsService],
exports: [AssetsService],
})
export class AssetsModule {}

View File

@@ -0,0 +1,183 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
AssetComponentCreate,
AssetComponentDto,
AssetCreate,
AssetDetail,
AssetDto,
AssetsResponse,
AssetUpdate,
} from '@siop/shared';
import { PrismaService } from '../prisma/prisma.service';
const assetInclude = {
category: true,
location: { include: { parent: true } },
_count: { select: { components: true } },
} satisfies Prisma.AssetInclude;
type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
@Injectable()
export class AssetsService {
constructor(private readonly prisma: PrismaService) {}
async list(): Promise<AssetsResponse> {
const rows = await this.prisma.asset.findMany({
include: assetInclude,
orderBy: { reference: 'asc' },
});
return { assets: rows.map((r) => this.toDto(r)) };
}
async get(id: string): Promise<AssetDetail> {
const row = await this.prisma.asset.findUnique({
where: { id },
include: { ...assetInclude, components: { include: { type: true } } },
});
if (!row) throw new NotFoundException('Appareil inconnu');
return {
...this.toDto(row),
components: row.components.map((c) => this.toComponentDto(c)),
};
}
async create(dto: AssetCreate): Promise<AssetDetail> {
await this.assertEquipmentCategory(dto.categoryId);
await this.assertLocation(dto.locationId);
if (dto.components?.length) {
await this.assertComponentTypes(dto.components.map((c) => c.typeId));
}
try {
const created = await this.prisma.asset.create({
data: {
reference: dto.reference,
brand: dto.brand,
model: dto.model,
serialNumber: dto.serialNumber,
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
loadKg: dto.loadKg,
floors: dto.floors,
categoryId: dto.categoryId,
locationId: dto.locationId,
components: dto.components?.length
? { create: dto.components }
: undefined,
},
select: { id: true },
});
return this.get(created.id);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Cette référence est déjà utilisée');
}
throw e;
}
}
async update(id: string, dto: AssetUpdate): Promise<AssetDetail> {
if (dto.categoryId) await this.assertEquipmentCategory(dto.categoryId);
if (dto.locationId) await this.assertLocation(dto.locationId);
try {
await this.prisma.asset.update({
where: { id },
data: {
...dto,
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
},
select: { id: true },
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
throw new NotFoundException('Appareil inconnu');
}
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Cette référence est déjà utilisée');
}
throw e;
}
return this.get(id);
}
async addComponent(
assetId: string,
dto: AssetComponentCreate,
): Promise<AssetComponentDto> {
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
if (!asset) throw new NotFoundException('Appareil inconnu');
await this.assertComponentTypes([dto.typeId]);
const created = await this.prisma.assetComponent.create({
data: { assetId, ...dto },
include: { type: true },
});
return this.toComponentDto(created);
}
async removeComponent(assetId: string, componentId: string): Promise<void> {
const { count } = await this.prisma.assetComponent.deleteMany({
where: { id: componentId, assetId },
});
if (count === 0) throw new NotFoundException('Organe inconnu');
}
private async assertEquipmentCategory(categoryId: string): Promise<void> {
const category = await this.prisma.category.findUnique({ where: { id: categoryId } });
if (!category || category.kind !== 'EQUIPMENT' || !category.isActive) {
throw new BadRequestException(
'La catégorie choisie nest pas une catégorie déquipement active',
);
}
}
private async assertLocation(locationId: string): Promise<void> {
const location = await this.prisma.location.findUnique({ where: { id: locationId } });
if (!location) throw new BadRequestException('Emplacement inconnu');
}
private async assertComponentTypes(typeIds: string[]): Promise<void> {
const types = await this.prisma.category.findMany({
where: { id: { in: typeIds } },
});
const valid =
types.length === new Set(typeIds).size &&
types.every((t) => t.kind === 'COMPONENT_TYPE' && t.isActive);
if (!valid) {
throw new BadRequestException('Chaque organe doit avoir un type dorgane actif');
}
}
private toComponentDto(c: { id: string; typeId: string; designation: string | null; type: { name: string } }): AssetComponentDto {
return {
id: c.id,
typeId: c.typeId,
typeName: c.type.name,
designation: c.designation,
};
}
private toDto(row: AssetRow): AssetDto {
return {
id: row.id,
reference: row.reference,
brand: row.brand,
model: row.model,
serialNumber: row.serialNumber,
commissionedAt: row.commissionedAt?.toISOString() ?? null,
loadKg: row.loadKg,
floors: row.floors,
status: row.status,
categoryId: row.categoryId,
categoryName: row.category.name,
locationId: row.locationId,
locationName: row.location.name,
siteName: row.location.parent?.name ?? row.location.name,
componentCount: row._count.components,
};
}
}