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:
@@ -0,0 +1,145 @@
|
||||
-- Migration autosuffisante : les extensions requises sont créées si absentes
|
||||
-- (la CI et tout environnement neuf n’ont pas notre init.sql).
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CategoryKind" AS ENUM ('EQUIPMENT', 'COMPONENT_TYPE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AssetStatus" AS ENUM ('IN_SERVICE', 'OUT_OF_SERVICE', 'UNDER_MAINTENANCE');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "activationExpiresAt" TIMESTAMP(3),
|
||||
ADD COLUMN "activationToken" TEXT,
|
||||
ADD COLUMN "phone" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Category" (
|
||||
"id" UUID NOT NULL,
|
||||
"kind" "CategoryKind" NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Location" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"parentId" UUID,
|
||||
"address" TEXT,
|
||||
"city" TEXT,
|
||||
"guardianName" TEXT,
|
||||
"guardianPhone" TEXT,
|
||||
"latitude" DOUBLE PRECISION,
|
||||
"longitude" DOUBLE PRECISION,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Asset" (
|
||||
"id" UUID NOT NULL,
|
||||
"reference" TEXT NOT NULL,
|
||||
"brand" TEXT NOT NULL,
|
||||
"model" TEXT,
|
||||
"serialNumber" TEXT,
|
||||
"commissionedAt" TIMESTAMP(3),
|
||||
"loadKg" INTEGER,
|
||||
"floors" INTEGER,
|
||||
"status" "AssetStatus" NOT NULL DEFAULT 'IN_SERVICE',
|
||||
"categoryId" UUID NOT NULL,
|
||||
"locationId" UUID NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Asset_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AssetComponent" (
|
||||
"id" UUID NOT NULL,
|
||||
"assetId" UUID NOT NULL,
|
||||
"typeId" UUID NOT NULL,
|
||||
"designation" TEXT,
|
||||
|
||||
CONSTRAINT "AssetComponent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Team" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
|
||||
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "_TeamToUser" (
|
||||
"A" UUID NOT NULL,
|
||||
"B" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "_TeamToUser_AB_pkey" PRIMARY KEY ("A","B")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Category_kind_name_key" ON "Category"("kind", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Location_parentId_idx" ON "Location"("parentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Asset_reference_key" ON "Asset"("reference");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Asset_locationId_idx" ON "Asset"("locationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Asset_categoryId_idx" ON "Asset"("categoryId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AssetComponent_assetId_idx" ON "AssetComponent"("assetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Team_name_key" ON "Team"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "_TeamToUser_B_index" ON "_TeamToUser"("B");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_activationToken_key" ON "User"("activationToken");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Location" ADD CONSTRAINT "Location_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AssetComponent" ADD CONSTRAINT "AssetComponent_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AssetComponent" ADD CONSTRAINT "AssetComponent_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_TeamToUser" ADD CONSTRAINT "_TeamToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "_TeamToUser" ADD CONSTRAINT "_TeamToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
|
||||
-- ————— PostGIS (hors périmètre Prisma, décision modèle R1) —————
|
||||
-- Position générée depuis latitude/longitude : rien à synchroniser côté app,
|
||||
-- la colonne est prête pour les requêtes spatiales (affectation auto, backlog).
|
||||
ALTER TABLE "Location"
|
||||
ADD COLUMN "position" geography(Point, 4326)
|
||||
GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint("longitude", "latitude"), 4326)::geography) STORED;
|
||||
|
||||
CREATE INDEX "Location_position_gix" ON "Location" USING GIST ("position");
|
||||
@@ -39,12 +39,105 @@ model User {
|
||||
email String @unique
|
||||
displayName String
|
||||
passwordHash String? // null tant que le compte n'est pas activé (R1)
|
||||
phone String?
|
||||
roleId String @db.Uuid
|
||||
role Role @relation(fields: [roleId], references: [id])
|
||||
isActive Boolean @default(true)
|
||||
isDemo Boolean @default(false) // seul un compte isDemo est empruntable (ADR-002)
|
||||
// Invitation (R1) : lien d'activation 7 jours, usage unique.
|
||||
// Statut dérivé : invité = passwordHash null && token présent.
|
||||
activationToken String? @unique
|
||||
activationExpiresAt DateTime?
|
||||
teams Team[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([roleId])
|
||||
}
|
||||
|
||||
// ————— R1 — Référentiel (docs/03-architecture/modele-donnees.md §R1) —————
|
||||
|
||||
enum CategoryKind {
|
||||
EQUIPMENT
|
||||
COMPONENT_TYPE
|
||||
}
|
||||
|
||||
enum AssetStatus {
|
||||
IN_SERVICE
|
||||
OUT_OF_SERVICE
|
||||
UNDER_MAINTENANCE
|
||||
}
|
||||
|
||||
model Category {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
kind CategoryKind
|
||||
name String
|
||||
isActive Boolean @default(true) // désactivable, jamais supprimée si utilisée
|
||||
assets Asset[]
|
||||
components AssetComponent[]
|
||||
|
||||
@@unique([kind, name])
|
||||
}
|
||||
|
||||
model Location {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
parentId String? @db.Uuid // site (null) → zone ; profondeur max 2 (service)
|
||||
parent Location? @relation("LocationTree", fields: [parentId], references: [id])
|
||||
children Location[] @relation("LocationTree")
|
||||
address String?
|
||||
city String?
|
||||
guardianName String?
|
||||
guardianPhone String?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
// + colonne PostGIS générée (voir migration r1_referentiel) :
|
||||
// position geography(Point,4326) GENERATED ALWAYS AS (…) STORED
|
||||
assets Asset[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([parentId])
|
||||
}
|
||||
|
||||
model Asset {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
reference String @unique // « A1 » — imprimée sur l'étiquette QR
|
||||
brand String
|
||||
model String?
|
||||
serialNumber String?
|
||||
commissionedAt DateTime?
|
||||
loadKg Int?
|
||||
floors Int?
|
||||
status AssetStatus @default(IN_SERVICE) // statut d'ÉQUIPEMENT ≠ statut d'OT
|
||||
categoryId String @db.Uuid
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
locationId String @db.Uuid
|
||||
location Location @relation(fields: [locationId], references: [id])
|
||||
components AssetComponent[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([locationId])
|
||||
@@index([categoryId])
|
||||
}
|
||||
|
||||
// Organe : PAS de colonne emplacement — « un organe n'a pas d'emplacement
|
||||
// propre » est garanti par construction (décision maquettes R1).
|
||||
model AssetComponent {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
assetId String @db.Uuid
|
||||
asset Asset @relation(fields: [assetId], references: [id], onDelete: Cascade)
|
||||
typeId String @db.Uuid
|
||||
type Category @relation(fields: [typeId], references: [id])
|
||||
designation String?
|
||||
|
||||
@@index([assetId])
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @unique
|
||||
description String?
|
||||
members User[]
|
||||
}
|
||||
|
||||
@@ -131,6 +131,15 @@ export async function seed(prisma: PrismaClient): Promise<void> {
|
||||
const passwordHash = await argon2.hash(
|
||||
process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026',
|
||||
);
|
||||
await seedUsers(prisma, roleIds, passwordHash);
|
||||
await seedReferentiel(prisma);
|
||||
}
|
||||
|
||||
async function seedUsers(
|
||||
prisma: PrismaClient,
|
||||
roleIds: Map<RoleName, string>,
|
||||
passwordHash: string,
|
||||
): Promise<void> {
|
||||
for (const u of DEMO_USERS) {
|
||||
// Les comptes démo appartiennent au seed : nom et rôle sont réalignés
|
||||
// à chaque exécution (jamais le mot de passe d'un compte existant).
|
||||
@@ -148,6 +157,207 @@ export async function seed(prisma: PrismaClient): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ————— R1 — Référentiel (données des maquettes validées) —————
|
||||
|
||||
const EQUIPMENT_CATEGORIES = [
|
||||
'Ascenseur électrique',
|
||||
'Ascenseur hydraulique',
|
||||
'Monte-charge',
|
||||
'EPMR (plateforme PMR)',
|
||||
];
|
||||
const COMPONENT_TYPES = [
|
||||
'Portes cabine / palières',
|
||||
'Treuil / machinerie',
|
||||
'Parachute',
|
||||
'Armoire de commande',
|
||||
'Boutons & signalisation',
|
||||
];
|
||||
|
||||
/** site → zones ; positions réelles Casablanca/Mohammedia. */
|
||||
const SITES: {
|
||||
name: string;
|
||||
address: string;
|
||||
city: string;
|
||||
guardianName?: string;
|
||||
guardianPhone?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zones: string[];
|
||||
}[] = [
|
||||
{
|
||||
name: 'Tour Atlas', address: 'Bd de la Corniche, Aïn Diab', city: 'Casablanca',
|
||||
guardianName: 'Karim Doukkali', guardianPhone: '06 61 23 45 67',
|
||||
latitude: 33.6062, longitude: -7.6706,
|
||||
zones: ['Hall principal', 'Tour bureaux (étages 1-24)', 'Parking sous-sol', 'Résidence (aile est)'],
|
||||
},
|
||||
{
|
||||
name: 'Résidence Al Manar', address: 'Bd Hassan II', city: 'Mohammedia',
|
||||
guardianName: 'Hassan Alami',
|
||||
latitude: 33.6866, longitude: -7.383,
|
||||
zones: ['Hall principal'],
|
||||
},
|
||||
{
|
||||
name: 'Anfa Place', address: 'Bd de l’Océan Atlantique', city: 'Casablanca',
|
||||
latitude: 33.5883, longitude: -7.6822,
|
||||
zones: ['Galerie commerciale'],
|
||||
},
|
||||
{
|
||||
name: 'Clinique Yasmine', address: 'Rue Ibn Rochd', city: 'Casablanca',
|
||||
guardianName: 'Rachid Mansouri',
|
||||
latitude: 33.5731, longitude: -7.6316,
|
||||
zones: ['Bloc A'],
|
||||
},
|
||||
{
|
||||
name: 'Marina Center', address: 'Av. des FAR', city: 'Mohammedia',
|
||||
latitude: 33.7, longitude: -7.39,
|
||||
zones: ['Hall B'],
|
||||
},
|
||||
];
|
||||
|
||||
const ASSETS: {
|
||||
reference: string; brand: string; model: string; serialNumber?: string;
|
||||
commissionedAt?: string; loadKg?: number; floors?: number;
|
||||
category: string; site: string; zone: string;
|
||||
status?: 'IN_SERVICE' | 'OUT_OF_SERVICE' | 'UNDER_MAINTENANCE';
|
||||
components?: { type: string; designation?: string }[];
|
||||
}[] = [
|
||||
{
|
||||
reference: 'A1', brand: 'Otis', model: 'Gen2 Premier', serialNumber: 'OT-2020-4521',
|
||||
commissionedAt: '2020-03-15', loadKg: 630, floors: 8,
|
||||
category: 'Ascenseur électrique', site: 'Résidence Al Manar', zone: 'Hall principal',
|
||||
components: [
|
||||
{ type: 'Portes cabine / palières', designation: 'Fermator 40/10' },
|
||||
{ type: 'Treuil / machinerie', designation: 'Gen2 gearless' },
|
||||
{ type: 'Parachute' },
|
||||
{ type: 'Armoire de commande', designation: 'MCS 220' },
|
||||
],
|
||||
},
|
||||
{
|
||||
reference: 'A2', brand: 'Otis', model: 'Gen2', loadKg: 630, floors: 12,
|
||||
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Hall principal',
|
||||
},
|
||||
{
|
||||
reference: 'B1', brand: 'Schindler', model: '3300', loadKg: 1000, floors: 24,
|
||||
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Tour bureaux (étages 1-24)',
|
||||
},
|
||||
{
|
||||
reference: 'B2', brand: 'Schindler', model: '3300', loadKg: 1000, floors: 24,
|
||||
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Tour bureaux (étages 1-24)',
|
||||
status: 'OUT_OF_SERVICE',
|
||||
components: [
|
||||
{ type: 'Portes cabine / palières', designation: 'Sematic' },
|
||||
{ type: 'Armoire de commande' },
|
||||
],
|
||||
},
|
||||
{
|
||||
reference: 'M1', brand: 'Kone', model: 'TranSys', loadKg: 2000, floors: 3,
|
||||
category: 'Monte-charge', site: 'Tour Atlas', zone: 'Parking sous-sol',
|
||||
status: 'UNDER_MAINTENANCE',
|
||||
},
|
||||
{
|
||||
reference: 'C1', brand: 'Kone', model: 'MonoSpace 500', loadKg: 800, floors: 5,
|
||||
category: 'Ascenseur électrique', site: 'Anfa Place', zone: 'Galerie commerciale',
|
||||
},
|
||||
{
|
||||
reference: 'D1', brand: 'ThyssenKrupp', model: 'Evolution', loadKg: 1600, floors: 6,
|
||||
category: 'Ascenseur électrique', site: 'Clinique Yasmine', zone: 'Bloc A',
|
||||
},
|
||||
{
|
||||
reference: 'E2', brand: 'Otis', model: 'HydroFit', loadKg: 630, floors: 4,
|
||||
category: 'Ascenseur hydraulique', site: 'Marina Center', zone: 'Hall B',
|
||||
},
|
||||
];
|
||||
|
||||
const TEAMS: { name: string; description: string; memberEmails: string[] }[] = [
|
||||
{
|
||||
name: 'Casablanca Centre',
|
||||
description: 'Tour Atlas · Anfa Place · Clinique Yasmine',
|
||||
memberEmails: ['technicien@demo.siop.ma', 'technicien-limite@demo.siop.ma'],
|
||||
},
|
||||
{
|
||||
name: 'Mohammedia',
|
||||
description: 'Résidence Al Manar · Marina Center',
|
||||
memberEmails: [],
|
||||
},
|
||||
];
|
||||
|
||||
async function seedReferentiel(prisma: PrismaClient): Promise<void> {
|
||||
const categoryIds = new Map<string, string>();
|
||||
for (const [kind, names] of [
|
||||
['EQUIPMENT', EQUIPMENT_CATEGORIES],
|
||||
['COMPONENT_TYPE', COMPONENT_TYPES],
|
||||
] as const) {
|
||||
for (const name of names) {
|
||||
const category = await prisma.category.upsert({
|
||||
where: { kind_name: { kind, name } },
|
||||
update: {},
|
||||
create: { kind, name },
|
||||
});
|
||||
categoryIds.set(name, category.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Pas d'unicité en base sur (parent, nom) : idempotence par findFirst.
|
||||
const zoneIds = new Map<string, string>(); // « site / zone » → id
|
||||
for (const site of SITES) {
|
||||
const { zones, ...data } = site;
|
||||
let root = await prisma.location.findFirst({
|
||||
where: { name: site.name, parentId: null },
|
||||
});
|
||||
root ??= await prisma.location.create({ data });
|
||||
for (const zoneName of zones) {
|
||||
let zone = await prisma.location.findFirst({
|
||||
where: { name: zoneName, parentId: root.id },
|
||||
});
|
||||
zone ??= await prisma.location.create({
|
||||
data: { name: zoneName, parentId: root.id, city: site.city },
|
||||
});
|
||||
zoneIds.set(`${site.name} / ${zoneName}`, zone.id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const asset of ASSETS) {
|
||||
const created = await prisma.asset.upsert({
|
||||
where: { reference: asset.reference },
|
||||
update: {},
|
||||
create: {
|
||||
reference: asset.reference,
|
||||
brand: asset.brand,
|
||||
model: asset.model,
|
||||
serialNumber: asset.serialNumber,
|
||||
commissionedAt: asset.commissionedAt ? new Date(asset.commissionedAt) : undefined,
|
||||
loadKg: asset.loadKg,
|
||||
floors: asset.floors,
|
||||
status: asset.status ?? 'IN_SERVICE',
|
||||
categoryId: categoryIds.get(asset.category)!,
|
||||
locationId: zoneIds.get(`${asset.site} / ${asset.zone}`)!,
|
||||
},
|
||||
include: { _count: { select: { components: true } } },
|
||||
});
|
||||
if (asset.components?.length && created._count.components === 0) {
|
||||
await prisma.assetComponent.createMany({
|
||||
data: asset.components.map((c) => ({
|
||||
assetId: created.id,
|
||||
typeId: categoryIds.get(c.type)!,
|
||||
designation: c.designation,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const team of TEAMS) {
|
||||
await prisma.team.upsert({
|
||||
where: { name: team.name },
|
||||
update: {},
|
||||
create: {
|
||||
name: team.name,
|
||||
description: team.description,
|
||||
members: { connect: team.memberEmails.map((email) => ({ email })) },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* c8 ignore start — wrapper CLI */
|
||||
if (require.main === module) {
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
221
apps/api/test/administration.e2e-spec.ts
Normal file
221
apps/api/test/administration.e2e-spec.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* E2E R1 — administration du référentiel : équipes, renommages, mises à jour
|
||||
* et TOUS les refus (conflits 409, inconnus 404, invalides 400) — le contrat
|
||||
* d'erreur fait partie du contrat.
|
||||
*/
|
||||
process.env.DEMO_MODE = 'true';
|
||||
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import request from 'supertest';
|
||||
import { seed } from '../prisma/seed';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
const GHOST = '00000000-0000-4000-8000-000000000000';
|
||||
|
||||
describe('Administration du référentiel (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let admin: string;
|
||||
const prisma = new PrismaClient();
|
||||
const http = () => request(app.getHttpServer());
|
||||
const suffix = `adm-${Date.now().toString(36)}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
await seed(prisma);
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule.forRoot()],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
const { body } = await http().get('/auth/demo-accounts');
|
||||
const compte = body.accounts.find(
|
||||
(a: { roleName: string }) => a.roleName === 'Administrateur',
|
||||
);
|
||||
admin = (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||
.accessToken;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.team.deleteMany({ where: { name: { contains: suffix } } });
|
||||
await prisma.category.deleteMany({ where: { name: { contains: suffix } } });
|
||||
await prisma.location.deleteMany({ where: { name: { contains: suffix } } });
|
||||
await app?.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
const auth = () => ({ Authorization: `Bearer ${admin}` });
|
||||
|
||||
it('équipes : création, doublon 409, membres, 404', async () => {
|
||||
const { body: users } = await http().get('/users').set(auth());
|
||||
const ahmed = users.users.find(
|
||||
(u: { email: string }) => u.email === 'technicien@demo.siop.ma',
|
||||
);
|
||||
|
||||
const team = await http()
|
||||
.post('/teams')
|
||||
.set(auth())
|
||||
.send({ name: `Équipe ${suffix}`, description: 'Zone de test' })
|
||||
.expect(201);
|
||||
expect(team.body.members).toHaveLength(0);
|
||||
|
||||
await http()
|
||||
.post('/teams')
|
||||
.set(auth())
|
||||
.send({ name: `Équipe ${suffix}` })
|
||||
.expect(409);
|
||||
|
||||
const updated = await http()
|
||||
.patch(`/teams/${team.body.id}`)
|
||||
.set(auth())
|
||||
.send({ memberIds: [ahmed.id], description: 'Zone mise à jour' })
|
||||
.expect(200);
|
||||
expect(updated.body.members.map((m: { id: string }) => m.id)).toEqual([ahmed.id]);
|
||||
|
||||
const { body: teams } = await http().get('/teams').set(auth()).expect(200);
|
||||
expect(teams.teams.some((t: { id: string }) => t.id === team.body.id)).toBe(true);
|
||||
|
||||
await http().patch(`/teams/${GHOST}`).set(auth()).send({ name: 'X' }).expect(404);
|
||||
await http()
|
||||
.patch(`/teams/${team.body.id}`)
|
||||
.set(auth())
|
||||
.send({ name: 'Casablanca Centre' })
|
||||
.expect(409);
|
||||
});
|
||||
|
||||
it('catégories : création, doublon 409, renommage, 404', async () => {
|
||||
const created = await http()
|
||||
.post('/categories')
|
||||
.set(auth())
|
||||
.send({ kind: 'EQUIPMENT', name: `Catégorie ${suffix}` })
|
||||
.expect(201);
|
||||
await http()
|
||||
.post('/categories')
|
||||
.set(auth())
|
||||
.send({ kind: 'EQUIPMENT', name: `Catégorie ${suffix}` })
|
||||
.expect(409);
|
||||
const renamed = await http()
|
||||
.patch(`/categories/${created.body.id}`)
|
||||
.set(auth())
|
||||
.send({ name: `Catégorie ${suffix} v2` })
|
||||
.expect(200);
|
||||
expect(renamed.body.name).toBe(`Catégorie ${suffix} v2`);
|
||||
await http()
|
||||
.patch(`/categories/${GHOST}`)
|
||||
.set(auth())
|
||||
.send({ name: 'X' })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('emplacements : renommage, parent inconnu, auto-parent, site avec zones, 404', async () => {
|
||||
const site = await http()
|
||||
.post('/locations')
|
||||
.set(auth())
|
||||
.send({ name: `Site ${suffix}` })
|
||||
.expect(201);
|
||||
const zone = await http()
|
||||
.post('/locations')
|
||||
.set(auth())
|
||||
.send({ name: `Zone ${suffix}`, parentId: site.body.id })
|
||||
.expect(201);
|
||||
|
||||
await http()
|
||||
.patch(`/locations/${zone.body.id}`)
|
||||
.set(auth())
|
||||
.send({ guardianName: 'Gardien Test', guardianPhone: '06 00 00 00 00' })
|
||||
.expect(200);
|
||||
|
||||
await http()
|
||||
.post('/locations')
|
||||
.set(auth())
|
||||
.send({ name: `Orphelin ${suffix}`, parentId: GHOST })
|
||||
.expect(400);
|
||||
await http()
|
||||
.patch(`/locations/${zone.body.id}`)
|
||||
.set(auth())
|
||||
.send({ parentId: zone.body.id })
|
||||
.expect(400);
|
||||
// Le site a une zone : il ne peut pas devenir zone lui-même
|
||||
await http()
|
||||
.patch(`/locations/${site.body.id}`)
|
||||
.set(auth())
|
||||
.send({ parentId: zone.body.id })
|
||||
.expect(400);
|
||||
await http().patch(`/locations/${GHOST}`).set(auth()).send({ name: 'X' }).expect(404);
|
||||
});
|
||||
|
||||
it('appareils : statut, référence en conflit, emplacement inconnu, 404', async () => {
|
||||
const { body } = await http().get('/assets').set(auth());
|
||||
const a1 = body.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||
const b1 = body.assets.find((a: { reference: string }) => a.reference === 'B1');
|
||||
|
||||
const updated = await http()
|
||||
.patch(`/assets/${b1.id}`)
|
||||
.set(auth())
|
||||
.send({ status: 'UNDER_MAINTENANCE' })
|
||||
.expect(200);
|
||||
expect(updated.body.status).toBe('UNDER_MAINTENANCE');
|
||||
await http()
|
||||
.patch(`/assets/${b1.id}`)
|
||||
.set(auth())
|
||||
.send({ status: 'IN_SERVICE' })
|
||||
.expect(200);
|
||||
|
||||
await http()
|
||||
.patch(`/assets/${b1.id}`)
|
||||
.set(auth())
|
||||
.send({ reference: a1.reference })
|
||||
.expect(409);
|
||||
await http()
|
||||
.patch(`/assets/${b1.id}`)
|
||||
.set(auth())
|
||||
.send({ locationId: GHOST })
|
||||
.expect(400);
|
||||
await http().get(`/assets/${GHOST}`).set(auth()).expect(404);
|
||||
await http()
|
||||
.patch(`/assets/${GHOST}`)
|
||||
.set(auth())
|
||||
.send({ brand: 'X' })
|
||||
.expect(404);
|
||||
await http()
|
||||
.post(`/assets/${GHOST}/components`)
|
||||
.set(auth())
|
||||
.send({ typeId: GHOST })
|
||||
.expect(404);
|
||||
await http()
|
||||
.delete(`/assets/${b1.id}/components/${GHOST}`)
|
||||
.set(auth())
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('personnes : mise à jour rôle/équipes, invitation sur rôle inconnu, 404', async () => {
|
||||
const { body: users } = await http().get('/users').set(auth());
|
||||
const youssef = users.users.find(
|
||||
(u: { email: string }) => u.email === 'technicien-limite@demo.siop.ma',
|
||||
);
|
||||
const { body: teams } = await http().get('/teams').set(auth());
|
||||
const mohammedia = teams.teams.find((t: { name: string }) => t.name === 'Mohammedia');
|
||||
|
||||
const updated = await http()
|
||||
.patch(`/users/${youssef.id}`)
|
||||
.set(auth())
|
||||
.send({ phone: '06 12 34 56 78', teamIds: [mohammedia.id] })
|
||||
.expect(200);
|
||||
expect(updated.body.teams.map((t: { name: string }) => t.name)).toContain('Mohammedia');
|
||||
// remise en l'état (seed idempotent : les équipes ne sont pas réécrites)
|
||||
const casa = teams.teams.find((t: { name: string }) => t.name === 'Casablanca Centre');
|
||||
await http()
|
||||
.patch(`/users/${youssef.id}`)
|
||||
.set(auth())
|
||||
.send({ teamIds: [casa.id] })
|
||||
.expect(200);
|
||||
|
||||
await http().patch(`/users/${GHOST}`).set(auth()).send({ phone: '0' }).expect(404);
|
||||
await http()
|
||||
.post('/users/invitations')
|
||||
.set(auth())
|
||||
.send({ email: `x-${suffix}@spelev.ma`, displayName: 'X', roleId: GHOST })
|
||||
.expect(404);
|
||||
await http().post(`/users/${GHOST}/invitation`).set(auth()).expect(404);
|
||||
});
|
||||
});
|
||||
128
apps/api/test/invitation.e2e-spec.ts
Normal file
128
apps/api/test/invitation.e2e-spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* E2E R1 — invitation & activation : lien 7 jours, usage unique,
|
||||
* aucun compte actif avant activation (décision maquettes R1).
|
||||
*/
|
||||
process.env.DEMO_MODE = 'true';
|
||||
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import request from 'supertest';
|
||||
import { seed } from '../prisma/seed';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
describe('Invitation → activation (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let admin: string;
|
||||
const prisma = new PrismaClient();
|
||||
const http = () => request(app.getHttpServer());
|
||||
const email = `invite-${Date.now().toString(36)}@spelev.ma`;
|
||||
|
||||
beforeAll(async () => {
|
||||
await seed(prisma);
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule.forRoot()],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
|
||||
const { body } = await http().get('/auth/demo-accounts');
|
||||
const compte = body.accounts.find(
|
||||
(a: { roleName: string }) => a.roleName === 'Administrateur',
|
||||
);
|
||||
admin = (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||
.accessToken;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.user.deleteMany({ where: { email } });
|
||||
await app?.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('invite, empêche la connexion avant activation, active, connecte', async () => {
|
||||
const { body: roles } = await http()
|
||||
.get('/roles')
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.expect(200);
|
||||
const technicien = roles.roles.find(
|
||||
(r: { name: string }) => r.name === 'Technicien',
|
||||
);
|
||||
|
||||
// 1. Invitation → compte « invited », lien émis
|
||||
const invitation = await http()
|
||||
.post('/users/invitations')
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.send({ email, displayName: 'Recrue Test', roleId: technicien.id })
|
||||
.expect(201);
|
||||
expect(invitation.body.activationToken).toBeTruthy();
|
||||
|
||||
const { body: users } = await http()
|
||||
.get('/users')
|
||||
.set('Authorization', `Bearer ${admin}`);
|
||||
const invited = users.users.find((u: { email: string }) => u.email === email);
|
||||
expect(invited.status).toBe('invited');
|
||||
|
||||
// 2. Pas de connexion possible avant activation
|
||||
await http()
|
||||
.post('/auth/login')
|
||||
.send({ email, password: 'MotDePasse!123' })
|
||||
.expect(401);
|
||||
|
||||
// 3. Renvoi du lien : l'ancien devient invalide (usage unique)
|
||||
const renvoi = await http()
|
||||
.post(`/users/${invitation.body.userId}/invitation`)
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.expect(201);
|
||||
await http()
|
||||
.post('/auth/activate')
|
||||
.send({ token: invitation.body.activationToken, password: 'MotDePasse!123' })
|
||||
.expect(400);
|
||||
|
||||
// 4. Activation → connecté directement, statut « active »
|
||||
const activation = await http()
|
||||
.post('/auth/activate')
|
||||
.send({ token: renvoi.body.activationToken, password: 'MotDePasse!123' })
|
||||
.expect(200);
|
||||
expect(activation.body.user.role.name).toBe('Technicien');
|
||||
await http()
|
||||
.get('/users/me')
|
||||
.set('Authorization', `Bearer ${activation.body.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
// 5. Le lien est consommé ; la connexion classique fonctionne désormais
|
||||
await http()
|
||||
.post('/auth/activate')
|
||||
.send({ token: renvoi.body.activationToken, password: 'Autre!12345' })
|
||||
.expect(400);
|
||||
await http()
|
||||
.post('/auth/login')
|
||||
.send({ email, password: 'MotDePasse!123' })
|
||||
.expect(200);
|
||||
|
||||
// 6. Renvoyer un lien sur un compte activé → refus
|
||||
await http()
|
||||
.post(`/users/${invitation.body.userId}/invitation`)
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.expect(409);
|
||||
});
|
||||
|
||||
it('refuse un email déjà connu (409) et un lien fantaisiste (400)', async () => {
|
||||
const { body: roles } = await http()
|
||||
.get('/roles')
|
||||
.set('Authorization', `Bearer ${admin}`);
|
||||
await http()
|
||||
.post('/users/invitations')
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.send({
|
||||
email: 'dispatcher@demo.siop.ma',
|
||||
displayName: 'Doublon',
|
||||
roleId: roles.roles[0].id,
|
||||
})
|
||||
.expect(409);
|
||||
await http()
|
||||
.post('/auth/activate')
|
||||
.send({ token: 'jeton-inexistant-123', password: 'MotDePasse!123' })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
185
apps/api/test/referentiel.e2e-spec.ts
Normal file
185
apps/api/test/referentiel.e2e-spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* E2E R1 — référentiel : parcours de recette (site → zone → appareil → organes)
|
||||
* + invariants (profondeur 2, types d'organes, matrice de permissions vivante).
|
||||
*/
|
||||
process.env.DEMO_MODE = 'true';
|
||||
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import request from 'supertest';
|
||||
import { seed } from '../prisma/seed';
|
||||
import { AppModule } from '../src/app.module';
|
||||
|
||||
describe('Référentiel (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
let admin: string; // jetons
|
||||
let technicien: string;
|
||||
const prisma = new PrismaClient();
|
||||
const http = () => request(app.getHttpServer());
|
||||
const suffix = Date.now().toString(36); // données de test uniques et repérables
|
||||
|
||||
beforeAll(async () => {
|
||||
await seed(prisma);
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule.forRoot()],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
|
||||
const { body } = await http().get('/auth/demo-accounts');
|
||||
const login = async (roleName: string) => {
|
||||
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||
const res = await http().post('/auth/demo-login').send({ userId: compte.id });
|
||||
return res.body.accessToken as string;
|
||||
};
|
||||
admin = await login('Administrateur');
|
||||
technicien = await login('Technicien');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.asset.deleteMany({ where: { reference: { contains: suffix } } });
|
||||
await prisma.location.deleteMany({ where: { name: { contains: suffix } } });
|
||||
await app?.close();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it('parcours de recette : site → zone → appareil avec organes', async () => {
|
||||
const site = await http()
|
||||
.post('/locations')
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.send({
|
||||
name: `Site Recette ${suffix}`,
|
||||
city: 'Casablanca',
|
||||
latitude: 33.59,
|
||||
longitude: -7.61,
|
||||
})
|
||||
.expect(201);
|
||||
expect(site.body.parentId).toBeNull();
|
||||
|
||||
const zone = await http()
|
||||
.post('/locations')
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.send({ name: `Hall Recette ${suffix}`, parentId: site.body.id })
|
||||
.expect(201);
|
||||
|
||||
const { body: cats } = await http()
|
||||
.get('/categories')
|
||||
.set('Authorization', `Bearer ${admin}`);
|
||||
const equipement = cats.categories.find(
|
||||
(c: { kind: string }) => c.kind === 'EQUIPMENT',
|
||||
);
|
||||
const typeOrgane = cats.categories.find(
|
||||
(c: { kind: string; isActive: boolean }) => c.kind === 'COMPONENT_TYPE' && c.isActive,
|
||||
);
|
||||
|
||||
const asset = await http()
|
||||
.post('/assets')
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.send({
|
||||
reference: `T-${suffix}`,
|
||||
brand: 'Otis',
|
||||
model: 'Gen2',
|
||||
categoryId: equipement.id,
|
||||
locationId: zone.body.id,
|
||||
components: [{ typeId: typeOrgane.id, designation: 'Organe de test' }],
|
||||
})
|
||||
.expect(201);
|
||||
expect(asset.body.components).toHaveLength(1);
|
||||
expect(asset.body.siteName).toBe(`Site Recette ${suffix}`);
|
||||
|
||||
// Ajout puis retrait d'un organe
|
||||
const organe = await http()
|
||||
.post(`/assets/${asset.body.id}/components`)
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.send({ typeId: typeOrgane.id })
|
||||
.expect(201);
|
||||
await http()
|
||||
.delete(`/assets/${asset.body.id}/components/${organe.body.id}`)
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.expect(204);
|
||||
});
|
||||
|
||||
it('refuse une hiérarchie de profondeur 3 (site → zone → ?)', async () => {
|
||||
const site = await http()
|
||||
.post('/locations')
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.send({ name: `Site Profond ${suffix}` })
|
||||
.expect(201);
|
||||
const zone = await http()
|
||||
.post('/locations')
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.send({ name: `Zone Profonde ${suffix}`, parentId: site.body.id })
|
||||
.expect(201);
|
||||
await http()
|
||||
.post('/locations')
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.send({ name: `Sous-zone ${suffix}`, parentId: zone.body.id })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('refuse un appareil dont la catégorie n’est pas un équipement', async () => {
|
||||
const { body: cats } = await http()
|
||||
.get('/categories')
|
||||
.set('Authorization', `Bearer ${admin}`);
|
||||
const typeOrgane = cats.categories.find((c: { kind: string }) => c.kind === 'COMPONENT_TYPE');
|
||||
const { body: locs } = await http()
|
||||
.get('/locations')
|
||||
.set('Authorization', `Bearer ${admin}`);
|
||||
await http()
|
||||
.post('/assets')
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.send({
|
||||
reference: `KO-${suffix}`,
|
||||
brand: 'Test',
|
||||
categoryId: typeOrgane.id, // kind COMPONENT_TYPE → refus
|
||||
locationId: locs.locations[0].id,
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('la matrice vit : un Technicien lit le parc mais ne crée rien', async () => {
|
||||
await http().get('/assets').set('Authorization', `Bearer ${technicien}`).expect(200);
|
||||
await http().get('/locations').set('Authorization', `Bearer ${technicien}`).expect(200);
|
||||
await http()
|
||||
.post('/locations')
|
||||
.set('Authorization', `Bearer ${technicien}`)
|
||||
.send({ name: `Interdit ${suffix}` })
|
||||
.expect(403);
|
||||
await http().get('/users').set('Authorization', `Bearer ${technicien}`).expect(403);
|
||||
});
|
||||
|
||||
it('le seed de la maquette est en place (A1 et ses organes, statuts)', async () => {
|
||||
const { body } = await http()
|
||||
.get('/assets')
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.expect(200);
|
||||
const a1 = body.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||
expect(a1.siteName).toBe('Résidence Al Manar');
|
||||
expect(a1.componentCount).toBe(4);
|
||||
const b2 = body.assets.find((a: { reference: string }) => a.reference === 'B2');
|
||||
expect(b2.status).toBe('OUT_OF_SERVICE');
|
||||
});
|
||||
|
||||
it('catégorie utilisée : désactivable, jamais supprimable (la route n’existe pas)', async () => {
|
||||
const { body: cats } = await http()
|
||||
.get('/categories')
|
||||
.set('Authorization', `Bearer ${admin}`);
|
||||
const used = cats.categories.find((c: { usageCount: number }) => c.usageCount > 0);
|
||||
await http()
|
||||
.delete(`/categories/${used.id}`)
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.expect(404); // pas de DELETE dans le contrat
|
||||
const off = await http()
|
||||
.patch(`/categories/${used.id}`)
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.send({ isActive: false })
|
||||
.expect(200);
|
||||
expect(off.body.isActive).toBe(false);
|
||||
await http()
|
||||
.patch(`/categories/${used.id}`)
|
||||
.set('Authorization', `Bearer ${admin}`)
|
||||
.send({ isActive: true })
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
1166
apps/web/src/api/schema.d.ts
vendored
1166
apps/web/src/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user