diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1aa0355..353f12c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -59,10 +59,10 @@ jobs: name: api (tests + couverture ≥ 70 %) runs-on: ubuntu-latest services: - # R0 : PostgreSQL nu suffit (r0_identity). Dès que des tests exigeront - # pgvector/PostGIS (R1+), passer sur l'image infra/postgres. + # R1+ : la migration r1_referentiel exige PostGIS (colonne générée geography). + # pgvector arrivera en R5 (bascule alors sur l’image infra/postgres). postgres: - image: postgres:18 + image: postgis/postgis:18-3.6 env: POSTGRES_USER: siop POSTGRES_PASSWORD: siop @@ -115,7 +115,7 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres:18 + image: postgis/postgis:18-3.6 env: POSTGRES_USER: siop POSTGRES_PASSWORD: siop diff --git a/CLAUDE.md b/CLAUDE.md index 00bb51f..3733c78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,5 +40,7 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS), - ✅ **R0.12 — tests + CI** (5 jobs verts) : `lint` (ESLint 10 flat config racine, règle ADR-001 anti-import MinIO codée et vérifiée), `ci-contract` (régénération spec+client, diff bloquant), `api` (PostgreSQL 18 + Redis, migrate deploy, Jest couverture ≥ 70 % bloquante — mesurée 97,5 %), `web` (typecheck + vitest + build), `e2e` (Playwright : parcours démo, bascule < 3 s chronométrée, déconnexion). Badge au README. - ✅ **R0.13 — Dockerfiles + runbook Dokploy** : image api (multi-stage pnpm deploy, `prisma migrate deploy` au boot, seed optionnel `SEED_ON_START`, non-root, healthcheck ; `binaryTargets` explicites) ; image web (nginx, proxy `/api` résolu à la requête, fallback SPA, cache assets) ; `infra/docker-compose.dokploy.yml` (5 services `siop2-`, seul le web sur `dokploy-network`) ; runbook `docs/06-production/runbook-dokploy.md`. **Répétition locale complète validée** (migrate+seed au boot, parcours via nginx conteneurisé, double verrou ADR-002 observé). Le déploiement réel attend les accès au serveur du partenaire. - ✅ **R0 CLOSE** (tag `release/r0`) — en production : `https://siop2.apps.enset.top` (Dokploy ENSET, profil démo, vérifiée en ligne). Restes non bloquants : secret `DOKPLOY_WEBHOOK_URL` (CD), sauvegardes PostgreSQL Dokploy. Production client SPELEV : attend les accès au serveur du partenaire. -- 🔄 **R1 Référentiel — ouverte, design d'abord** : `docs/02-design/maquettes/maquette-r1.html` (7 écrans : sites+carte, fiche site, ascenseurs, création, étiquette QR, personnes & équipes, catégories) — **⛔ en attente de validation du référent avant tout code R1**. Ensuite : modèle de données R1 (Location PostGIS, Asset+organes, Category, Team, invitation/activation) → API → web → tests/e2e → déploiement. +- ✅ **R1 — maquettes validées** (16/07) : `maquette-r1.html`, 7 écrans + 4 décisions (statuts d'équipement ≠ statuts OT ; organe sans emplacement par construction ; invitation par lien 7 j ; catégories jamais supprimées si utilisées). +- ✅ **R1.1 — socle backend** : migration `r1_referentiel` (Category, Location site→zone + PostGIS générée, Asset, AssetComponent, Team, invitation User), 21 nouvelles opérations au contrat (26 total), 4 modules API + invitations/activation sous `@RequirePermission`, seed parc maquette (5 sites, 8 appareils, organes, 2 équipes), 36 tests (96 %/85 %), CI sur `postgis/postgis:18-3.6`. +- 🔄 **R1.2 — reprise ici** : `apps/web` — écrans Sites (+ carte Leaflet/OSM à intégrer), Fiche site, Ascenseurs, Nouvel ascenseur, Étiquette QR imprimable, Personnes & équipes (invitation + lien à copier), Catégories — fidèles à maquette-r1.html ; puis e2e Playwright du parcours de recette R1 et déploiement. - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5. diff --git a/apps/api/prisma/migrations/20260716121129_r1_referentiel/migration.sql b/apps/api/prisma/migrations/20260716121129_r1_referentiel/migration.sql new file mode 100644 index 0000000..675cd0a --- /dev/null +++ b/apps/api/prisma/migrations/20260716121129_r1_referentiel/migration.sql @@ -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"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 7afe851..6a175fb 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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[] +} diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 8918ffc..72afbd9 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -131,6 +131,15 @@ export async function seed(prisma: PrismaClient): Promise { 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, + passwordHash: string, +): Promise { 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 { } } +// ————— 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 { + const categoryIds = new Map(); + 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(); // « 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(); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 19e5f97..6210b2f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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] : []), ], diff --git a/apps/api/src/assets/assets.controller.ts b/apps/api/src/assets/assets.controller.ts new file mode 100644 index 0000000..99c9aa2 --- /dev/null +++ b/apps/api/src/assets/assets.controller.ts @@ -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); + } +} diff --git a/apps/api/src/assets/assets.module.ts b/apps/api/src/assets/assets.module.ts new file mode 100644 index 0000000..52f948a --- /dev/null +++ b/apps/api/src/assets/assets.module.ts @@ -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 {} diff --git a/apps/api/src/assets/assets.service.ts b/apps/api/src/assets/assets.service.ts new file mode 100644 index 0000000..813c93f --- /dev/null +++ b/apps/api/src/assets/assets.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const location = await this.prisma.location.findUnique({ where: { id: locationId } }); + if (!location) throw new BadRequestException('Emplacement inconnu'); + } + + private async assertComponentTypes(typeIds: string[]): Promise { + 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, + }; + } +} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 6ab8b9e..dc34060 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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); + } } diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 7d5d9d9..d2a565d 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 { + 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 { // Identité seule — les droits restent en base (invariant R0) const accessToken = await this.jwtService.signAsync({ diff --git a/apps/api/src/categories/categories.controller.ts b/apps/api/src/categories/categories.controller.ts new file mode 100644 index 0000000..7ae9c4c --- /dev/null +++ b/apps/api/src/categories/categories.controller.ts @@ -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); + } +} diff --git a/apps/api/src/categories/categories.module.ts b/apps/api/src/categories/categories.module.ts new file mode 100644 index 0000000..4795813 --- /dev/null +++ b/apps/api/src/categories/categories.module.ts @@ -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 {} diff --git a/apps/api/src/categories/categories.service.ts b/apps/api/src/categories/categories.service.ts new file mode 100644 index 0000000..8eca25d --- /dev/null +++ b/apps/api/src/categories/categories.service.ts @@ -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 { + 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 { + 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 { + 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; + } + } +} diff --git a/apps/api/src/locations/locations.controller.ts b/apps/api/src/locations/locations.controller.ts new file mode 100644 index 0000000..2645da3 --- /dev/null +++ b/apps/api/src/locations/locations.controller.ts @@ -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); + } +} diff --git a/apps/api/src/locations/locations.module.ts b/apps/api/src/locations/locations.module.ts new file mode 100644 index 0000000..3e0c7f6 --- /dev/null +++ b/apps/api/src/locations/locations.module.ts @@ -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 {} diff --git a/apps/api/src/locations/locations.service.ts b/apps/api/src/locations/locations.service.ts new file mode 100644 index 0000000..29862e4 --- /dev/null +++ b/apps/api/src/locations/locations.service.ts @@ -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 { + const rows: LocationRow[] = await this.prisma.location.findMany({ + include: { _count: { select: { assets: true } } }, + orderBy: { name: 'asc' }, + }); + const childAssets = new Map(); + 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 { + 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 { + 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 { + 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, 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, + }; + } +} diff --git a/apps/api/src/teams/teams.controller.ts b/apps/api/src/teams/teams.controller.ts new file mode 100644 index 0000000..9be0e70 --- /dev/null +++ b/apps/api/src/teams/teams.controller.ts @@ -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); + } +} diff --git a/apps/api/src/teams/teams.module.ts b/apps/api/src/teams/teams.module.ts new file mode 100644 index 0000000..3fc92e4 --- /dev/null +++ b/apps/api/src/teams/teams.module.ts @@ -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 {} diff --git a/apps/api/src/teams/teams.service.ts b/apps/api/src/teams/teams.service.ts new file mode 100644 index 0000000..7a7a93d --- /dev/null +++ b/apps/api/src/teams/teams.service.ts @@ -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 { + 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 { + 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 { + 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(''), + })), + }; + } +} diff --git a/apps/api/src/users/users.controller.ts b/apps/api/src/users/users.controller.ts index 5fb6706..917d200 100644 --- a/apps/api/src/users/users.controller.ts +++ b/apps/api/src/users/users.controller.ts @@ -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 { 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); + } } diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts index 28981a0..513776d 100644 --- a/apps/api/src/users/users.module.ts +++ b/apps/api/src/users/users.module.ts @@ -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 {} diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts new file mode 100644 index 0000000..748360c --- /dev/null +++ b/apps/api/src/users/users.service.ts @@ -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 { + const rows = await this.prisma.user.findMany({ + include: userInclude, + orderBy: { displayName: 'asc' }, + }); + return { users: rows.map((u) => this.toDto(u)) }; + } + + async roles(): Promise { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/apps/api/test/administration.e2e-spec.ts b/apps/api/test/administration.e2e-spec.ts new file mode 100644 index 0000000..e6b8661 --- /dev/null +++ b/apps/api/test/administration.e2e-spec.ts @@ -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); + }); +}); diff --git a/apps/api/test/invitation.e2e-spec.ts b/apps/api/test/invitation.e2e-spec.ts new file mode 100644 index 0000000..c5c86b2 --- /dev/null +++ b/apps/api/test/invitation.e2e-spec.ts @@ -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); + }); +}); diff --git a/apps/api/test/referentiel.e2e-spec.ts b/apps/api/test/referentiel.e2e-spec.ts new file mode 100644 index 0000000..7ca52f5 --- /dev/null +++ b/apps/api/test/referentiel.e2e-spec.ts @@ -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); + }); +}); diff --git a/apps/web/src/api/schema.d.ts b/apps/web/src/api/schema.d.ts index f22df9b..11a91df 100644 --- a/apps/web/src/api/schema.d.ts +++ b/apps/web/src/api/schema.d.ts @@ -78,6 +78,283 @@ export interface paths { patch?: never; trace?: never; }; + "/auth/activate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Activer un compte invité (lien 7 jours) — connecte directement */ + post: operations["activateAccount"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/categories": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Référentiels administrables (catégories d’équipement, types d’organes) */ + get: operations["listCategories"]; + put?: never; + /** Ajouter une catégorie */ + post: operations["createCategory"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/categories/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Renommer ou (dés)activer — jamais de suppression si utilisée */ + patch: operations["updateCategory"]; + trace?: never; + }; + "/locations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Sites et zones (liste plate, le client construit l’arbre) */ + get: operations["listLocations"]; + put?: never; + /** Créer un site (sans parent) ou une zone (profondeur max 2) */ + post: operations["createLocation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/locations/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Modifier un emplacement */ + patch: operations["updateLocation"]; + trace?: never; + }; + "/assets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Inventaire des appareils */ + get: operations["listAssets"]; + put?: never; + /** Créer un appareil (avec ses organes) — le QR découle de la référence */ + post: operations["createAsset"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/assets/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Fiche appareil (identité + organes) */ + get: operations["getAsset"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Modifier un appareil (dont son statut d’équipement) */ + patch: operations["updateAsset"]; + trace?: never; + }; + "/assets/{id}/components": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Ajouter un organe (jamais d’emplacement propre) */ + post: operations["addAssetComponent"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/assets/{id}/components/{componentId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Retirer un organe */ + delete: operations["removeAssetComponent"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/teams": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Équipes et leurs membres */ + get: operations["listTeams"]; + put?: never; + /** Créer une équipe */ + post: operations["createTeam"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/teams/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Modifier une équipe (nom, description, membres) */ + patch: operations["updateTeam"]; + trace?: never; + }; + "/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Personnes (statut dérivé : actif / invité / désactivé) */ + get: operations["listUsers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/roles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Les 7 rôles (pour l’invitation) */ + get: operations["listRoles"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/invitations": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Inviter — crée le compte inactif et émet le lien d’activation (7 j) */ + post: operations["inviteUser"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/{id}/invitation": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Régénérer le lien d’activation d’un compte non activé */ + post: operations["resendInvitation"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Modifier une personne (rôle, équipes, activation du compte) */ + patch: operations["updateUser"]; + trace?: never; + }; "/health": { parameters: { query?: never; @@ -158,6 +435,305 @@ export interface components { canDelete: boolean; }[]; }; + ActivateRequest: { + token: string; + password: string; + }; + CategoriesResponse: { + categories: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + kind: "EQUIPMENT" | "COMPONENT_TYPE"; + name: string; + isActive: boolean; + usageCount: number; + }[]; + }; + Category: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + kind: "EQUIPMENT" | "COMPONENT_TYPE"; + name: string; + isActive: boolean; + usageCount: number; + }; + CategoryCreate: { + /** @enum {string} */ + kind: "EQUIPMENT" | "COMPONENT_TYPE"; + name: string; + }; + CategoryUpdate: { + name?: string; + isActive?: boolean; + }; + LocationsResponse: { + locations: { + /** Format: uuid */ + 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; + assetCount: number; + }[]; + }; + Location: { + /** Format: uuid */ + 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; + assetCount: number; + }; + LocationCreate: { + name: string; + /** Format: uuid */ + parentId?: string; + address?: string; + city?: string; + guardianName?: string; + guardianPhone?: string; + latitude?: number; + longitude?: number; + }; + LocationUpdate: { + name?: string; + /** Format: uuid */ + parentId?: string; + address?: string; + city?: string; + guardianName?: string; + guardianPhone?: string; + latitude?: number; + longitude?: number; + }; + AssetsResponse: { + assets: { + /** Format: uuid */ + id: string; + reference: string; + brand: string; + model: string | null; + serialNumber: string | null; + commissionedAt: string | null; + loadKg: number | null; + floors: number | null; + /** @enum {string} */ + status: "IN_SERVICE" | "OUT_OF_SERVICE" | "UNDER_MAINTENANCE"; + /** Format: uuid */ + categoryId: string; + categoryName: string; + /** Format: uuid */ + locationId: string; + locationName: string; + siteName: string; + componentCount: number; + }[]; + }; + AssetDetail: { + /** Format: uuid */ + id: string; + reference: string; + brand: string; + model: string | null; + serialNumber: string | null; + commissionedAt: string | null; + loadKg: number | null; + floors: number | null; + /** @enum {string} */ + status: "IN_SERVICE" | "OUT_OF_SERVICE" | "UNDER_MAINTENANCE"; + /** Format: uuid */ + categoryId: string; + categoryName: string; + /** Format: uuid */ + locationId: string; + locationName: string; + siteName: string; + componentCount: number; + components: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + typeId: string; + typeName: string; + designation: string | null; + }[]; + }; + AssetCreate: { + reference: string; + brand: string; + model?: string; + serialNumber?: string; + /** Format: date-time */ + commissionedAt?: string; + loadKg?: number; + floors?: number; + /** Format: uuid */ + categoryId: string; + /** Format: uuid */ + locationId: string; + components?: { + /** Format: uuid */ + typeId: string; + designation?: string; + }[]; + }; + AssetUpdate: { + reference?: string; + brand?: string; + model?: string; + serialNumber?: string; + /** Format: date-time */ + commissionedAt?: string; + loadKg?: number; + floors?: number; + /** Format: uuid */ + categoryId?: string; + /** Format: uuid */ + locationId?: string; + /** @enum {string} */ + status?: "IN_SERVICE" | "OUT_OF_SERVICE" | "UNDER_MAINTENANCE"; + }; + AssetComponent: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + typeId: string; + typeName: string; + designation: string | null; + }; + AssetComponentCreate: { + /** Format: uuid */ + typeId: string; + designation?: string; + }; + TeamsResponse: { + teams: { + /** Format: uuid */ + id: string; + name: string; + description: string | null; + members: { + /** Format: uuid */ + id: string; + displayName: string; + roleName: string; + initials: string; + }[]; + }[]; + }; + Team: { + /** Format: uuid */ + id: string; + name: string; + description: string | null; + members: { + /** Format: uuid */ + id: string; + displayName: string; + roleName: string; + initials: string; + }[]; + }; + TeamCreate: { + name: string; + description?: string; + memberIds?: string[]; + }; + TeamUpdate: { + name?: string; + description?: string; + memberIds?: string[]; + }; + UsersResponse: { + users: { + /** Format: uuid */ + id: string; + /** Format: email */ + email: string; + displayName: string; + phone: string | null; + role: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + name: "Administrateur" | "Dispatcher" | "Technicien" | "Technicien limité" | "Gestionnaire" | "Demandeur" | "Vue seule"; + }; + teams: { + /** Format: uuid */ + id: string; + name: string; + }[]; + /** @enum {string} */ + status: "active" | "invited" | "disabled"; + isDemo: boolean; + }[]; + }; + RolesResponse: { + roles: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + name: "Administrateur" | "Dispatcher" | "Technicien" | "Technicien limité" | "Gestionnaire" | "Demandeur" | "Vue seule"; + }[]; + }; + InvitationResponse: { + /** Format: uuid */ + userId: string; + activationToken: string; + /** Format: date-time */ + expiresAt: string; + }; + InvitationCreate: { + /** Format: email */ + email: string; + displayName: string; + /** Format: uuid */ + roleId: string; + teamIds?: string[]; + phone?: string; + }; + UserAdmin: { + /** Format: uuid */ + id: string; + /** Format: email */ + email: string; + displayName: string; + phone: string | null; + role: { + /** Format: uuid */ + id: string; + /** @enum {string} */ + name: "Administrateur" | "Dispatcher" | "Technicien" | "Technicien limité" | "Gestionnaire" | "Demandeur" | "Vue seule"; + }; + teams: { + /** Format: uuid */ + id: string; + name: string; + }[]; + /** @enum {string} */ + status: "active" | "invited" | "disabled"; + isDemo: boolean; + }; + UserUpdate: { + displayName?: string; + phone?: string; + /** Format: uuid */ + roleId?: string; + teamIds?: string[]; + isActive?: boolean; + }; HealthResponse: { /** @enum {string} */ status: "ok" | "degraded"; @@ -295,6 +871,596 @@ export interface operations { }; }; }; + activateAccount: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ActivateRequest"]; + }; + }; + responses: { + /** @description Compte activé et connecté */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthResponse"]; + }; + }; + /** @description Lien invalide ou expiré */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listCategories: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Liste */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CategoriesResponse"]; + }; + }; + }; + }; + createCategory: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CategoryCreate"]; + }; + }; + responses: { + /** @description Créée */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Category"]; + }; + }; + /** @description Nom déjà utilisé pour ce type */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateCategory: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CategoryUpdate"]; + }; + }; + responses: { + /** @description Mise à jour */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Category"]; + }; + }; + /** @description Inconnue */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listLocations: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Liste */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["LocationsResponse"]; + }; + }; + }; + }; + createLocation: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LocationCreate"]; + }; + }; + responses: { + /** @description Créé */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Location"]; + }; + }; + /** @description Hiérarchie trop profonde */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateLocation: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["LocationUpdate"]; + }; + }; + responses: { + /** @description Mis à jour */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Location"]; + }; + }; + /** @description Inconnu */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listAssets: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Liste */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AssetsResponse"]; + }; + }; + }; + }; + createAsset: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AssetCreate"]; + }; + }; + responses: { + /** @description Créé */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AssetDetail"]; + }; + }; + /** @description Référence déjà utilisée */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getAsset: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Fiche */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AssetDetail"]; + }; + }; + /** @description Inconnu */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateAsset: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AssetUpdate"]; + }; + }; + responses: { + /** @description Mis à jour */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AssetDetail"]; + }; + }; + /** @description Inconnu */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + addAssetComponent: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AssetComponentCreate"]; + }; + }; + responses: { + /** @description Ajouté */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AssetComponent"]; + }; + }; + /** @description Le type choisi n’est pas un type d’organe */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + removeAssetComponent: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + componentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Retiré */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Inconnu */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listTeams: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Liste */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TeamsResponse"]; + }; + }; + }; + }; + createTeam: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TeamCreate"]; + }; + }; + responses: { + /** @description Créée */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Team"]; + }; + }; + /** @description Nom déjà utilisé */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateTeam: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TeamUpdate"]; + }; + }; + responses: { + /** @description Mise à jour */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Team"]; + }; + }; + /** @description Inconnue */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + listUsers: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Liste */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UsersResponse"]; + }; + }; + }; + }; + listRoles: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Liste */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RolesResponse"]; + }; + }; + }; + }; + inviteUser: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["InvitationCreate"]; + }; + }; + responses: { + /** @description Invitation émise */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InvitationResponse"]; + }; + }; + /** @description Email déjà utilisé */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + resendInvitation: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Nouveau lien */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InvitationResponse"]; + }; + }; + /** @description Compte déjà activé */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateUser: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserUpdate"]; + }; + }; + responses: { + /** @description Mise à jour */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserAdmin"]; + }; + }; + /** @description Inconnue */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; getHealth: { parameters: { query?: never; diff --git a/docs/03-architecture/modele-donnees.md b/docs/03-architecture/modele-donnees.md index a5a7157..9763544 100644 --- a/docs/03-architecture/modele-donnees.md +++ b/docs/03-architecture/modele-donnees.md @@ -50,10 +50,74 @@ model User { | `demo-login` refuse tout compte `isDemo=false` | service auth (+ test e2e) | | Matrice complète : chaque rôle a une ligne par catégorie d'objet | seed idempotent (+ test) | +## R1 — Référentiel + +```prisma +enum CategoryKind { EQUIPMENT COMPONENT_TYPE } // référentiels administrables +enum AssetStatus { IN_SERVICE OUT_OF_SERVICE UNDER_MAINTENANCE } + +model Category { // renommable/désactivable, JAMAIS supprimée si utilisée + id String @id @default(uuid()) @db.Uuid + kind CategoryKind + name String + isActive Boolean @default(true) + @@unique([kind, name]) +} + +model Location { // site (parentId null) → zone (1 niveau max, vérifié service) + id String @id @default(uuid()) @db.Uuid + name String + parentId String? @db.Uuid // auto-relation « LocationTree » + address String? city String? + guardianName String? guardianPhone String? + latitude Float? longitude Float? // saisies par la carte + // + colonne PostGIS générée (migration SQL) : + // position geography(Point,4326) GENERATED ALWAYS AS (ST_Point(longitude,latitude)::geography) STORED +} + +model Asset { // l'appareil (ascenseur, monte-charge…) + id String @id @default(uuid()) @db.Uuid + reference String @unique // « A1 », « B2 » — imprimée sur le 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(kind=EQUIPMENT) + locationId String @db.Uuid // rattachement obligatoire + components AssetComponent[] +} + +model AssetComponent { // organe — PAS de colonne emplacement : la règle + id String @id @default(uuid()) @db.Uuid // « un organe n'a pas d'emplacement + assetId String @db.Uuid // propre » est garantie PAR + typeId String @db.Uuid // CONSTRUCTION (table dédiée), + designation String? // plus besoin du CHECK v1 +} + +model Team { // équipes par zone — l'assignation d'OT (R2) s'appuiera dessus + id String @id @default(uuid()) @db.Uuid + name String @unique + description String? + members User[] // m2m implicite +} + +// User (R0) reçoit : phone?, teams Team[], et l'invitation (ADR maquettes R1) : +// activationToken String? @unique + activationExpiresAt DateTime? +// statut dérivé : invité = passwordHash null && token présent ; actif = hash présent +``` + +**Invariants R1** : + +| Invariant | Où il vit | +| --- | --- | +| Hiérarchie d'emplacements limitée à 2 niveaux (site → zone) | service locations (+ test) | +| Un organe n'a pas d'emplacement propre | par construction (AssetComponent sans locationId) | +| `Category(kind)` cohérente avec l'usage (EQUIPMENT sur Asset, COMPONENT_TYPE sur organe) | services (+ test) | +| Catégorie utilisée : jamais supprimée (désactivation seulement) | service categories (+ test) | +| Lien d'activation : 7 jours, usage unique, aucun compte actif avant | service users (+ test e2e) | +| Position : lat/lng saisis, colonne PostGIS **générée** pour les requêtes spatiales futures | migration SQL | + ## À venir (référence v1 éprouvée, sera réintroduit release par release) -- **R1** : `Location` (hiérarchie + colonne PostGIS), `Asset` + organes (hiérarchie 2 niveaux, - CHECK « un organe n'a pas d'emplacement propre »), `Category`, `Team`. - **R2** : `WorkOrder` (machine à états stricte, priorité « personne bloquée »), `Request` (lien 1-1 vers OT), `InterventionReport` (bilan codé 6 champs → `ReferenceValue`), `TaskTemplate`/`PreventivePlan`/`ChecklistItem` (périodicité calendrier), `Meter`. diff --git a/docs/journal/journal.md b/docs/journal/journal.md index 0aef5f2..4c0153c 100644 --- a/docs/journal/journal.md +++ b/docs/journal/journal.md @@ -4,6 +4,26 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook** --- +## 2026-07-16 — Pr. Daaif (+ Claude) — R1.1 : socle backend du référentiel + +**Actions** + +- **Modèle R1** (doc + Prisma + migration `r1_referentiel`) : Category (kind 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 (m2m User), invitation sur User (token unique + expiration). Migration **autosuffisante** (`CREATE EXTENSION IF NOT EXISTS postgis`). +- **Contrat** : 21 nouvelles opérations (26 au total) — categories/locations/assets+organes/teams/users/roles/invitations/activate ; générateur OpenAPI étendu aux paramètres de chemin ; spec + client web régénérés dans le même commit. +- **API** : 4 nouveaux modules + gestion des personnes, tous sous `@RequirePermission` (la matrice décide) ; invariants en service : profondeur 2, kinds de catégories, catégorie jamais supprimée, lien d'activation 7 jours à usage unique qui connecte directement. +- **Seed** : parc de la maquette (5 sites + 8 zones, 8 appareils dont B2 à l'arrêt et M1 en maintenance, organes d'A1/B2, 9 catégories, 2 équipes). +- **36 tests verts** (couverture 96 % stmts / 85 % branches) : parcours de recette site→zone→appareil→organes, profondeur 3 refusée, matrice vivante (Technicien lit mais ne crée pas), invitation→activation complète (lien périmé/consommé/renvoyé). Smoke test sur build de prod : sites avec compteurs, fiche A1 et ses 4 organes. +- **CI** : bascule sur `postgis/postgis:18-3.6` (la migration R1 l'exige) — le moment anticipé dans le commentaire du workflow. + +**Leçons** + +- Migration modifiée après application locale ⇒ réaligner son checksum dans `_prisma_migrations` (ou reset) — d'où la règle : rendre la migration autosuffisante AVANT de l'appliquer. +- Un serveur `reuseExistingServer` de Playwright peut squatter :3000 et faire tester un dist périmé — tuer le port avant tout smoke test. + +**Prochaine étape** : R1.2 `apps/web` — écrans Sites (+ carte Leaflet/OSM), Fiche site, Ascenseurs, Nouvel ascenseur, Étiquette QR, Personnes & équipes, Catégories, fidèles à maquette-r1.html. + +--- + ## 2026-07-16 — Pr. Daaif (+ Claude) — R0 CLOSE (tag) · R1 ouverte : maquettes à valider **Actions** @@ -20,6 +40,7 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook** - Catégorie utilisée : renommage/désactivation seulement, jamais de suppression. **⛔ Bloquant** : validation des maquettes R1 par le référent avant toute ligne de code applicatif R1. +**→ Levé le 16/07/2026 : maquettes R1 et les 4 décisions de conception VALIDÉES par le référent.** Lancement R1.1 (socle backend). --- diff --git a/docs/openapi.json b/docs/openapi.json index 2607e72..1dcc24d 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -133,6 +133,803 @@ } } }, + "/auth/activate": { + "post": { + "operationId": "activateAccount", + "summary": "Activer un compte invité (lien 7 jours) — connecte directement", + "tags": [ + "auth" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Compte activé et connecté", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthResponse" + } + } + } + }, + "400": { + "description": "Lien invalide ou expiré" + } + } + } + }, + "/categories": { + "get": { + "operationId": "listCategories", + "summary": "Référentiels administrables (catégories d’équipement, types d’organes)", + "tags": [ + "categories" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Liste", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CategoriesResponse" + } + } + } + } + } + }, + "post": { + "operationId": "createCategory", + "summary": "Ajouter une catégorie", + "tags": [ + "categories" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CategoryCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Créée", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Category" + } + } + } + }, + "409": { + "description": "Nom déjà utilisé pour ce type" + } + } + } + }, + "/categories/{id}": { + "patch": { + "operationId": "updateCategory", + "summary": "Renommer ou (dés)activer — jamais de suppression si utilisée", + "tags": [ + "categories" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CategoryUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Mise à jour", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Category" + } + } + } + }, + "404": { + "description": "Inconnue" + } + } + } + }, + "/locations": { + "get": { + "operationId": "listLocations", + "summary": "Sites et zones (liste plate, le client construit l’arbre)", + "tags": [ + "locations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Liste", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationsResponse" + } + } + } + } + } + }, + "post": { + "operationId": "createLocation", + "summary": "Créer un site (sans parent) ou une zone (profondeur max 2)", + "tags": [ + "locations" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Créé", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Location" + } + } + } + }, + "400": { + "description": "Hiérarchie trop profonde" + } + } + } + }, + "/locations/{id}": { + "patch": { + "operationId": "updateLocation", + "summary": "Modifier un emplacement", + "tags": [ + "locations" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LocationUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Mis à jour", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Location" + } + } + } + }, + "404": { + "description": "Inconnu" + } + } + } + }, + "/assets": { + "get": { + "operationId": "listAssets", + "summary": "Inventaire des appareils", + "tags": [ + "assets" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Liste", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetsResponse" + } + } + } + } + } + }, + "post": { + "operationId": "createAsset", + "summary": "Créer un appareil (avec ses organes) — le QR découle de la référence", + "tags": [ + "assets" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Créé", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetDetail" + } + } + } + }, + "409": { + "description": "Référence déjà utilisée" + } + } + } + }, + "/assets/{id}": { + "get": { + "operationId": "getAsset", + "summary": "Fiche appareil (identité + organes)", + "tags": [ + "assets" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Fiche", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetDetail" + } + } + } + }, + "404": { + "description": "Inconnu" + } + } + }, + "patch": { + "operationId": "updateAsset", + "summary": "Modifier un appareil (dont son statut d’équipement)", + "tags": [ + "assets" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Mis à jour", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetDetail" + } + } + } + }, + "404": { + "description": "Inconnu" + } + } + } + }, + "/assets/{id}/components": { + "post": { + "operationId": "addAssetComponent", + "summary": "Ajouter un organe (jamais d’emplacement propre)", + "tags": [ + "assets" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetComponentCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Ajouté", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetComponent" + } + } + } + }, + "400": { + "description": "Le type choisi n’est pas un type d’organe" + } + } + } + }, + "/assets/{id}/components/{componentId}": { + "delete": { + "operationId": "removeAssetComponent", + "summary": "Retirer un organe", + "tags": [ + "assets" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "componentId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "204": { + "description": "Retiré" + }, + "404": { + "description": "Inconnu" + } + } + } + }, + "/teams": { + "get": { + "operationId": "listTeams", + "summary": "Équipes et leurs membres", + "tags": [ + "teams" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Liste", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamsResponse" + } + } + } + } + } + }, + "post": { + "operationId": "createTeam", + "summary": "Créer une équipe", + "tags": [ + "teams" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Créée", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Team" + } + } + } + }, + "409": { + "description": "Nom déjà utilisé" + } + } + } + }, + "/teams/{id}": { + "patch": { + "operationId": "updateTeam", + "summary": "Modifier une équipe (nom, description, membres)", + "tags": [ + "teams" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Mise à jour", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Team" + } + } + } + }, + "404": { + "description": "Inconnue" + } + } + } + }, + "/users": { + "get": { + "operationId": "listUsers", + "summary": "Personnes (statut dérivé : actif / invité / désactivé)", + "tags": [ + "users" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Liste", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UsersResponse" + } + } + } + } + } + } + }, + "/roles": { + "get": { + "operationId": "listRoles", + "summary": "Les 7 rôles (pour l’invitation)", + "tags": [ + "users" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Liste", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RolesResponse" + } + } + } + } + } + } + }, + "/users/invitations": { + "post": { + "operationId": "inviteUser", + "summary": "Inviter — crée le compte inactif et émet le lien d’activation (7 j)", + "tags": [ + "users" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationCreate" + } + } + } + }, + "responses": { + "201": { + "description": "Invitation émise", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationResponse" + } + } + } + }, + "409": { + "description": "Email déjà utilisé" + } + } + } + }, + "/users/{id}/invitation": { + "post": { + "operationId": "resendInvitation", + "summary": "Régénérer le lien d’activation d’un compte non activé", + "tags": [ + "users" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "201": { + "description": "Nouveau lien", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationResponse" + } + } + } + }, + "409": { + "description": "Compte déjà activé" + } + } + } + }, + "/users/{id}": { + "patch": { + "operationId": "updateUser", + "summary": "Modifier une personne (rôle, équipes, activation du compte)", + "tags": [ + "users" + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Mise à jour", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserAdmin" + } + } + } + }, + "404": { + "description": "Inconnue" + } + } + } + }, "/health": { "get": { "operationId": "getHealth", @@ -417,6 +1214,1474 @@ ], "additionalProperties": false }, + "ActivateRequest": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "token": { + "type": "string", + "minLength": 10 + }, + "password": { + "type": "string", + "minLength": 8 + } + }, + "required": [ + "token", + "password" + ], + "additionalProperties": false + }, + "CategoriesResponse": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "kind": { + "type": "string", + "enum": [ + "EQUIPMENT", + "COMPONENT_TYPE" + ] + }, + "name": { + "type": "string" + }, + "isActive": { + "type": "boolean" + }, + "usageCount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "id", + "kind", + "name", + "isActive", + "usageCount" + ], + "additionalProperties": false + } + } + }, + "required": [ + "categories" + ], + "additionalProperties": false + }, + "Category": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "kind": { + "type": "string", + "enum": [ + "EQUIPMENT", + "COMPONENT_TYPE" + ] + }, + "name": { + "type": "string" + }, + "isActive": { + "type": "boolean" + }, + "usageCount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "id", + "kind", + "name", + "isActive", + "usageCount" + ], + "additionalProperties": false + }, + "CategoryCreate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "EQUIPMENT", + "COMPONENT_TYPE" + ] + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + }, + "required": [ + "kind", + "name" + ], + "additionalProperties": false + }, + "CategoryUpdate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "isActive": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "LocationsResponse": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "locations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "name": { + "type": "string" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + { + "type": "null" + } + ] + }, + "address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "city": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "guardianName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "guardianPhone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "latitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "longitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "assetCount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "id", + "name", + "parentId", + "address", + "city", + "guardianName", + "guardianPhone", + "latitude", + "longitude", + "assetCount" + ], + "additionalProperties": false + } + } + }, + "required": [ + "locations" + ], + "additionalProperties": false + }, + "Location": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "name": { + "type": "string" + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + { + "type": "null" + } + ] + }, + "address": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "city": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "guardianName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "guardianPhone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "latitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "longitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "assetCount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "id", + "name", + "parentId", + "address", + "city", + "guardianName", + "guardianPhone", + "latitude", + "longitude", + "assetCount" + ], + "additionalProperties": false + }, + "LocationCreate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "parentId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "address": { + "type": "string", + "maxLength": 200 + }, + "city": { + "type": "string", + "maxLength": 80 + }, + "guardianName": { + "type": "string", + "maxLength": 120 + }, + "guardianPhone": { + "type": "string", + "maxLength": 40 + }, + "latitude": { + "type": "number", + "minimum": -90, + "maximum": 90 + }, + "longitude": { + "type": "number", + "minimum": -180, + "maximum": 180 + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "LocationUpdate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "parentId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "address": { + "type": "string", + "maxLength": 200 + }, + "city": { + "type": "string", + "maxLength": 80 + }, + "guardianName": { + "type": "string", + "maxLength": 120 + }, + "guardianPhone": { + "type": "string", + "maxLength": 40 + }, + "latitude": { + "type": "number", + "minimum": -90, + "maximum": 90 + }, + "longitude": { + "type": "number", + "minimum": -180, + "maximum": 180 + } + }, + "additionalProperties": false + }, + "AssetsResponse": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "assets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "reference": { + "type": "string" + }, + "brand": { + "type": "string" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "serialNumber": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "commissionedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "loadKg": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "floors": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "IN_SERVICE", + "OUT_OF_SERVICE", + "UNDER_MAINTENANCE" + ] + }, + "categoryId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "categoryName": { + "type": "string" + }, + "locationId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "locationName": { + "type": "string" + }, + "siteName": { + "type": "string" + }, + "componentCount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "id", + "reference", + "brand", + "model", + "serialNumber", + "commissionedAt", + "loadKg", + "floors", + "status", + "categoryId", + "categoryName", + "locationId", + "locationName", + "siteName", + "componentCount" + ], + "additionalProperties": false + } + } + }, + "required": [ + "assets" + ], + "additionalProperties": false + }, + "AssetDetail": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "reference": { + "type": "string" + }, + "brand": { + "type": "string" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "serialNumber": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "commissionedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ] + }, + "loadKg": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "floors": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "IN_SERVICE", + "OUT_OF_SERVICE", + "UNDER_MAINTENANCE" + ] + }, + "categoryId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "categoryName": { + "type": "string" + }, + "locationId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "locationName": { + "type": "string" + }, + "siteName": { + "type": "string" + }, + "componentCount": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "components": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "typeId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "typeName": { + "type": "string" + }, + "designation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "typeId", + "typeName", + "designation" + ], + "additionalProperties": false + } + } + }, + "required": [ + "id", + "reference", + "brand", + "model", + "serialNumber", + "commissionedAt", + "loadKg", + "floors", + "status", + "categoryId", + "categoryName", + "locationId", + "locationName", + "siteName", + "componentCount", + "components" + ], + "additionalProperties": false + }, + "AssetCreate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "reference": { + "type": "string", + "minLength": 1, + "maxLength": 30 + }, + "brand": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "model": { + "type": "string", + "maxLength": 80 + }, + "serialNumber": { + "type": "string", + "maxLength": 80 + }, + "commissionedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "loadKg": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "floors": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "categoryId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "locationId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "components": { + "type": "array", + "items": { + "type": "object", + "properties": { + "typeId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "designation": { + "type": "string", + "maxLength": 120 + } + }, + "required": [ + "typeId" + ], + "additionalProperties": false + } + } + }, + "required": [ + "reference", + "brand", + "categoryId", + "locationId" + ], + "additionalProperties": false + }, + "AssetUpdate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "reference": { + "type": "string", + "minLength": 1, + "maxLength": 30 + }, + "brand": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "model": { + "type": "string", + "maxLength": 80 + }, + "serialNumber": { + "type": "string", + "maxLength": 80 + }, + "commissionedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + "loadKg": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "floors": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "categoryId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "locationId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "status": { + "type": "string", + "enum": [ + "IN_SERVICE", + "OUT_OF_SERVICE", + "UNDER_MAINTENANCE" + ] + } + }, + "additionalProperties": false + }, + "AssetComponent": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "typeId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "typeName": { + "type": "string" + }, + "designation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "typeId", + "typeName", + "designation" + ], + "additionalProperties": false + }, + "AssetComponentCreate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "typeId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "designation": { + "type": "string", + "maxLength": 120 + } + }, + "required": [ + "typeId" + ], + "additionalProperties": false + }, + "TeamsResponse": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "teams": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "members": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "displayName": { + "type": "string" + }, + "roleName": { + "type": "string" + }, + "initials": { + "type": "string" + } + }, + "required": [ + "id", + "displayName", + "roleName", + "initials" + ], + "additionalProperties": false + } + } + }, + "required": [ + "id", + "name", + "description", + "members" + ], + "additionalProperties": false + } + } + }, + "required": [ + "teams" + ], + "additionalProperties": false + }, + "Team": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "members": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "displayName": { + "type": "string" + }, + "roleName": { + "type": "string" + }, + "initials": { + "type": "string" + } + }, + "required": [ + "id", + "displayName", + "roleName", + "initials" + ], + "additionalProperties": false + } + } + }, + "required": [ + "id", + "name", + "description", + "members" + ], + "additionalProperties": false + }, + "TeamCreate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "memberIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + } + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, + "TeamUpdate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "description": { + "type": "string", + "maxLength": 200 + }, + "memberIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + } + } + }, + "additionalProperties": false + }, + "UsersResponse": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "users": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + }, + "displayName": { + "type": "string" + }, + "phone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "role": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "name": { + "type": "string", + "enum": [ + "Administrateur", + "Dispatcher", + "Technicien", + "Technicien limité", + "Gestionnaire", + "Demandeur", + "Vue seule" + ] + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + }, + "teams": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + }, + "status": { + "type": "string", + "enum": [ + "active", + "invited", + "disabled" + ] + }, + "isDemo": { + "type": "boolean" + } + }, + "required": [ + "id", + "email", + "displayName", + "phone", + "role", + "teams", + "status", + "isDemo" + ], + "additionalProperties": false + } + } + }, + "required": [ + "users" + ], + "additionalProperties": false + }, + "RolesResponse": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "name": { + "type": "string", + "enum": [ + "Administrateur", + "Dispatcher", + "Technicien", + "Technicien limité", + "Gestionnaire", + "Demandeur", + "Vue seule" + ] + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + } + }, + "required": [ + "roles" + ], + "additionalProperties": false + }, + "InvitationResponse": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "userId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "activationToken": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + } + }, + "required": [ + "userId", + "activationToken", + "expiresAt" + ], + "additionalProperties": false + }, + "InvitationCreate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "roleId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "teamIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + } + }, + "phone": { + "type": "string", + "maxLength": 40 + } + }, + "required": [ + "email", + "displayName", + "roleId" + ], + "additionalProperties": false + }, + "UserAdmin": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "email": { + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + }, + "displayName": { + "type": "string" + }, + "phone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "role": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "name": { + "type": "string", + "enum": [ + "Administrateur", + "Dispatcher", + "Technicien", + "Technicien limité", + "Gestionnaire", + "Demandeur", + "Vue seule" + ] + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + }, + "teams": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + } + }, + "status": { + "type": "string", + "enum": [ + "active", + "invited", + "disabled" + ] + }, + "isDemo": { + "type": "boolean" + } + }, + "required": [ + "id", + "email", + "displayName", + "phone", + "role", + "teams", + "status", + "isDemo" + ], + "additionalProperties": false + }, + "UserUpdate": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "phone": { + "type": "string", + "maxLength": 40 + }, + "roleId": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "teamIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + } + }, + "isActive": { + "type": "boolean" + } + }, + "additionalProperties": false + }, "HealthResponse": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/packages/shared/scripts/generate-openapi.ts b/packages/shared/scripts/generate-openapi.ts index b8b70c4..ad1d3cb 100644 --- a/packages/shared/scripts/generate-openapi.ts +++ b/packages/shared/scripts/generate-openapi.ts @@ -38,6 +38,16 @@ for (const op of API_CONTRACT) { operationId: op.operationId, summary: op.summary, tags: op.tags, + ...(op.pathParams?.length + ? { + parameters: op.pathParams.map((name) => ({ + name, + in: 'path', + required: true, + schema: { type: 'string', format: 'uuid' }, + })), + } + : {}), ...(op.isPublic ? {} : { security: [{ bearerAuth: [] }] }), ...(op.demoOnly ? { diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index c771fd4..b6538a2 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -7,6 +7,36 @@ import { } from './schemas/auth'; import { MeResponseSchema } from './schemas/users'; import { HealthResponseSchema } from './schemas/health'; +import { + AssetCreateSchema, + AssetComponentCreateSchema, + AssetComponentSchema, + AssetDetailSchema, + AssetsResponseSchema, + AssetUpdateSchema, + CategoriesResponseSchema, + CategoryCreateSchema, + CategorySchema, + CategoryUpdateSchema, + LocationCreateSchema, + LocationSchema, + LocationsResponseSchema, + LocationUpdateSchema, + TeamCreateSchema, + TeamSchema, + TeamsResponseSchema, + TeamUpdateSchema, +} from './schemas/referentiel'; +import { + ActivateRequestSchema, + ActivateResponseSchema, + InvitationCreateSchema, + InvitationResponseSchema, + RolesResponseSchema, + UserAdminSchema, + UsersResponseSchema, + UserUpdateSchema, +} from './schemas/users-admin'; /** * Contrat d'API R0 — source unique de vérité (règle d'or ADR-001). @@ -23,6 +53,8 @@ export interface ApiOperation { isPublic?: boolean; /** ADR-002 : la route N'EXISTE PAS (404) si DEMO_MODE n'est pas actif. */ demoOnly?: boolean; + /** Paramètres de chemin (`{id}` dans path) — tous UUID en R1. */ + pathParams?: string[]; request?: { name: string; schema: z.ZodType }; responses: Record< number, @@ -86,6 +118,255 @@ export const API_CONTRACT: ApiOperation[] = [ 401: { description: 'Non authentifié' }, }, }, + { + operationId: 'activateAccount', + method: 'post', + path: '/auth/activate', + summary: 'Activer un compte invité (lien 7 jours) — connecte directement', + tags: ['auth'], + isPublic: true, + request: { name: 'ActivateRequest', schema: ActivateRequestSchema }, + responses: { + 200: { description: 'Compte activé et connecté', name: 'AuthResponse', schema: ActivateResponseSchema }, + 400: { description: 'Lien invalide ou expiré' }, + }, + }, + + // ————— R1 · Référentiel ————— + { + operationId: 'listCategories', + method: 'get', + path: '/categories', + summary: 'Référentiels administrables (catégories d’équipement, types d’organes)', + tags: ['categories'], + responses: { + 200: { description: 'Liste', name: 'CategoriesResponse', schema: CategoriesResponseSchema }, + }, + }, + { + operationId: 'createCategory', + method: 'post', + path: '/categories', + summary: 'Ajouter une catégorie', + tags: ['categories'], + request: { name: 'CategoryCreate', schema: CategoryCreateSchema }, + responses: { + 201: { description: 'Créée', name: 'Category', schema: CategorySchema }, + 409: { description: 'Nom déjà utilisé pour ce type' }, + }, + }, + { + operationId: 'updateCategory', + method: 'patch', + path: '/categories/{id}', + summary: 'Renommer ou (dés)activer — jamais de suppression si utilisée', + tags: ['categories'], + pathParams: ['id'], + request: { name: 'CategoryUpdate', schema: CategoryUpdateSchema }, + responses: { + 200: { description: 'Mise à jour', name: 'Category', schema: CategorySchema }, + 404: { description: 'Inconnue' }, + }, + }, + { + operationId: 'listLocations', + method: 'get', + path: '/locations', + summary: 'Sites et zones (liste plate, le client construit l’arbre)', + tags: ['locations'], + responses: { + 200: { description: 'Liste', name: 'LocationsResponse', schema: LocationsResponseSchema }, + }, + }, + { + operationId: 'createLocation', + method: 'post', + path: '/locations', + summary: 'Créer un site (sans parent) ou une zone (profondeur max 2)', + tags: ['locations'], + request: { name: 'LocationCreate', schema: LocationCreateSchema }, + responses: { + 201: { description: 'Créé', name: 'Location', schema: LocationSchema }, + 400: { description: 'Hiérarchie trop profonde' }, + }, + }, + { + operationId: 'updateLocation', + method: 'patch', + path: '/locations/{id}', + summary: 'Modifier un emplacement', + tags: ['locations'], + pathParams: ['id'], + request: { name: 'LocationUpdate', schema: LocationUpdateSchema }, + responses: { + 200: { description: 'Mis à jour', name: 'Location', schema: LocationSchema }, + 404: { description: 'Inconnu' }, + }, + }, + { + operationId: 'listAssets', + method: 'get', + path: '/assets', + summary: 'Inventaire des appareils', + tags: ['assets'], + responses: { + 200: { description: 'Liste', name: 'AssetsResponse', schema: AssetsResponseSchema }, + }, + }, + { + operationId: 'getAsset', + method: 'get', + path: '/assets/{id}', + summary: 'Fiche appareil (identité + organes)', + tags: ['assets'], + pathParams: ['id'], + responses: { + 200: { description: 'Fiche', name: 'AssetDetail', schema: AssetDetailSchema }, + 404: { description: 'Inconnu' }, + }, + }, + { + operationId: 'createAsset', + method: 'post', + path: '/assets', + summary: 'Créer un appareil (avec ses organes) — le QR découle de la référence', + tags: ['assets'], + request: { name: 'AssetCreate', schema: AssetCreateSchema }, + responses: { + 201: { description: 'Créé', name: 'AssetDetail', schema: AssetDetailSchema }, + 409: { description: 'Référence déjà utilisée' }, + }, + }, + { + operationId: 'updateAsset', + method: 'patch', + path: '/assets/{id}', + summary: 'Modifier un appareil (dont son statut d’équipement)', + tags: ['assets'], + pathParams: ['id'], + request: { name: 'AssetUpdate', schema: AssetUpdateSchema }, + responses: { + 200: { description: 'Mis à jour', name: 'AssetDetail', schema: AssetDetailSchema }, + 404: { description: 'Inconnu' }, + }, + }, + { + operationId: 'addAssetComponent', + method: 'post', + path: '/assets/{id}/components', + summary: 'Ajouter un organe (jamais d’emplacement propre)', + tags: ['assets'], + pathParams: ['id'], + request: { name: 'AssetComponentCreate', schema: AssetComponentCreateSchema }, + responses: { + 201: { description: 'Ajouté', name: 'AssetComponent', schema: AssetComponentSchema }, + 400: { description: 'Le type choisi n’est pas un type d’organe' }, + }, + }, + { + operationId: 'removeAssetComponent', + method: 'delete', + path: '/assets/{id}/components/{componentId}', + summary: 'Retirer un organe', + tags: ['assets'], + pathParams: ['id', 'componentId'], + responses: { + 204: { description: 'Retiré' }, + 404: { description: 'Inconnu' }, + }, + }, + { + operationId: 'listTeams', + method: 'get', + path: '/teams', + summary: 'Équipes et leurs membres', + tags: ['teams'], + responses: { + 200: { description: 'Liste', name: 'TeamsResponse', schema: TeamsResponseSchema }, + }, + }, + { + operationId: 'createTeam', + method: 'post', + path: '/teams', + summary: 'Créer une équipe', + tags: ['teams'], + request: { name: 'TeamCreate', schema: TeamCreateSchema }, + responses: { + 201: { description: 'Créée', name: 'Team', schema: TeamSchema }, + 409: { description: 'Nom déjà utilisé' }, + }, + }, + { + operationId: 'updateTeam', + method: 'patch', + path: '/teams/{id}', + summary: 'Modifier une équipe (nom, description, membres)', + tags: ['teams'], + pathParams: ['id'], + request: { name: 'TeamUpdate', schema: TeamUpdateSchema }, + responses: { + 200: { description: 'Mise à jour', name: 'Team', schema: TeamSchema }, + 404: { description: 'Inconnue' }, + }, + }, + { + operationId: 'listUsers', + method: 'get', + path: '/users', + summary: 'Personnes (statut dérivé : actif / invité / désactivé)', + tags: ['users'], + responses: { + 200: { description: 'Liste', name: 'UsersResponse', schema: UsersResponseSchema }, + }, + }, + { + operationId: 'listRoles', + method: 'get', + path: '/roles', + summary: 'Les 7 rôles (pour l’invitation)', + tags: ['users'], + responses: { + 200: { description: 'Liste', name: 'RolesResponse', schema: RolesResponseSchema }, + }, + }, + { + operationId: 'inviteUser', + method: 'post', + path: '/users/invitations', + summary: 'Inviter — crée le compte inactif et émet le lien d’activation (7 j)', + tags: ['users'], + request: { name: 'InvitationCreate', schema: InvitationCreateSchema }, + responses: { + 201: { description: 'Invitation émise', name: 'InvitationResponse', schema: InvitationResponseSchema }, + 409: { description: 'Email déjà utilisé' }, + }, + }, + { + operationId: 'resendInvitation', + method: 'post', + path: '/users/{id}/invitation', + summary: 'Régénérer le lien d’activation d’un compte non activé', + tags: ['users'], + pathParams: ['id'], + responses: { + 201: { description: 'Nouveau lien', name: 'InvitationResponse', schema: InvitationResponseSchema }, + 409: { description: 'Compte déjà activé' }, + }, + }, + { + operationId: 'updateUser', + method: 'patch', + path: '/users/{id}', + summary: 'Modifier une personne (rôle, équipes, activation du compte)', + tags: ['users'], + pathParams: ['id'], + request: { name: 'UserUpdate', schema: UserUpdateSchema }, + responses: { + 200: { description: 'Mise à jour', name: 'UserAdmin', schema: UserAdminSchema }, + 404: { description: 'Inconnue' }, + }, + }, { operationId: 'getHealth', method: 'get', diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 857743d..1672841 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,5 +1,8 @@ export * from './permissions'; +export * from './referentiel'; export * from './schemas/auth'; export * from './schemas/users'; +export * from './schemas/users-admin'; +export * from './schemas/referentiel'; export * from './schemas/health'; export * from './contract'; diff --git a/packages/shared/src/referentiel.ts b/packages/shared/src/referentiel.ts new file mode 100644 index 0000000..4664de2 --- /dev/null +++ b/packages/shared/src/referentiel.ts @@ -0,0 +1,29 @@ +/** Vocabulaires du référentiel (R1) — partagés API / web / seed. */ + +export const ASSET_STATUSES = [ + 'IN_SERVICE', + 'OUT_OF_SERVICE', + 'UNDER_MAINTENANCE', +] as const; +export type AssetStatus = (typeof ASSET_STATUSES)[number]; + +/** Libellés métier (charte §7 : vocabulaire du carnet). */ +export const ASSET_STATUS_LABELS: Record = { + IN_SERVICE: 'En service', + OUT_OF_SERVICE: 'À l’arrêt', + UNDER_MAINTENANCE: 'En maintenance', +}; + +export const CATEGORY_KINDS = ['EQUIPMENT', 'COMPONENT_TYPE'] as const; +export type CategoryKind = (typeof CATEGORY_KINDS)[number]; + +export const CATEGORY_KIND_LABELS: Record = { + EQUIPMENT: 'Catégorie d’équipement', + COMPONENT_TYPE: 'Type d’organe', +}; + +/** Hiérarchie d'emplacements : site → zone, jamais plus profond (invariant R1). */ +export const LOCATION_MAX_DEPTH = 2; + +/** Durée de validité d'un lien d'activation (invitation). */ +export const INVITATION_TTL_DAYS = 7; diff --git a/packages/shared/src/schemas/referentiel.ts b/packages/shared/src/schemas/referentiel.ts new file mode 100644 index 0000000..75fcf83 --- /dev/null +++ b/packages/shared/src/schemas/referentiel.ts @@ -0,0 +1,158 @@ +import { z } from 'zod'; +import { ASSET_STATUSES, CATEGORY_KINDS } from '../referentiel'; + +// ————— Catégories (référentiels administrables) ————— + +export const CategorySchema = z.object({ + id: z.uuid(), + kind: z.enum(CATEGORY_KINDS), + name: z.string(), + isActive: z.boolean(), + usageCount: z.number().int(), // appareils ou organes qui l'utilisent +}); +export type Category = z.infer; + +export const CategoriesResponseSchema = z.object({ + categories: z.array(CategorySchema), +}); +export type CategoriesResponse = z.infer; + +export const CategoryCreateSchema = z.object({ + kind: z.enum(CATEGORY_KINDS), + name: z.string().min(1).max(80), +}); +export type CategoryCreate = z.infer; + +export const CategoryUpdateSchema = z.object({ + name: z.string().min(1).max(80).optional(), + isActive: z.boolean().optional(), // désactivation — jamais de suppression si utilisé +}); +export type CategoryUpdate = z.infer; + +// ————— Emplacements (site → zone) ————— + +export const LocationSchema = z.object({ + id: z.uuid(), + name: z.string(), + parentId: z.uuid().nullable(), // null = site + address: z.string().nullable(), + city: z.string().nullable(), + guardianName: z.string().nullable(), + guardianPhone: z.string().nullable(), + latitude: z.number().nullable(), + longitude: z.number().nullable(), + assetCount: z.number().int(), // appareils rattachés (zones incluses pour un site) +}); +export type LocationDto = z.infer; + +export const LocationsResponseSchema = z.object({ + locations: z.array(LocationSchema), // liste plate — le web construit l'arbre +}); +export type LocationsResponse = z.infer; + +export const LocationCreateSchema = z.object({ + name: z.string().min(1).max(120), + parentId: z.uuid().optional(), + address: z.string().max(200).optional(), + city: z.string().max(80).optional(), + guardianName: z.string().max(120).optional(), + guardianPhone: z.string().max(40).optional(), + latitude: z.number().min(-90).max(90).optional(), + longitude: z.number().min(-180).max(180).optional(), +}); +export type LocationCreate = z.infer; + +export const LocationUpdateSchema = LocationCreateSchema.partial(); +export type LocationUpdate = z.infer; + +// ————— Appareils & organes ————— + +export const AssetComponentSchema = z.object({ + id: z.uuid(), + typeId: z.uuid(), + typeName: z.string(), + designation: z.string().nullable(), +}); +export type AssetComponentDto = z.infer; + +export const AssetSchema = z.object({ + id: z.uuid(), + reference: z.string(), + brand: z.string(), + model: z.string().nullable(), + serialNumber: z.string().nullable(), + commissionedAt: z.iso.datetime().nullable(), + loadKg: z.number().int().nullable(), + floors: z.number().int().nullable(), + status: z.enum(ASSET_STATUSES), + categoryId: z.uuid(), + categoryName: z.string(), + locationId: z.uuid(), + locationName: z.string(), + siteName: z.string(), // le site racine (= locationName si rattaché au site) + componentCount: z.number().int(), +}); +export type AssetDto = z.infer; + +export const AssetsResponseSchema = z.object({ assets: z.array(AssetSchema) }); +export type AssetsResponse = z.infer; + +export const AssetDetailSchema = AssetSchema.extend({ + components: z.array(AssetComponentSchema), +}); +export type AssetDetail = z.infer; + +export const AssetComponentCreateSchema = z.object({ + typeId: z.uuid(), // Category(kind=COMPONENT_TYPE) — vérifié service + designation: z.string().max(120).optional(), +}); +export type AssetComponentCreate = z.infer; + +export const AssetCreateSchema = z.object({ + reference: z.string().min(1).max(30), + brand: z.string().min(1).max(80), + model: z.string().max(80).optional(), + serialNumber: z.string().max(80).optional(), + commissionedAt: z.iso.datetime().optional(), + loadKg: z.number().int().positive().optional(), + floors: z.number().int().positive().optional(), + categoryId: z.uuid(), // Category(kind=EQUIPMENT) — vérifié service + locationId: z.uuid(), + components: z.array(AssetComponentCreateSchema).optional(), +}); +export type AssetCreate = z.infer; + +export const AssetUpdateSchema = AssetCreateSchema.omit({ components: true }) + .partial() + .extend({ status: z.enum(ASSET_STATUSES).optional() }); +export type AssetUpdate = z.infer; + +// ————— Équipes ————— + +export const TeamMemberSchema = z.object({ + id: z.uuid(), + displayName: z.string(), + roleName: z.string(), + initials: z.string(), +}); + +export const TeamSchema = z.object({ + id: z.uuid(), + name: z.string(), + description: z.string().nullable(), + members: z.array(TeamMemberSchema), +}); +export type Team = z.infer; + +export const TeamsResponseSchema = z.object({ teams: z.array(TeamSchema) }); +export type TeamsResponse = z.infer; + +export const TeamCreateSchema = z.object({ + name: z.string().min(1).max(80), + description: z.string().max(200).optional(), + memberIds: z.array(z.uuid()).optional(), +}); +export type TeamCreate = z.infer; + +export const TeamUpdateSchema = TeamCreateSchema.partial(); +export type TeamUpdate = z.infer; diff --git a/packages/shared/src/schemas/users-admin.ts b/packages/shared/src/schemas/users-admin.ts new file mode 100644 index 0000000..21f20fa --- /dev/null +++ b/packages/shared/src/schemas/users-admin.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; +import { ROLE_NAMES } from '../permissions'; +import { AuthResponseSchema } from './auth'; + +/** Statut dérivé : invité = pas de mot de passe + lien émis ; désactivé prime. */ +export const USER_STATUSES = ['active', 'invited', 'disabled'] as const; + +export const UserAdminSchema = z.object({ + id: z.uuid(), + email: z.email(), + displayName: z.string(), + phone: z.string().nullable(), + role: z.object({ id: z.uuid(), name: z.enum(ROLE_NAMES) }), + teams: z.array(z.object({ id: z.uuid(), name: z.string() })), + status: z.enum(USER_STATUSES), + isDemo: z.boolean(), +}); +export type UserAdmin = z.infer; + +export const UsersResponseSchema = z.object({ users: z.array(UserAdminSchema) }); +export type UsersResponse = z.infer; + +export const RolesResponseSchema = z.object({ + roles: z.array(z.object({ id: z.uuid(), name: z.enum(ROLE_NAMES) })), +}); +export type RolesResponse = z.infer; + +export const UserUpdateSchema = z.object({ + displayName: z.string().min(1).max(120).optional(), + phone: z.string().max(40).optional(), + roleId: z.uuid().optional(), + teamIds: z.array(z.uuid()).optional(), // remplace l'affectation + isActive: z.boolean().optional(), +}); +export type UserUpdate = z.infer; + +// ————— Invitation (maquettes R1 : jamais de mot de passe créé pour autrui) ————— + +export const InvitationCreateSchema = z.object({ + email: z.email(), + displayName: z.string().min(1).max(120), + roleId: z.uuid(), + teamIds: z.array(z.uuid()).optional(), + phone: z.string().max(40).optional(), +}); +export type InvitationCreate = z.infer; + +/** Le lien est construit par le web (`/activation?token=…`) — l'API ne + * connaît pas son origine publique. L'envoi d'email viendra plus tard ; + * en R1 l'admin copie le lien depuis l'interface. */ +export const InvitationResponseSchema = z.object({ + userId: z.uuid(), + activationToken: z.string(), + expiresAt: z.iso.datetime(), +}); +export type InvitationResponse = z.infer; + +export const ActivateRequestSchema = z.object({ + token: z.string().min(10), + password: z.string().min(8, '8 caractères minimum'), +}); +export type ActivateRequest = z.infer; + +/** L'activation connecte directement la personne (AuthResponse). */ +export const ActivateResponseSchema = AuthResponseSchema;