feat(r1.1): socle backend du référentiel — modèle, contrat, API, seed, tests

- migration r1_referentiel : Category (EQUIPMENT/COMPONENT_TYPE), Location
  (site → zone, lat/lng + colonne PostGIS générée geography(Point,4326)
  + index GIST), Asset (statut d'équipement), AssetComponent (organe sans
  emplacement PAR CONSTRUCTION), Team, invitation sur User ; migration
  autosuffisante (CREATE EXTENSION IF NOT EXISTS postgis)
- contrat : 21 nouvelles opérations (26 total), générateur OpenAPI étendu
  aux paramètres de chemin ; spec + client web régénérés dans ce commit
- API : modules categories/locations/assets/teams + gestion des personnes
  (liste, rôles, invitation lien 7 j à usage unique, activation publique
  qui connecte directement, mise à jour rôle/équipes) — tout sous
  @RequirePermission ; invariants en service (profondeur 2, kinds,
  catégorie jamais supprimée)
- seed : parc de la maquette validée (5 sites + 8 zones, 8 appareils,
  organes A1/B2, 9 catégories, 2 équipes) — idempotent
- 36 tests verts (couverture 96 % stmts / 85 % branches) : recette
  site→zone→appareil→organes, matrice vivante, invitation→activation ;
  smoke test sur build de prod
