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:
44
apps/api/src/locations/locations.controller.ts
Normal file
44
apps/api/src/locations/locations.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/locations/locations.module.ts
Normal file
10
apps/api/src/locations/locations.module.ts
Normal 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 {}
|
||||
113
apps/api/src/locations/locations.service.ts
Normal file
113
apps/api/src/locations/locations.service.ts
Normal 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 d’emplacement',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user