mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +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:
73
apps/api/src/assets/assets.controller.ts
Normal file
73
apps/api/src/assets/assets.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/assets/assets.module.ts
Normal file
10
apps/api/src/assets/assets.module.ts
Normal 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 {}
|
||||
183
apps/api/src/assets/assets.service.ts
Normal file
183
apps/api/src/assets/assets.service.ts
Normal 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 n’est 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 d’organe 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user