- CI : postgres → postgis/postgis:18-3.6 (la migration R1 l'exige)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-16 12:27:48 +01:00
parent 6d9aafdab4
commit 266ffaaf1b
36 changed files with 6016 additions and 13 deletions

View File

@@ -59,10 +59,10 @@ jobs:
name: api (tests + couverture ≥ 70 %) name: api (tests + couverture ≥ 70 %)
runs-on: ubuntu-latest runs-on: ubuntu-latest
services: services:
# R0 : PostgreSQL nu suffit (r0_identity). Dès que des tests exigeront # R1+ : la migration r1_referentiel exige PostGIS (colonne générée geography).
# pgvector/PostGIS (R1+), passer sur l'image infra/postgres. # pgvector arrivera en R5 (bascule alors sur limage infra/postgres).
postgres: postgres:
image: postgres:18 image: postgis/postgis:18-3.6
env: env:
POSTGRES_USER: siop POSTGRES_USER: siop
POSTGRES_PASSWORD: siop POSTGRES_PASSWORD: siop
@@ -115,7 +115,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services: services:
postgres: postgres:
image: postgres:18 image: postgis/postgis:18-3.6
env: env:
POSTGRES_USER: siop POSTGRES_USER: siop
POSTGRES_PASSWORD: siop POSTGRES_PASSWORD: siop

View File

@@ -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.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.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. - **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 sitezone + 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 R0R5. - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5.

View File

@@ -0,0 +1,145 @@
-- Migration autosuffisante : les extensions requises sont créées si absentes
-- (la CI et tout environnement neuf nont 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");

View File

@@ -39,12 +39,105 @@ model User {
email String @unique email String @unique
displayName String displayName String
passwordHash String? // null tant que le compte n'est pas activé (R1) passwordHash String? // null tant que le compte n'est pas activé (R1)
phone String?
roleId String @db.Uuid roleId String @db.Uuid
role Role @relation(fields: [roleId], references: [id]) role Role @relation(fields: [roleId], references: [id])
isActive Boolean @default(true) isActive Boolean @default(true)
isDemo Boolean @default(false) // seul un compte isDemo est empruntable (ADR-002) 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([roleId]) @@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[]
}

View File

@@ -131,6 +131,15 @@ export async function seed(prisma: PrismaClient): Promise<void> {
const passwordHash = await argon2.hash( const passwordHash = await argon2.hash(
process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026', process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026',
); );
await seedUsers(prisma, roleIds, passwordHash);
await seedReferentiel(prisma);
}
async function seedUsers(
prisma: PrismaClient,
roleIds: Map<RoleName, string>,
passwordHash: string,
): Promise<void> {
for (const u of DEMO_USERS) { for (const u of DEMO_USERS) {
// Les comptes démo appartiennent au seed : nom et rôle sont réalignés // 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). // à chaque exécution (jamais le mot de passe d'un compte existant).
@@ -148,6 +157,207 @@ export async function seed(prisma: PrismaClient): Promise<void> {
} }
} }
// ————— R1 — Référentiel (données des maquettes validées) —————
const EQUIPMENT_CATEGORIES = [
'Ascenseur électrique',
'Ascenseur hydraulique',
'Monte-charge',
'EPMR (plateforme PMR)',
];
const COMPONENT_TYPES = [
'Portes cabine / palières',
'Treuil / machinerie',
'Parachute',
'Armoire de commande',
'Boutons & signalisation',
];
/** site → zones ; positions réelles Casablanca/Mohammedia. */
const SITES: {
name: string;
address: string;
city: string;
guardianName?: string;
guardianPhone?: string;
latitude: number;
longitude: number;
zones: string[];
}[] = [
{
name: 'Tour Atlas', address: 'Bd de la Corniche, Aïn Diab', city: 'Casablanca',
guardianName: 'Karim Doukkali', guardianPhone: '06 61 23 45 67',
latitude: 33.6062, longitude: -7.6706,
zones: ['Hall principal', 'Tour bureaux (étages 1-24)', 'Parking sous-sol', 'Résidence (aile est)'],
},
{
name: 'Résidence Al Manar', address: 'Bd Hassan II', city: 'Mohammedia',
guardianName: 'Hassan Alami',
latitude: 33.6866, longitude: -7.383,
zones: ['Hall principal'],
},
{
name: 'Anfa Place', address: 'Bd de lOcéan Atlantique', city: 'Casablanca',
latitude: 33.5883, longitude: -7.6822,
zones: ['Galerie commerciale'],
},
{
name: 'Clinique Yasmine', address: 'Rue Ibn Rochd', city: 'Casablanca',
guardianName: 'Rachid Mansouri',
latitude: 33.5731, longitude: -7.6316,
zones: ['Bloc A'],
},
{
name: 'Marina Center', address: 'Av. des FAR', city: 'Mohammedia',
latitude: 33.7, longitude: -7.39,
zones: ['Hall B'],
},
];
const ASSETS: {
reference: string; brand: string; model: string; serialNumber?: string;
commissionedAt?: string; loadKg?: number; floors?: number;
category: string; site: string; zone: string;
status?: 'IN_SERVICE' | 'OUT_OF_SERVICE' | 'UNDER_MAINTENANCE';
components?: { type: string; designation?: string }[];
}[] = [
{
reference: 'A1', brand: 'Otis', model: 'Gen2 Premier', serialNumber: 'OT-2020-4521',
commissionedAt: '2020-03-15', loadKg: 630, floors: 8,
category: 'Ascenseur électrique', site: 'Résidence Al Manar', zone: 'Hall principal',
components: [
{ type: 'Portes cabine / palières', designation: 'Fermator 40/10' },
{ type: 'Treuil / machinerie', designation: 'Gen2 gearless' },
{ type: 'Parachute' },
{ type: 'Armoire de commande', designation: 'MCS 220' },
],
},
{
reference: 'A2', brand: 'Otis', model: 'Gen2', loadKg: 630, floors: 12,
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Hall principal',
},
{
reference: 'B1', brand: 'Schindler', model: '3300', loadKg: 1000, floors: 24,
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Tour bureaux (étages 1-24)',
},
{
reference: 'B2', brand: 'Schindler', model: '3300', loadKg: 1000, floors: 24,
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Tour bureaux (étages 1-24)',
status: 'OUT_OF_SERVICE',
components: [
{ type: 'Portes cabine / palières', designation: 'Sematic' },
{ type: 'Armoire de commande' },
],
},
{
reference: 'M1', brand: 'Kone', model: 'TranSys', loadKg: 2000, floors: 3,
category: 'Monte-charge', site: 'Tour Atlas', zone: 'Parking sous-sol',
status: 'UNDER_MAINTENANCE',
},
{
reference: 'C1', brand: 'Kone', model: 'MonoSpace 500', loadKg: 800, floors: 5,
category: 'Ascenseur électrique', site: 'Anfa Place', zone: 'Galerie commerciale',
},
{
reference: 'D1', brand: 'ThyssenKrupp', model: 'Evolution', loadKg: 1600, floors: 6,
category: 'Ascenseur électrique', site: 'Clinique Yasmine', zone: 'Bloc A',
},
{
reference: 'E2', brand: 'Otis', model: 'HydroFit', loadKg: 630, floors: 4,
category: 'Ascenseur hydraulique', site: 'Marina Center', zone: 'Hall B',
},
];
const TEAMS: { name: string; description: string; memberEmails: string[] }[] = [
{
name: 'Casablanca Centre',
description: 'Tour Atlas · Anfa Place · Clinique Yasmine',
memberEmails: ['technicien@demo.siop.ma', 'technicien-limite@demo.siop.ma'],
},
{
name: 'Mohammedia',
description: 'Résidence Al Manar · Marina Center',
memberEmails: [],
},
];
async function seedReferentiel(prisma: PrismaClient): Promise<void> {
const categoryIds = new Map<string, string>();
for (const [kind, names] of [
['EQUIPMENT', EQUIPMENT_CATEGORIES],
['COMPONENT_TYPE', COMPONENT_TYPES],
] as const) {
for (const name of names) {
const category = await prisma.category.upsert({
where: { kind_name: { kind, name } },
update: {},
create: { kind, name },
});
categoryIds.set(name, category.id);
}
}
// Pas d'unicité en base sur (parent, nom) : idempotence par findFirst.
const zoneIds = new Map<string, string>(); // « site / zone » → id
for (const site of SITES) {
const { zones, ...data } = site;
let root = await prisma.location.findFirst({
where: { name: site.name, parentId: null },
});
root ??= await prisma.location.create({ data });
for (const zoneName of zones) {
let zone = await prisma.location.findFirst({
where: { name: zoneName, parentId: root.id },
});
zone ??= await prisma.location.create({
data: { name: zoneName, parentId: root.id, city: site.city },
});
zoneIds.set(`${site.name} / ${zoneName}`, zone.id);
}
}
for (const asset of ASSETS) {
const created = await prisma.asset.upsert({
where: { reference: asset.reference },
update: {},
create: {
reference: asset.reference,
brand: asset.brand,
model: asset.model,
serialNumber: asset.serialNumber,
commissionedAt: asset.commissionedAt ? new Date(asset.commissionedAt) : undefined,
loadKg: asset.loadKg,
floors: asset.floors,
status: asset.status ?? 'IN_SERVICE',
categoryId: categoryIds.get(asset.category)!,
locationId: zoneIds.get(`${asset.site} / ${asset.zone}`)!,
},
include: { _count: { select: { components: true } } },
});
if (asset.components?.length && created._count.components === 0) {
await prisma.assetComponent.createMany({
data: asset.components.map((c) => ({
assetId: created.id,
typeId: categoryIds.get(c.type)!,
designation: c.designation,
})),
});
}
}
for (const team of TEAMS) {
await prisma.team.upsert({
where: { name: team.name },
update: {},
create: {
name: team.name,
description: team.description,
members: { connect: team.memberEmails.map((email) => ({ email })) },
},
});
}
}
/* c8 ignore start — wrapper CLI */ /* c8 ignore start — wrapper CLI */
if (require.main === module) { if (require.main === module) {
const prisma = new PrismaClient(); const prisma = new PrismaClient();

View File

@@ -1,11 +1,15 @@
import { DynamicModule, Module } from '@nestjs/common'; import { DynamicModule, Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { AssetsModule } from './assets/assets.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { DemoAuthModule } from './auth/demo/demo-auth.module'; import { DemoAuthModule } from './auth/demo/demo-auth.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { CategoriesModule } from './categories/categories.module';
import { demoModeEnabled } from './config/env'; import { demoModeEnabled } from './config/env';
import { FilesModule } from './files/files.module'; import { FilesModule } from './files/files.module';
import { HealthModule } from './health/health.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 { PermissionsGuard } from './permissions/permissions.guard';
import { PermissionsModule } from './permissions/permissions.module'; import { PermissionsModule } from './permissions/permissions.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
@@ -28,6 +32,11 @@ export class AppModule {
AuthModule, AuthModule,
UsersModule, UsersModule,
HealthModule, HealthModule,
// R1 — référentiel
CategoriesModule,
LocationsModule,
AssetsModule,
TeamsModule,
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404 // ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
...(demoModeEnabled() ? [DemoAuthModule] : []), ...(demoModeEnabled() ? [DemoAuthModule] : []),
], ],

View File

@@ -0,0 +1,73 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
AssetComponentCreateSchema,
AssetCreateSchema,
AssetUpdateSchema,
type AssetComponentCreate,
type AssetCreate,
type AssetUpdate,
} from '@siop/shared';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { AssetsService } from './assets.service';
@Controller('assets')
export class AssetsController {
constructor(private readonly assetsService: AssetsService) {}
@Get()
@RequirePermission('ASSETS', 'view')
list() {
return this.assetsService.list();
}
@Get(':id')
@RequirePermission('ASSETS', 'view')
get(@Param('id', ParseUUIDPipe) id: string) {
return this.assetsService.get(id);
}
@Post()
@RequirePermission('ASSETS', 'create')
create(@Body(new ZodValidationPipe(AssetCreateSchema)) body: AssetCreate) {
return this.assetsService.create(body);
}
@Patch(':id')
@RequirePermission('ASSETS', 'edit')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(AssetUpdateSchema)) body: AssetUpdate,
) {
return this.assetsService.update(id, body);
}
@Post(':id/components')
@RequirePermission('ASSETS', 'edit')
addComponent(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(AssetComponentCreateSchema)) body: AssetComponentCreate,
) {
return this.assetsService.addComponent(id, body);
}
@Delete(':id/components/:componentId')
@RequirePermission('ASSETS', 'edit')
@HttpCode(204)
removeComponent(
@Param('id', ParseUUIDPipe) id: string,
@Param('componentId', ParseUUIDPipe) componentId: string,
) {
return this.assetsService.removeComponent(id, componentId);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AssetsController } from './assets.controller';
import { AssetsService } from './assets.service';
@Module({
controllers: [AssetsController],
providers: [AssetsService],
exports: [AssetsService],
})
export class AssetsModule {}

View File

@@ -0,0 +1,183 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type {
AssetComponentCreate,
AssetComponentDto,
AssetCreate,
AssetDetail,
AssetDto,
AssetsResponse,
AssetUpdate,
} from '@siop/shared';
import { PrismaService } from '../prisma/prisma.service';
const assetInclude = {
category: true,
location: { include: { parent: true } },
_count: { select: { components: true } },
} satisfies Prisma.AssetInclude;
type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
@Injectable()
export class AssetsService {
constructor(private readonly prisma: PrismaService) {}
async list(): Promise<AssetsResponse> {
const rows = await this.prisma.asset.findMany({
include: assetInclude,
orderBy: { reference: 'asc' },
});
return { assets: rows.map((r) => this.toDto(r)) };
}
async get(id: string): Promise<AssetDetail> {
const row = await this.prisma.asset.findUnique({
where: { id },
include: { ...assetInclude, components: { include: { type: true } } },
});
if (!row) throw new NotFoundException('Appareil inconnu');
return {
...this.toDto(row),
components: row.components.map((c) => this.toComponentDto(c)),
};
}
async create(dto: AssetCreate): Promise<AssetDetail> {
await this.assertEquipmentCategory(dto.categoryId);
await this.assertLocation(dto.locationId);
if (dto.components?.length) {
await this.assertComponentTypes(dto.components.map((c) => c.typeId));
}
try {
const created = await this.prisma.asset.create({
data: {
reference: dto.reference,
brand: dto.brand,
model: dto.model,
serialNumber: dto.serialNumber,
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
loadKg: dto.loadKg,
floors: dto.floors,
categoryId: dto.categoryId,
locationId: dto.locationId,
components: dto.components?.length
? { create: dto.components }
: undefined,
},
select: { id: true },
});
return this.get(created.id);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Cette référence est déjà utilisée');
}
throw e;
}
}
async update(id: string, dto: AssetUpdate): Promise<AssetDetail> {
if (dto.categoryId) await this.assertEquipmentCategory(dto.categoryId);
if (dto.locationId) await this.assertLocation(dto.locationId);
try {
await this.prisma.asset.update({
where: { id },
data: {
...dto,
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
},
select: { id: true },
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
throw new NotFoundException('Appareil inconnu');
}
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Cette référence est déjà utilisée');
}
throw e;
}
return this.get(id);
}
async addComponent(
assetId: string,
dto: AssetComponentCreate,
): Promise<AssetComponentDto> {
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
if (!asset) throw new NotFoundException('Appareil inconnu');
await this.assertComponentTypes([dto.typeId]);
const created = await this.prisma.assetComponent.create({
data: { assetId, ...dto },
include: { type: true },
});
return this.toComponentDto(created);
}
async removeComponent(assetId: string, componentId: string): Promise<void> {
const { count } = await this.prisma.assetComponent.deleteMany({
where: { id: componentId, assetId },
});
if (count === 0) throw new NotFoundException('Organe inconnu');
}
private async assertEquipmentCategory(categoryId: string): Promise<void> {
const category = await this.prisma.category.findUnique({ where: { id: categoryId } });
if (!category || category.kind !== 'EQUIPMENT' || !category.isActive) {
throw new BadRequestException(
'La catégorie choisie nest pas une catégorie déquipement active',
);
}
}
private async assertLocation(locationId: string): Promise<void> {
const location = await this.prisma.location.findUnique({ where: { id: locationId } });
if (!location) throw new BadRequestException('Emplacement inconnu');
}
private async assertComponentTypes(typeIds: string[]): Promise<void> {
const types = await this.prisma.category.findMany({
where: { id: { in: typeIds } },
});
const valid =
types.length === new Set(typeIds).size &&
types.every((t) => t.kind === 'COMPONENT_TYPE' && t.isActive);
if (!valid) {
throw new BadRequestException('Chaque organe doit avoir un type dorgane 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,
};
}
}

View File

@@ -1,5 +1,10 @@
import { Body, Controller, HttpCode, Post } from '@nestjs/common'; 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 { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { Public } from './public.decorator'; import { Public } from './public.decorator';
@@ -14,4 +19,12 @@ export class AuthController {
login(@Body(new ZodValidationPipe(LoginRequestSchema)) body: LoginRequest) { login(@Body(new ZodValidationPipe(LoginRequestSchema)) body: LoginRequest) {
return this.authService.login(body.email, body.password); 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);
}
} }

View File

@@ -1,4 +1,5 @@
import { import {
BadRequestException,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -75,6 +76,35 @@ export class AuthService {
return this.issueToken(user); return this.issueToken(user);
} }
/** R1 — activation d'un compte invité : lien 7 jours, usage unique,
* choisit le mot de passe et connecte directement. */
async activate(token: string, password: string): Promise<AuthResponse> {
const user = await this.prisma.user.findUnique({
where: { activationToken: token },
include: { role: true },
});
if (
!user ||
!user.isActive ||
!user.activationExpiresAt ||
user.activationExpiresAt < new Date()
) {
throw new BadRequestException(
'Ce lien dactivation est invalide ou a expiré — demandez un nouveau lien',
);
}
const activated = await this.prisma.user.update({
where: { id: user.id },
data: {
passwordHash: await argon2.hash(password),
activationToken: null,
activationExpiresAt: null,
},
include: { role: true },
});
return this.issueToken(activated);
}
private async issueToken(user: UserWithRole): Promise<AuthResponse> { private async issueToken(user: UserWithRole): Promise<AuthResponse> {
// Identité seule — les droits restent en base (invariant R0) // Identité seule — les droits restent en base (invariant R0)
const accessToken = await this.jwtService.signAsync({ const accessToken = await this.jwtService.signAsync({

View File

@@ -0,0 +1,44 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
CategoryCreateSchema,
CategoryUpdateSchema,
type CategoryCreate,
type CategoryUpdate,
} from '@siop/shared';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { CategoriesService } from './categories.service';
@Controller('categories')
export class CategoriesController {
constructor(private readonly categoriesService: CategoriesService) {}
/** Donnée de référence lue par les formulaires — authentification seule. */
@Get()
list() {
return this.categoriesService.list();
}
@Post()
@RequirePermission('SETTINGS', 'create')
create(@Body(new ZodValidationPipe(CategoryCreateSchema)) body: CategoryCreate) {
return this.categoriesService.create(body);
}
@Patch(':id')
@RequirePermission('SETTINGS', 'edit')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(CategoryUpdateSchema)) body: CategoryUpdate,
) {
return this.categoriesService.update(id, body);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
@Module({
controllers: [CategoriesController],
providers: [CategoriesService],
exports: [CategoriesService],
})
export class CategoriesModule {}

View File

@@ -0,0 +1,75 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CategoriesResponse,
Category,
CategoryCreate,
CategoryUpdate,
} from '@siop/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class CategoriesService {
constructor(private readonly prisma: PrismaService) {}
async list(): Promise<CategoriesResponse> {
const rows = await this.prisma.category.findMany({
include: { _count: { select: { assets: true, components: true } } },
orderBy: [{ kind: 'asc' }, { name: 'asc' }],
});
return {
categories: rows.map((c) => ({
id: c.id,
kind: c.kind,
name: c.name,
isActive: c.isActive,
usageCount: c.kind === 'EQUIPMENT' ? c._count.assets : c._count.components,
})),
};
}
async create(dto: CategoryCreate): Promise<Category> {
try {
const created = await this.prisma.category.create({ data: dto });
return { ...created, usageCount: 0 };
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Ce nom existe déjà pour ce référentiel');
}
throw e;
}
}
/** Renommage / (dés)activation — la suppression n'existe pas (invariant R1). */
async update(id: string, dto: CategoryUpdate): Promise<Category> {
try {
const updated = await this.prisma.category.update({
where: { id },
data: dto,
include: { _count: { select: { assets: true, components: true } } },
});
return {
id: updated.id,
kind: updated.kind,
name: updated.name,
isActive: updated.isActive,
usageCount:
updated.kind === 'EQUIPMENT'
? updated._count.assets
: updated._count.components,
};
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
throw new NotFoundException('Catégorie inconnue');
}
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Ce nom existe déjà pour ce référentiel');
}
throw e;
}
}
}

View File

@@ -0,0 +1,44 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
LocationCreateSchema,
LocationUpdateSchema,
type LocationCreate,
type LocationUpdate,
} from '@siop/shared';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { LocationsService } from './locations.service';
@Controller('locations')
export class LocationsController {
constructor(private readonly locationsService: LocationsService) {}
@Get()
@RequirePermission('LOCATIONS', 'view')
list() {
return this.locationsService.list();
}
@Post()
@RequirePermission('LOCATIONS', 'create')
create(@Body(new ZodValidationPipe(LocationCreateSchema)) body: LocationCreate) {
return this.locationsService.create(body);
}
@Patch(':id')
@RequirePermission('LOCATIONS', 'edit')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(LocationUpdateSchema)) body: LocationUpdate,
) {
return this.locationsService.update(id, body);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { LocationsController } from './locations.controller';
import { LocationsService } from './locations.service';
@Module({
controllers: [LocationsController],
providers: [LocationsService],
exports: [LocationsService],
})
export class LocationsModule {}

View File

@@ -0,0 +1,113 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
LocationCreate,
LocationDto,
LocationsResponse,
LocationUpdate,
} from '@siop/shared';
import { PrismaService } from '../prisma/prisma.service';
type LocationRow = {
id: string;
name: string;
parentId: string | null;
address: string | null;
city: string | null;
guardianName: string | null;
guardianPhone: string | null;
latitude: number | null;
longitude: number | null;
_count: { assets: number };
};
@Injectable()
export class LocationsService {
constructor(private readonly prisma: PrismaService) {}
/** Liste plate ; l'assetCount d'un SITE inclut les appareils de ses zones. */
async list(): Promise<LocationsResponse> {
const rows: LocationRow[] = await this.prisma.location.findMany({
include: { _count: { select: { assets: true } } },
orderBy: { name: 'asc' },
});
const childAssets = new Map<string, number>();
for (const row of rows) {
if (row.parentId) {
childAssets.set(
row.parentId,
(childAssets.get(row.parentId) ?? 0) + row._count.assets,
);
}
}
return {
locations: rows.map((row) =>
this.toDto(row, row._count.assets + (childAssets.get(row.id) ?? 0)),
),
};
}
async create(dto: LocationCreate): Promise<LocationDto> {
await this.assertDepth(dto.parentId);
const created = await this.prisma.location.create({
data: dto,
include: { _count: { select: { assets: true } } },
});
return this.toDto(created, 0);
}
async update(id: string, dto: LocationUpdate): Promise<LocationDto> {
const existing = await this.prisma.location.findUnique({
where: { id },
include: { _count: { select: { children: true, assets: true } } },
});
if (!existing) throw new NotFoundException('Emplacement inconnu');
if (dto.parentId) {
if (dto.parentId === id) {
throw new BadRequestException('Un emplacement ne peut pas être son propre parent');
}
if (existing._count.children > 0) {
throw new BadRequestException(
'Ce site a des zones : il ne peut pas devenir une zone (hiérarchie limitée à 2 niveaux)',
);
}
await this.assertDepth(dto.parentId);
}
const updated = await this.prisma.location.update({
where: { id },
data: dto,
include: { _count: { select: { assets: true } } },
});
return this.toDto(updated, updated._count.assets);
}
/** Invariant R1 : site → zone, jamais plus profond. */
private async assertDepth(parentId?: string): Promise<void> {
if (!parentId) return;
const parent = await this.prisma.location.findUnique({ where: { id: parentId } });
if (!parent) throw new BadRequestException('Emplacement parent inconnu');
if (parent.parentId) {
throw new BadRequestException(
'Hiérarchie limitée à 2 niveaux : une zone ne peut pas contenir demplacement',
);
}
}
private toDto(row: Omit<LocationRow, '_count'>, assetCount: number): LocationDto {
return {
id: row.id,
name: row.name,
parentId: row.parentId,
address: row.address,
city: row.city,
guardianName: row.guardianName,
guardianPhone: row.guardianPhone,
latitude: row.latitude,
longitude: row.longitude,
assetCount,
};
}
}

