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 {
CategoryCreateSchema,
CategoryUpdateSchema,
type CategoryCreate,
type CategoryUpdate,
} from '@siop/shared';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { CategoriesService } from './categories.service';
@Controller('categories')
export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {}
/** Donnée de référence lue par les formulaires — authentification seule. */
@Get()
list() {
return this.categoriesService.list();
}
@Post()
@RequirePermission('SETTINGS', 'create')
create(@Body(new ZodValidationPipe(CategoryCreateSchema)) body: CategoryCreate) {
return this.categoriesService.create(body);
}
@Patch(':id')
@RequirePermission('SETTINGS', 'edit')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(CategoryUpdateSchema)) body: CategoryUpdate,
) {
return this.categoriesService.update(id, body);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
@Module({
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],
})
export class CategoriesModule {}

View File

@@ -0,0 +1,75 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CategoriesResponse,
Category,
CategoryCreate,
CategoryUpdate,
} from '@siop/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class CategoriesService {
constructor(private readonly prisma: PrismaService) {}
async list(): Promise<CategoriesResponse> {
const rows = await this.prisma.category.findMany({
include: { _count: { select: { assets: true, components: true } } },
orderBy: [{ kind: 'asc' }, { name: 'asc' }],
});
return {
categories: rows.map((c) => ({
id: c.id,
kind: c.kind,
name: c.name,
isActive: c.isActive,
usageCount: c.kind === 'EQUIPMENT' ? c._count.assets : c._count.components,
})),
};
}
async create(dto: CategoryCreate): Promise<Category> {
try {
const created = await this.prisma.category.create({ data: dto });
return { ...created, usageCount: 0 };
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Ce nom existe déjà pour ce référentiel');
}
throw e;
}
}
/** Renommage / (dés)activation — la suppression n'existe pas (invariant R1). */
async update(id: string, dto: CategoryUpdate): Promise<Category> {
try {
const updated = await this.prisma.category.update({
where: { id },
data: dto,
include: { _count: { select: { assets: true, components: true } } },
});
return {
id: updated.id,
kind: updated.kind,
name: updated.name,
isActive: updated.isActive,
usageCount:
updated.kind === 'EQUIPMENT'
? updated._count.assets
: updated._count.components,
};
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
throw new NotFoundException('Catégorie inconnue');
}
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Ce nom existe déjà pour ce référentiel');
}
throw e;
}
}
}