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:
@@ -1,11 +1,15 @@
|
||||
import { DynamicModule, Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { AssetsModule } from './assets/assets.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { DemoAuthModule } from './auth/demo/demo-auth.module';
|
||||
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||
import { CategoriesModule } from './categories/categories.module';
|
||||
import { demoModeEnabled } from './config/env';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { LocationsModule } from './locations/locations.module';
|
||||
import { TeamsModule } from './teams/teams.module';
|
||||
import { PermissionsGuard } from './permissions/permissions.guard';
|
||||
import { PermissionsModule } from './permissions/permissions.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
@@ -28,6 +32,11 @@ export class AppModule {
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
HealthModule,
|
||||
// R1 — référentiel
|
||||
CategoriesModule,
|
||||
LocationsModule,
|
||||
AssetsModule,
|
||||
TeamsModule,
|
||||
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||
],
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
|
||||
import { LoginRequestSchema, type LoginRequest } from '@siop/shared';
|
||||
import {
|
||||
ActivateRequestSchema,
|
||||
LoginRequestSchema,
|
||||
type ActivateRequest,
|
||||
type LoginRequest,
|
||||
} from '@siop/shared';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { AuthService } from './auth.service';
|
||||
import { Public } from './public.decorator';
|
||||
@@ -14,4 +19,12 @@ export class AuthController {
|
||||
login(@Body(new ZodValidationPipe(LoginRequestSchema)) body: LoginRequest) {
|
||||
return this.authService.login(body.email, body.password);
|
||||
}
|
||||
|
||||
/** Publique par nature : la personne n'a pas encore de compte actif. */
|
||||
@Public()
|
||||
@Post('activate')
|
||||
@HttpCode(200)
|
||||
activate(@Body(new ZodValidationPipe(ActivateRequestSchema)) body: ActivateRequest) {
|
||||
return this.authService.activate(body.token, body.password);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -75,6 +76,35 @@ export class AuthService {
|
||||
return this.issueToken(user);
|
||||
}
|
||||
|
||||
/** R1 — activation d'un compte invité : lien 7 jours, usage unique,
|
||||
* choisit le mot de passe et connecte directement. */
|
||||
async activate(token: string, password: string): Promise<AuthResponse> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { activationToken: token },
|
||||
include: { role: true },
|
||||
});
|
||||
if (
|
||||
!user ||
|
||||
!user.isActive ||
|
||||
!user.activationExpiresAt ||
|
||||
user.activationExpiresAt < new Date()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Ce lien d’activation est invalide ou a expiré — demandez un nouveau lien',
|
||||
);
|
||||
}
|
||||
const activated = await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
passwordHash: await argon2.hash(password),
|
||||
activationToken: null,
|
||||
activationExpiresAt: null,
|
||||
},
|
||||
include: { role: true },
|
||||
});
|
||||
return this.issueToken(activated);
|
||||
}
|
||||
|
||||
private async issueToken(user: UserWithRole): Promise<AuthResponse> {
|
||||
// Identité seule — les droits restent en base (invariant R0)
|
||||
const accessToken = await this.jwtService.signAsync({
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
44
apps/api/src/teams/teams.controller.ts
Normal file
44
apps/api/src/teams/teams.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
TeamCreateSchema,
|
||||
TeamUpdateSchema,
|
||||
type TeamCreate,
|
||||
type TeamUpdate,
|
||||
} from '@siop/shared';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { TeamsService } from './teams.service';
|
||||
|
||||
@Controller('teams')
|
||||
export class TeamsController {
|
||||
constructor(private readonly teamsService: TeamsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('PEOPLE_TEAMS', 'view')
|
||||
list() {
|
||||
return this.teamsService.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('PEOPLE_TEAMS', 'create')
|
||||
create(@Body(new ZodValidationPipe(TeamCreateSchema)) body: TeamCreate) {
|
||||
return this.teamsService.create(body);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePermission('PEOPLE_TEAMS', 'edit')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(TeamUpdateSchema)) body: TeamUpdate,
|
||||
) {
|
||||
return this.teamsService.update(id, body);
|
||||
}
|
||||
}
|
||||
10
apps/api/src/teams/teams.module.ts
Normal file
10
apps/api/src/teams/teams.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TeamsController } from './teams.controller';
|
||||
import { TeamsService } from './teams.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TeamsController],
|
||||
providers: [TeamsService],
|
||||
exports: [TeamsService],
|
||||
})
|
||||
export class TeamsModule {}
|
||||
92
apps/api/src/teams/teams.service.ts
Normal file
92
apps/api/src/teams/teams.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { Team, TeamCreate, TeamsResponse, TeamUpdate } from '@siop/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const teamInclude = {
|
||||
members: { include: { role: true }, orderBy: { displayName: 'asc' } },
|
||||
} satisfies Prisma.TeamInclude;
|
||||
|
||||
type TeamRow = Prisma.TeamGetPayload<{ include: typeof teamInclude }>;
|
||||
|
||||
@Injectable()
|
||||
export class TeamsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(): Promise<TeamsResponse> {
|
||||
const rows = await this.prisma.team.findMany({
|
||||
include: teamInclude,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return { teams: rows.map((t) => this.toDto(t)) };
|
||||
}
|
||||
|
||||
async create(dto: TeamCreate): Promise<Team> {
|
||||
try {
|
||||
const created = await this.prisma.team.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
members: dto.memberIds?.length
|
||||
? { connect: dto.memberIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
},
|
||||
include: teamInclude,
|
||||
});
|
||||
return this.toDto(created);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Ce nom d’équipe existe déjà');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: TeamUpdate): Promise<Team> {
|
||||
try {
|
||||
const updated = await this.prisma.team.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
members: dto.memberIds
|
||||
? { set: dto.memberIds.map((memberId) => ({ id: memberId })) }
|
||||
: undefined,
|
||||
},
|
||||
include: teamInclude,
|
||||
});
|
||||
return this.toDto(updated);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||
throw new NotFoundException('Équipe inconnue');
|
||||
}
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Ce nom d’équipe existe déjà');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: TeamRow): Team {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
members: row.members.map((m) => ({
|
||||
id: m.id,
|
||||
displayName: m.displayName,
|
||||
roleName: m.role.name,
|
||||
initials: m.displayName
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((w) => w[0]!.toUpperCase())
|
||||
.join(''),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,41 @@
|
||||
import { Controller, Get, NotFoundException } from '@nestjs/common';
|
||||
import type { MeResponse, RoleName } from '@siop/shared';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
InvitationCreateSchema,
|
||||
UserUpdateSchema,
|
||||
type InvitationCreate,
|
||||
type MeResponse,
|
||||
type RoleName,
|
||||
type UserUpdate,
|
||||
} from '@siop/shared';
|
||||
import {
|
||||
AuthenticatedUser,
|
||||
CurrentUser,
|
||||
} from '../auth/current-user.decorator';
|
||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Controller('users')
|
||||
@Controller()
|
||||
export class UsersController {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
private readonly usersService: UsersService,
|
||||
) {}
|
||||
|
||||
/** R0 : lecture du profil courant (gestion complète des utilisateurs en R1). */
|
||||
@Get('me')
|
||||
/** Profil courant — pas de permission : chacun lit le sien. */
|
||||
@Get('users/me')
|
||||
async me(@CurrentUser() current: AuthenticatedUser): Promise<MeResponse> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: current.userId },
|
||||
@@ -31,4 +51,37 @@ export class UsersController {
|
||||
permissions: await this.permissionsService.getForRole(user.roleId),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('users')
|
||||
@RequirePermission('PEOPLE_TEAMS', 'view')
|
||||
list() {
|
||||
return this.usersService.list();
|
||||
}
|
||||
|
||||
@Get('roles')
|
||||
@RequirePermission('PEOPLE_TEAMS', 'view')
|
||||
roles() {
|
||||
return this.usersService.roles();
|
||||
}
|
||||
|
||||
@Post('users/invitations')
|
||||
@RequirePermission('PEOPLE_TEAMS', 'create')
|
||||
invite(@Body(new ZodValidationPipe(InvitationCreateSchema)) body: InvitationCreate) {
|
||||
return this.usersService.invite(body);
|
||||
}
|
||||
|
||||
@Post('users/:id/invitation')
|
||||
@RequirePermission('PEOPLE_TEAMS', 'edit')
|
||||
resendInvitation(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.usersService.resendInvitation(id);
|
||||
}
|
||||
|
||||
@Patch('users/:id')
|
||||
@RequirePermission('PEOPLE_TEAMS', 'edit')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body(new ZodValidationPipe(UserUpdateSchema)) body: UserUpdate,
|
||||
) {
|
||||
return this.usersService.update(id, body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
141
apps/api/src/users/users.service.ts
Normal file
141
apps/api/src/users/users.service.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
INVITATION_TTL_DAYS,
|
||||
type InvitationCreate,
|
||||
type InvitationResponse,
|
||||
type RoleName,
|
||||
type RolesResponse,
|
||||
type UserAdmin,
|
||||
type UsersResponse,
|
||||
type UserUpdate,
|
||||
} from '@siop/shared';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const userInclude = {
|
||||
role: true,
|
||||
teams: { orderBy: { name: 'asc' } },
|
||||
} satisfies Prisma.UserInclude;
|
||||
|
||||
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(): Promise<UsersResponse> {
|
||||
const rows = await this.prisma.user.findMany({
|
||||
include: userInclude,
|
||||
orderBy: { displayName: 'asc' },
|
||||
});
|
||||
return { users: rows.map((u) => this.toDto(u)) };
|
||||
}
|
||||
|
||||
async roles(): Promise<RolesResponse> {
|
||||
const roles = await this.prisma.role.findMany({ orderBy: { name: 'asc' } });
|
||||
return { roles: roles.map((r) => ({ id: r.id, name: r.name as RoleName })) };
|
||||
}
|
||||
|
||||
/** Invitation : compte créé SANS mot de passe + lien d'activation 7 jours.
|
||||
* L'envoi d'email viendra plus tard — le web affiche le lien à copier. */
|
||||
async invite(dto: InvitationCreate): Promise<InvitationResponse> {
|
||||
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
|
||||
if (!role) throw new NotFoundException('Rôle inconnu');
|
||||
try {
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
displayName: dto.displayName,
|
||||
phone: dto.phone,
|
||||
roleId: dto.roleId,
|
||||
teams: dto.teamIds?.length
|
||||
? { connect: dto.teamIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
...this.freshToken(),
|
||||
},
|
||||
});
|
||||
return {
|
||||
userId: user.id,
|
||||
activationToken: user.activationToken!,
|
||||
expiresAt: user.activationExpiresAt!.toISOString(),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
throw new ConflictException('Cet email a déjà un compte');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async resendInvitation(userId: string): Promise<InvitationResponse> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException('Personne inconnue');
|
||||
if (user.passwordHash) {
|
||||
throw new ConflictException('Ce compte est déjà activé');
|
||||
}
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: this.freshToken(),
|
||||
});
|
||||
return {
|
||||
userId: updated.id,
|
||||
activationToken: updated.activationToken!,
|
||||
expiresAt: updated.activationExpiresAt!.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
|
||||
try {
|
||||
const updated = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
displayName: dto.displayName,
|
||||
phone: dto.phone,
|
||||
roleId: dto.roleId,
|
||||
isActive: dto.isActive,
|
||||
teams: dto.teamIds
|
||||
? { set: dto.teamIds.map((id) => ({ id })) }
|
||||
: undefined,
|
||||
},
|
||||
include: userInclude,
|
||||
});
|
||||
return this.toDto(updated);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||
throw new NotFoundException('Personne inconnue');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private freshToken() {
|
||||
return {
|
||||
activationToken: randomBytes(32).toString('base64url'),
|
||||
activationExpiresAt: new Date(
|
||||
Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private toDto(row: UserRow): UserAdmin {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
displayName: row.displayName,
|
||||
phone: row.phone,
|
||||
role: { id: row.role.id, name: row.role.name as RoleName },
|
||||
teams: row.teams.map((t) => ({ id: t.id, name: t.name })),
|
||||
status: !row.isActive
|
||||
? 'disabled'
|
||||
: row.passwordHash
|
||||
? 'active'
|
||||
: 'invited',
|
||||
isDemo: row.isDemo,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user