View File

@@ -0,0 +1,44 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import {
TeamCreateSchema,
TeamUpdateSchema,
type TeamCreate,
type TeamUpdate,
} from '@siop/shared';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { TeamsService } from './teams.service';
@Controller('teams')
export class TeamsController {
constructor(private readonly teamsService: TeamsService) {}
@Get()
@RequirePermission('PEOPLE_TEAMS', 'view')
list() {
return this.teamsService.list();
}
@Post()
@RequirePermission('PEOPLE_TEAMS', 'create')
create(@Body(new ZodValidationPipe(TeamCreateSchema)) body: TeamCreate) {
return this.teamsService.create(body);
}
@Patch(':id')
@RequirePermission('PEOPLE_TEAMS', 'edit')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body(new ZodValidationPipe(TeamUpdateSchema)) body: TeamUpdate,
) {
return this.teamsService.update(id, body);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TeamsController } from './teams.controller';
import { TeamsService } from './teams.service';
@Module({
controllers: [TeamsController],
providers: [TeamsService],
exports: [TeamsService],
})
export class TeamsModule {}

View File

@@ -0,0 +1,92 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import type { Team, TeamCreate, TeamsResponse, TeamUpdate } from '@siop/shared';
import { PrismaService } from '../prisma/prisma.service';
const teamInclude = {
members: { include: { role: true }, orderBy: { displayName: 'asc' } },
} satisfies Prisma.TeamInclude;
type TeamRow = Prisma.TeamGetPayload<{ include: typeof teamInclude }>;
@Injectable()
export class TeamsService {
constructor(private readonly prisma: PrismaService) {}
async list(): Promise<TeamsResponse> {
const rows = await this.prisma.team.findMany({
include: teamInclude,
orderBy: { name: 'asc' },
});
return { teams: rows.map((t) => this.toDto(t)) };
}
async create(dto: TeamCreate): Promise<Team> {
try {
const created = await this.prisma.team.create({
data: {
name: dto.name,
description: dto.description,
members: dto.memberIds?.length
? { connect: dto.memberIds.map((id) => ({ id })) }
: undefined,
},
include: teamInclude,
});
return this.toDto(created);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Ce nom déquipe existe déjà');
}
throw e;
}
}
async update(id: string, dto: TeamUpdate): Promise<Team> {
try {
const updated = await this.prisma.team.update({
where: { id },
data: {
name: dto.name,
description: dto.description,
members: dto.memberIds
? { set: dto.memberIds.map((memberId) => ({ id: memberId })) }
: undefined,
},
include: teamInclude,
});
return this.toDto(updated);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
throw new NotFoundException('Équipe inconnue');
}
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Ce nom déquipe existe déjà');
}
throw e;
}
}
private toDto(row: TeamRow): Team {
return {
id: row.id,
name: row.name,
description: row.description,
members: row.members.map((m) => ({
id: m.id,
displayName: m.displayName,
roleName: m.role.name,
initials: m.displayName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0]!.toUpperCase())
.join(''),
})),
};
}
}

