mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 20:51:53 +00:00
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:
44
apps/api/src/categories/categories.controller.ts
Normal file
44
apps/api/src/categories/categories.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/categories/categories.module.ts
Normal file
10
apps/api/src/categories/categories.module.ts
Normal 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 {}
|
||||
75
apps/api/src/categories/categories.service.ts
Normal file
75
apps/api/src/categories/categories.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user