View File

@@ -1,21 +1,41 @@
import { Controller, Get, NotFoundException } from '@nestjs/common'; import {
import type { MeResponse, RoleName } from '@siop/shared'; 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 { import {
AuthenticatedUser, AuthenticatedUser,
CurrentUser, CurrentUser,
} from '../auth/current-user.decorator'; } from '../auth/current-user.decorator';
import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { PermissionsService } from '../permissions/permissions.service'; import { PermissionsService } from '../permissions/permissions.service';
import { RequirePermission } from '../permissions/require-permission.decorator';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { UsersService } from './users.service';
@Controller('users') @Controller()
export class UsersController { export class UsersController {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly permissionsService: PermissionsService, private readonly permissionsService: PermissionsService,
private readonly usersService: UsersService,
) {} ) {}
/** R0 : lecture du profil courant (gestion complète des utilisateurs en R1). */ /** Profil courant — pas de permission : chacun lit le sien. */
@Get('me') @Get('users/me')
async me(@CurrentUser() current: AuthenticatedUser): Promise<MeResponse> { async me(@CurrentUser() current: AuthenticatedUser): Promise<MeResponse> {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { id: current.userId }, where: { id: current.userId },
@@ -31,4 +51,37 @@ export class UsersController {
permissions: await this.permissionsService.getForRole(user.roleId), 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);
}
} }

View File

@@ -1,7 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { UsersController } from './users.controller'; import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({ @Module({
controllers: [UsersController], controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
}) })
export class UsersModule {} export class UsersModule {}

View File

@@ -0,0 +1,141 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import {
INVITATION_TTL_DAYS,
type InvitationCreate,
type InvitationResponse,
type RoleName,
type RolesResponse,
type UserAdmin,
type UsersResponse,
type UserUpdate,
} from '@siop/shared';
import { randomBytes } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
const userInclude = {
role: true,
teams: { orderBy: { name: 'asc' } },
} satisfies Prisma.UserInclude;
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
async list(): Promise<UsersResponse> {
const rows = await this.prisma.user.findMany({
include: userInclude,
orderBy: { displayName: 'asc' },
});
return { users: rows.map((u) => this.toDto(u)) };
}
async roles(): Promise<RolesResponse> {
const roles = await this.prisma.role.findMany({ orderBy: { name: 'asc' } });
return { roles: roles.map((r) => ({ id: r.id, name: r.name as RoleName })) };
}
/** Invitation : compte créé SANS mot de passe + lien d'activation 7 jours.
* L'envoi d'email viendra plus tard — le web affiche le lien à copier. */
async invite(dto: InvitationCreate): Promise<InvitationResponse> {
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
if (!role) throw new NotFoundException('Rôle inconnu');
try {
const user = await this.prisma.user.create({
data: {
email: dto.email,
displayName: dto.displayName,
phone: dto.phone,
roleId: dto.roleId,
teams: dto.teamIds?.length
? { connect: dto.teamIds.map((id) => ({ id })) }
: undefined,
...this.freshToken(),
},
});
return {
userId: user.id,
activationToken: user.activationToken!,
expiresAt: user.activationExpiresAt!.toISOString(),
};
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Cet email a déjà un compte');
}
throw e;
}
}
async resendInvitation(userId: string): Promise<InvitationResponse> {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new NotFoundException('Personne inconnue');
if (user.passwordHash) {
throw new ConflictException('Ce compte est déjà activé');
}
const updated = await this.prisma.user.update({
where: { id: userId },
data: this.freshToken(),
});
return {
userId: updated.id,
activationToken: updated.activationToken!,
expiresAt: updated.activationExpiresAt!.toISOString(),
};
}
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
try {
const updated = await this.prisma.user.update({
where: { id: userId },
data: {
displayName: dto.displayName,
phone: dto.phone,
roleId: dto.roleId,
isActive: dto.isActive,
teams: dto.teamIds
? { set: dto.teamIds.map((id) => ({ id })) }
: undefined,
},
include: userInclude,
});
return this.toDto(updated);
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
throw new NotFoundException('Personne inconnue');
}
throw e;
}
}
private freshToken() {
return {
activationToken: randomBytes(32).toString('base64url'),
activationExpiresAt: new Date(
Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000,
),
};
}
private toDto(row: UserRow): UserAdmin {
return {
id: row.id,
email: row.email,
displayName: row.displayName,
phone: row.phone,
role: { id: row.role.id, name: row.role.name as RoleName },
teams: row.teams.map((t) => ({ id: t.id, name: t.name })),
status: !row.isActive
? 'disabled'
: row.passwordHash
? 'active'
: 'invited',
isDemo: row.isDemo,
};
}
}

View File

@@ -0,0 +1,221 @@
/**
* E2E R1 — administration du référentiel : équipes, renommages, mises à jour
* et TOUS les refus (conflits 409, inconnus 404, invalides 400) — le contrat
* d'erreur fait partie du contrat.
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
const GHOST = '00000000-0000-4000-8000-000000000000';
describe('Administration du référentiel (e2e)', () => {
let app: INestApplication;
let admin: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const suffix = `adm-${Date.now().toString(36)}`;
beforeAll(async () => {
await seed(prisma);
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const compte = body.accounts.find(
(a: { roleName: string }) => a.roleName === 'Administrateur',
);
admin = (await http().post('/auth/demo-login').send({ userId: compte.id })).body
.accessToken;
});
afterAll(async () => {
await prisma.team.deleteMany({ where: { name: { contains: suffix } } });
await prisma.category.deleteMany({ where: { name: { contains: suffix } } });
await prisma.location.deleteMany({ where: { name: { contains: suffix } } });
await app?.close();
await prisma.$disconnect();
});
const auth = () => ({ Authorization: `Bearer ${admin}` });
it('équipes : création, doublon 409, membres, 404', async () => {
const { body: users } = await http().get('/users').set(auth());
const ahmed = users.users.find(
(u: { email: string }) => u.email === 'technicien@demo.siop.ma',
);
const team = await http()
.post('/teams')
.set(auth())
.send({ name: `Équipe ${suffix}`, description: 'Zone de test' })
.expect(201);
expect(team.body.members).toHaveLength(0);
await http()
.post('/teams')
.set(auth())
.send({ name: `Équipe ${suffix}` })
.expect(409);
const updated = await http()
.patch(`/teams/${team.body.id}`)
.set(auth())
.send({ memberIds: [ahmed.id], description: 'Zone mise à jour' })
.expect(200);
expect(updated.body.members.map((m: { id: string }) => m.id)).toEqual([ahmed.id]);
const { body: teams } = await http().get('/teams').set(auth()).expect(200);
expect(teams.teams.some((t: { id: string }) => t.id === team.body.id)).toBe(true);
await http().patch(`/teams/${GHOST}`).set(auth()).send({ name: 'X' }).expect(404);
await http()
.patch(`/teams/${team.body.id}`)
.set(auth())
.send({ name: 'Casablanca Centre' })
.expect(409);
});
it('catégories : création, doublon 409, renommage, 404', async () => {
const created = await http()
.post('/categories')
.set(auth())
.send({ kind: 'EQUIPMENT', name: `Catégorie ${suffix}` })
.expect(201);
await http()
.post('/categories')
.set(auth())
.send({ kind: 'EQUIPMENT', name: `Catégorie ${suffix}` })
.expect(409);
const renamed = await http()
.patch(`/categories/${created.body.id}`)
.set(auth())
.send({ name: `Catégorie ${suffix} v2` })
.expect(200);
expect(renamed.body.name).toBe(`Catégorie ${suffix} v2`);
await http()
.patch(`/categories/${GHOST}`)
.set(auth())
.send({ name: 'X' })
.expect(404);
});
it('emplacements : renommage, parent inconnu, auto-parent, site avec zones, 404', async () => {
const site = await http()
.post('/locations')
.set(auth())
.send({ name: `Site ${suffix}` })
.expect(201);
const zone = await http()
.post('/locations')
.set(auth())
.send({ name: `Zone ${suffix}`, parentId: site.body.id })
.expect(201);
await http()
.patch(`/locations/${zone.body.id}`)
.set(auth())
.send({ guardianName: 'Gardien Test', guardianPhone: '06 00 00 00 00' })
.expect(200);
await http()
.post('/locations')
.set(auth())
.send({ name: `Orphelin ${suffix}`, parentId: GHOST })
.expect(400);
await http()
.patch(`/locations/${zone.body.id}`)
.set(auth())
.send({ parentId: zone.body.id })
.expect(400);
// Le site a une zone : il ne peut pas devenir zone lui-même
await http()
.patch(`/locations/${site.body.id}`)
.set(auth())
.send({ parentId: zone.body.id })
.expect(400);
await http().patch(`/locations/${GHOST}`).set(auth()).send({ name: 'X' }).expect(404);
});
it('appareils : statut, référence en conflit, emplacement inconnu, 404', async () => {
const { body } = await http().get('/assets').set(auth());
const a1 = body.assets.find((a: { reference: string }) => a.reference === 'A1');
const b1 = body.assets.find((a: { reference: string }) => a.reference === 'B1');
const updated = await http()
.patch(`/assets/${b1.id}`)
.set(auth())
.send({ status: 'UNDER_MAINTENANCE' })
.expect(200);
expect(updated.body.status).toBe('UNDER_MAINTENANCE');
await http()
.patch(`/assets/${b1.id}`)
.set(auth())
.send({ status: 'IN_SERVICE' })
.expect(200);
await http()
.patch(`/assets/${b1.id}`)
.set(auth())
.send({ reference: a1.reference })
.expect(409);
await http()
.patch(`/assets/${b1.id}`)
.set(auth())
.send({ locationId: GHOST })
.expect(400);
await http().get(`/assets/${GHOST}`).set(auth()).expect(404);
await http()
.patch(`/assets/${GHOST}`)
.set(auth())
.send({ brand: 'X' })
.expect(404);
await http()
.post(`/assets/${GHOST}/components`)
.set(auth())
.send({ typeId: GHOST })
.expect(404);
await http()
.delete(`/assets/${b1.id}/components/${GHOST}`)
.set(auth())
.expect(404);
});
it('personnes : mise à jour rôle/équipes, invitation sur rôle inconnu, 404', async () => {
const { body: users } = await http().get('/users').set(auth());
const youssef = users.users.find(
(u: { email: string }) => u.email === 'technicien-limite@demo.siop.ma',
);
const { body: teams } = await http().get('/teams').set(auth());
const mohammedia = teams.teams.find((t: { name: string }) => t.name === 'Mohammedia');
const updated = await http()
.patch(`/users/${youssef.id}`)
.set(auth())
.send({ phone: '06 12 34 56 78', teamIds: [mohammedia.id] })
.expect(200);
expect(updated.body.teams.map((t: { name: string }) => t.name)).toContain('Mohammedia');
// remise en l'état (seed idempotent : les équipes ne sont pas réécrites)
const casa = teams.teams.find((t: { name: string }) => t.name === 'Casablanca Centre');
await http()
.patch(`/users/${youssef.id}`)
.set(auth())
.send({ teamIds: [casa.id] })
.expect(200);
await http().patch(`/users/${GHOST}`).set(auth()).send({ phone: '0' }).expect(404);
await http()
.post('/users/invitations')
.set(auth())
.send({ email: `x-${suffix}@spelev.ma`, displayName: 'X', roleId: GHOST })
.expect(404);
await http().post(`/users/${GHOST}/invitation`).set(auth()).expect(404);
});
});

View File

@@ -0,0 +1,128 @@
/**
* E2E R1 — invitation & activation : lien 7 jours, usage unique,
* aucun compte actif avant activation (décision maquettes R1).
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
describe('Invitation → activation (e2e)', () => {
let app: INestApplication;
let admin: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const email = `invite-${Date.now().toString(36)}@spelev.ma`;
beforeAll(async () => {
await seed(prisma);
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const compte = body.accounts.find(
(a: { roleName: string }) => a.roleName === 'Administrateur',
);
admin = (await http().post('/auth/demo-login').send({ userId: compte.id })).body
.accessToken;
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { email } });
await app?.close();
await prisma.$disconnect();
});
it('invite, empêche la connexion avant activation, active, connecte', async () => {
const { body: roles } = await http()
.get('/roles')
.set('Authorization', `Bearer ${admin}`)
.expect(200);
const technicien = roles.roles.find(
(r: { name: string }) => r.name === 'Technicien',
);
// 1. Invitation → compte « invited », lien émis
const invitation = await http()
.post('/users/invitations')
.set('Authorization', `Bearer ${admin}`)
.send({ email, displayName: 'Recrue Test', roleId: technicien.id })
.expect(201);
expect(invitation.body.activationToken).toBeTruthy();
const { body: users } = await http()
.get('/users')
.set('Authorization', `Bearer ${admin}`);
const invited = users.users.find((u: { email: string }) => u.email === email);
expect(invited.status).toBe('invited');
// 2. Pas de connexion possible avant activation
await http()
.post('/auth/login')
.send({ email, password: 'MotDePasse!123' })
.expect(401);
// 3. Renvoi du lien : l'ancien devient invalide (usage unique)
const renvoi = await http()
.post(`/users/${invitation.body.userId}/invitation`)
.set('Authorization', `Bearer ${admin}`)
.expect(201);
await http()
.post('/auth/activate')
.send({ token: invitation.body.activationToken, password: 'MotDePasse!123' })
.expect(400);
// 4. Activation → connecté directement, statut « active »
const activation = await http()
.post('/auth/activate')
.send({ token: renvoi.body.activationToken, password: 'MotDePasse!123' })
.expect(200);
expect(activation.body.user.role.name).toBe('Technicien');
await http()
.get('/users/me')
.set('Authorization', `Bearer ${activation.body.accessToken}`)
.expect(200);
// 5. Le lien est consommé ; la connexion classique fonctionne désormais
await http()
.post('/auth/activate')
.send({ token: renvoi.body.activationToken, password: 'Autre!12345' })
.expect(400);
await http()
.post('/auth/login')
.send({ email, password: 'MotDePasse!123' })
.expect(200);
// 6. Renvoyer un lien sur un compte activé → refus
await http()
.post(`/users/${invitation.body.userId}/invitation`)
.set('Authorization', `Bearer ${admin}`)
.expect(409);
});
it('refuse un email déjà connu (409) et un lien fantaisiste (400)', async () => {
const { body: roles } = await http()
.get('/roles')
.set('Authorization', `Bearer ${admin}`);
await http()
.post('/users/invitations')
.set('Authorization', `Bearer ${admin}`)
.send({
email: 'dispatcher@demo.siop.ma',
displayName: 'Doublon',
roleId: roles.roles[0].id,
})
.expect(409);
await http()
.post('/auth/activate')
.send({ token: 'jeton-inexistant-123', password: 'MotDePasse!123' })
.expect(400);
});
});

View File

@@ -0,0 +1,185 @@
/**
* E2E R1 — référentiel : parcours de recette (site → zone → appareil → organes)
* + invariants (profondeur 2, types d'organes, matrice de permissions vivante).
*/
process.env.DEMO_MODE = 'true';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { seed } from '../prisma/seed';
import { AppModule } from '../src/app.module';
describe('Référentiel (e2e)', () => {
let app: INestApplication;
let admin: string; // jetons
let technicien: string;
const prisma = new PrismaClient();
const http = () => request(app.getHttpServer());
const suffix = Date.now().toString(36); // données de test uniques et repérables
beforeAll(async () => {
await seed(prisma);
const moduleRef = await Test.createTestingModule({
imports: [AppModule.forRoot()],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
const { body } = await http().get('/auth/demo-accounts');
const login = async (roleName: string) => {
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
const res = await http().post('/auth/demo-login').send({ userId: compte.id });
return res.body.accessToken as string;
};
admin = await login('Administrateur');
technicien = await login('Technicien');
});
afterAll(async () => {
await prisma.asset.deleteMany({ where: { reference: { contains: suffix } } });
await prisma.location.deleteMany({ where: { name: { contains: suffix } } });
await app?.close();
await prisma.$disconnect();
});
it('parcours de recette : site → zone → appareil avec organes', async () => {
const site = await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({
name: `Site Recette ${suffix}`,
city: 'Casablanca',
latitude: 33.59,
longitude: -7.61,
})
.expect(201);
expect(site.body.parentId).toBeNull();
const zone = await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({ name: `Hall Recette ${suffix}`, parentId: site.body.id })
.expect(201);
const { body: cats } = await http()
.get('/categories')
.set('Authorization', `Bearer ${admin}`);
const equipement = cats.categories.find(
(c: { kind: string }) => c.kind === 'EQUIPMENT',
);
const typeOrgane = cats.categories.find(
(c: { kind: string; isActive: boolean }) => c.kind === 'COMPONENT_TYPE' && c.isActive,
);
const asset = await http()
.post('/assets')
.set('Authorization', `Bearer ${admin}`)
.send({
reference: `T-${suffix}`,
brand: 'Otis',
model: 'Gen2',
categoryId: equipement.id,
locationId: zone.body.id,
components: [{ typeId: typeOrgane.id, designation: 'Organe de test' }],
})
.expect(201);
expect(asset.body.components).toHaveLength(1);
expect(asset.body.siteName).toBe(`Site Recette ${suffix}`);
// Ajout puis retrait d'un organe
const organe = await http()
.post(`/assets/${asset.body.id}/components`)
.set('Authorization', `Bearer ${admin}`)
.send({ typeId: typeOrgane.id })
.expect(201);
await http()
.delete(`/assets/${asset.body.id}/components/${organe.body.id}`)
.set('Authorization', `Bearer ${admin}`)
.expect(204);
});
it('refuse une hiérarchie de profondeur 3 (site → zone → ?)', async () => {
const site = await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({ name: `Site Profond ${suffix}` })
.expect(201);
const zone = await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({ name: `Zone Profonde ${suffix}`, parentId: site.body.id })
.expect(201);
await http()
.post('/locations')
.set('Authorization', `Bearer ${admin}`)
.send({ name: `Sous-zone ${suffix}`, parentId: zone.body.id })
.expect(400);
});
it('refuse un appareil dont la catégorie nest 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 nexiste 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);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -50,10 +50,74 @@ model User {
| `demo-login` refuse tout compte `isDemo=false` | service auth (+ test e2e) | | `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) | | 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) ## À 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` - **R2** : `WorkOrder` (machine à états stricte, priorité « personne bloquée »), `Request`
(lien 1-1 vers OT), `InterventionReport` (bilan codé 6 champs → `ReferenceValue`), (lien 1-1 vers OT), `InterventionReport` (bilan codé 6 champs → `ReferenceValue`),
`TaskTemplate`/`PreventivePlan`/`ChecklistItem` (périodicité calendrier), `Meter`. `TaskTemplate`/`PreventivePlan`/`ChecklistItem` (périodicité calendrier), `Meter`.

View File

@@ -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 ## 2026-07-16 — Pr. Daaif (+ Claude) — R0 CLOSE (tag) · R1 ouverte : maquettes à valider
**Actions** **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. - 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. **⛔ 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).
--- ---

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,16 @@ for (const op of API_CONTRACT) {
operationId: op.operationId, operationId: op.operationId,
summary: op.summary, summary: op.summary,
tags: op.tags, 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.isPublic ? {} : { security: [{ bearerAuth: [] }] }),
...(op.demoOnly ...(op.demoOnly
? { ? {

View File

@@ -7,6 +7,36 @@ import {
} from './schemas/auth'; } from './schemas/auth';
import { MeResponseSchema } from './schemas/users'; import { MeResponseSchema } from './schemas/users';
import { HealthResponseSchema } from './schemas/health'; 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). * Contrat d'API R0 — source unique de vérité (règle d'or ADR-001).
@@ -23,6 +53,8 @@ export interface ApiOperation {
isPublic?: boolean; isPublic?: boolean;
/** ADR-002 : la route N'EXISTE PAS (404) si DEMO_MODE n'est pas actif. */ /** ADR-002 : la route N'EXISTE PAS (404) si DEMO_MODE n'est pas actif. */
demoOnly?: boolean; demoOnly?: boolean;
/** Paramètres de chemin (`{id}` dans path) — tous UUID en R1. */
pathParams?: string[];
request?: { name: string; schema: z.ZodType }; request?: { name: string; schema: z.ZodType };
responses: Record< responses: Record<
number, number,
@@ -86,6 +118,255 @@ export const API_CONTRACT: ApiOperation[] = [
401: { description: 'Non authentifié' }, 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 dorganes)',
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 larbre)',
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 demplacement propre)',
tags: ['assets'],
pathParams: ['id'],
request: { name: 'AssetComponentCreate', schema: AssetComponentCreateSchema },
responses: {
201: { description: 'Ajouté', name: 'AssetComponent', schema: AssetComponentSchema },
400: { description: 'Le type choisi nest pas un type dorgane' },
},
},
{
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 linvitation)',
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 dactivation (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 dactivation dun 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', operationId: 'getHealth',
method: 'get', method: 'get',

View File

@@ -1,5 +1,8 @@
export * from './permissions'; export * from './permissions';
export * from './referentiel';
export * from './schemas/auth'; export * from './schemas/auth';
export * from './schemas/users'; export * from './schemas/users';
export * from './schemas/users-admin';
export * from './schemas/referentiel';
export * from './schemas/health'; export * from './schemas/health';
export * from './contract'; export * from './contract';

View File

@@ -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<AssetStatus, string> = {
IN_SERVICE: 'En service',
OUT_OF_SERVICE: 'À larrê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<CategoryKind, string> = {
EQUIPMENT: 'Catégorie déquipement',
COMPONENT_TYPE: 'Type dorgane',
};
/** 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;

View File

@@ -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<typeof CategorySchema>;
export const CategoriesResponseSchema = z.object({
categories: z.array(CategorySchema),
});
export type CategoriesResponse = z.infer<typeof CategoriesResponseSchema>;
export const CategoryCreateSchema = z.object({
kind: z.enum(CATEGORY_KINDS),
name: z.string().min(1).max(80),
});
export type CategoryCreate = z.infer<typeof CategoryCreateSchema>;
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<typeof CategoryUpdateSchema>;
// ————— 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<typeof LocationSchema>;
export const LocationsResponseSchema = z.object({
locations: z.array(LocationSchema), // liste plate — le web construit l'arbre
});
export type LocationsResponse = z.infer<typeof LocationsResponseSchema>;
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<typeof LocationCreateSchema>;
export const LocationUpdateSchema = LocationCreateSchema.partial();
export type LocationUpdate = z.infer<typeof LocationUpdateSchema>;
// ————— 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<typeof AssetComponentSchema>;
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<typeof AssetSchema>;
export const AssetsResponseSchema = z.object({ assets: z.array(AssetSchema) });
export type AssetsResponse = z.infer<typeof AssetsResponseSchema>;
export const AssetDetailSchema = AssetSchema.extend({
components: z.array(AssetComponentSchema),
});
export type AssetDetail = z.infer<typeof AssetDetailSchema>;
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<typeof AssetComponentCreateSchema>;
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<typeof AssetCreateSchema>;
export const AssetUpdateSchema = AssetCreateSchema.omit({ components: true })
.partial()
.extend({ status: z.enum(ASSET_STATUSES).optional() });
export type AssetUpdate = z.infer<typeof AssetUpdateSchema>;
// ————— É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<typeof TeamSchema>;
export const TeamsResponseSchema = z.object({ teams: z.array(TeamSchema) });
export type TeamsResponse = z.infer<typeof TeamsResponseSchema>;
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<typeof TeamCreateSchema>;
export const TeamUpdateSchema = TeamCreateSchema.partial();
export type TeamUpdate = z.infer<typeof TeamUpdateSchema>;

View File

@@ -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<typeof UserAdminSchema>;
export const UsersResponseSchema = z.object({ users: z.array(UserAdminSchema) });
export type UsersResponse = z.infer<typeof UsersResponseSchema>;
export const RolesResponseSchema = z.object({
roles: z.array(z.object({ id: z.uuid(), name: z.enum(ROLE_NAMES) })),
});
export type RolesResponse = z.infer<typeof RolesResponseSchema>;
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<typeof UserUpdateSchema>;
// ————— 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<typeof InvitationCreateSchema>;
/** 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<typeof InvitationResponseSchema>;
export const ActivateRequestSchema = z.object({
token: z.string().min(10),
password: z.string().min(8, '8 caractères minimum'),
});
export type ActivateRequest = z.infer<typeof ActivateRequestSchema>;
/** L'activation connecte directement la personne (AuthResponse). */
export const ActivateResponseSchema = AuthResponseSchema;