mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r1.1): socle backend du référentiel — modèle, contrat, API, seed, tests
- migration r1_referentiel : Category (EQUIPMENT/COMPONENT_TYPE), Location (site → zone, lat/lng + colonne PostGIS générée geography(Point,4326) + index GIST), Asset (statut d'équipement), AssetComponent (organe sans emplacement PAR CONSTRUCTION), Team, invitation sur User ; migration autosuffisante (CREATE EXTENSION IF NOT EXISTS postgis) - contrat : 21 nouvelles opérations (26 total), générateur OpenAPI étendu aux paramètres de chemin ; spec + client web régénérés dans ce commit - API : modules categories/locations/assets/teams + gestion des personnes (liste, rôles, invitation lien 7 j à usage unique, activation publique qui connecte directement, mise à jour rôle/équipes) — tout sous @RequirePermission ; invariants en service (profondeur 2, kinds, catégorie jamais supprimée) - seed : parc de la maquette validée (5 sites + 8 zones, 8 appareils, organes A1/B2, 9 catégories, 2 équipes) — idempotent - 36 tests verts (couverture 96 % stmts / 85 % branches) : recette site→zone→appareil→organes, matrice vivante, invitation→activation ; smoke test sur build de prod - CI : postgres → postgis/postgis:18-3.6 (la migration R1 l'exige) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -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 l’image 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
|
||||||
|
|||||||
@@ -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 site→zone + PostGIS générée, Asset, AssetComponent, Team, invitation User), 21 nouvelles opérations au contrat (26 total), 4 modules API + invitations/activation sous `@RequirePermission`, seed parc maquette (5 sites, 8 appareils, organes, 2 équipes), 36 tests (96 %/85 %), CI sur `postgis/postgis:18-3.6`.
|
||||||
|
- 🔄 **R1.2 — reprise ici** : `apps/web` — écrans Sites (+ carte Leaflet/OSM à intégrer), Fiche site, Ascenseurs, Nouvel ascenseur, Étiquette QR imprimable, Personnes & équipes (invitation + lien à copier), Catégories — fidèles à maquette-r1.html ; puis e2e Playwright du parcours de recette R1 et déploiement.
|
||||||
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
-- Migration autosuffisante : les extensions requises sont créées si absentes
|
||||||
|
-- (la CI et tout environnement neuf n’ont pas notre init.sql).
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "CategoryKind" AS ENUM ('EQUIPMENT', 'COMPONENT_TYPE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "AssetStatus" AS ENUM ('IN_SERVICE', 'OUT_OF_SERVICE', 'UNDER_MAINTENANCE');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "activationExpiresAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "activationToken" TEXT,
|
||||||
|
ADD COLUMN "phone" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Category" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"kind" "CategoryKind" NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
|
||||||
|
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Location" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"parentId" UUID,
|
||||||
|
"address" TEXT,
|
||||||
|
"city" TEXT,
|
||||||
|
"guardianName" TEXT,
|
||||||
|
"guardianPhone" TEXT,
|
||||||
|
"latitude" DOUBLE PRECISION,
|
||||||
|
"longitude" DOUBLE PRECISION,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Location_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Asset" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"reference" TEXT NOT NULL,
|
||||||
|
"brand" TEXT NOT NULL,
|
||||||
|
"model" TEXT,
|
||||||
|
"serialNumber" TEXT,
|
||||||
|
"commissionedAt" TIMESTAMP(3),
|
||||||
|
"loadKg" INTEGER,
|
||||||
|
"floors" INTEGER,
|
||||||
|
"status" "AssetStatus" NOT NULL DEFAULT 'IN_SERVICE',
|
||||||
|
"categoryId" UUID NOT NULL,
|
||||||
|
"locationId" UUID NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Asset_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AssetComponent" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"assetId" UUID NOT NULL,
|
||||||
|
"typeId" UUID NOT NULL,
|
||||||
|
"designation" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "AssetComponent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Team" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Team_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_TeamToUser" (
|
||||||
|
"A" UUID NOT NULL,
|
||||||
|
"B" UUID NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "_TeamToUser_AB_pkey" PRIMARY KEY ("A","B")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Category_kind_name_key" ON "Category"("kind", "name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Location_parentId_idx" ON "Location"("parentId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Asset_reference_key" ON "Asset"("reference");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Asset_locationId_idx" ON "Asset"("locationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Asset_categoryId_idx" ON "Asset"("categoryId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "AssetComponent_assetId_idx" ON "AssetComponent"("assetId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Team_name_key" ON "Team"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "_TeamToUser_B_index" ON "_TeamToUser"("B");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_activationToken_key" ON "User"("activationToken");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Location" ADD CONSTRAINT "Location_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AssetComponent" ADD CONSTRAINT "AssetComponent_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "Asset"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "AssetComponent" ADD CONSTRAINT "AssetComponent_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_TeamToUser" ADD CONSTRAINT "_TeamToUser_A_fkey" FOREIGN KEY ("A") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "_TeamToUser" ADD CONSTRAINT "_TeamToUser_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
-- ————— PostGIS (hors périmètre Prisma, décision modèle R1) —————
|
||||||
|
-- Position générée depuis latitude/longitude : rien à synchroniser côté app,
|
||||||
|
-- la colonne est prête pour les requêtes spatiales (affectation auto, backlog).
|
||||||
|
ALTER TABLE "Location"
|
||||||
|
ADD COLUMN "position" geography(Point, 4326)
|
||||||
|
GENERATED ALWAYS AS (ST_SetSRID(ST_MakePoint("longitude", "latitude"), 4326)::geography) STORED;
|
||||||
|
|
||||||
|
CREATE INDEX "Location_position_gix" ON "Location" USING GIST ("position");
|
||||||
@@ -39,12 +39,105 @@ model User {
|
|||||||
email String @unique
|
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[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 l’Océan Atlantique', city: 'Casablanca',
|
||||||
|
latitude: 33.5883, longitude: -7.6822,
|
||||||
|
zones: ['Galerie commerciale'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Clinique Yasmine', address: 'Rue Ibn Rochd', city: 'Casablanca',
|
||||||
|
guardianName: 'Rachid Mansouri',
|
||||||
|
latitude: 33.5731, longitude: -7.6316,
|
||||||
|
zones: ['Bloc A'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Marina Center', address: 'Av. des FAR', city: 'Mohammedia',
|
||||||
|
latitude: 33.7, longitude: -7.39,
|
||||||
|
zones: ['Hall B'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ASSETS: {
|
||||||
|
reference: string; brand: string; model: string; serialNumber?: string;
|
||||||
|
commissionedAt?: string; loadKg?: number; floors?: number;
|
||||||
|
category: string; site: string; zone: string;
|
||||||
|
status?: 'IN_SERVICE' | 'OUT_OF_SERVICE' | 'UNDER_MAINTENANCE';
|
||||||
|
components?: { type: string; designation?: string }[];
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
reference: 'A1', brand: 'Otis', model: 'Gen2 Premier', serialNumber: 'OT-2020-4521',
|
||||||
|
commissionedAt: '2020-03-15', loadKg: 630, floors: 8,
|
||||||
|
category: 'Ascenseur électrique', site: 'Résidence Al Manar', zone: 'Hall principal',
|
||||||
|
components: [
|
||||||
|
{ type: 'Portes cabine / palières', designation: 'Fermator 40/10' },
|
||||||
|
{ type: 'Treuil / machinerie', designation: 'Gen2 gearless' },
|
||||||
|
{ type: 'Parachute' },
|
||||||
|
{ type: 'Armoire de commande', designation: 'MCS 220' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'A2', brand: 'Otis', model: 'Gen2', loadKg: 630, floors: 12,
|
||||||
|
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Hall principal',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'B1', brand: 'Schindler', model: '3300', loadKg: 1000, floors: 24,
|
||||||
|
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Tour bureaux (étages 1-24)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'B2', brand: 'Schindler', model: '3300', loadKg: 1000, floors: 24,
|
||||||
|
category: 'Ascenseur électrique', site: 'Tour Atlas', zone: 'Tour bureaux (étages 1-24)',
|
||||||
|
status: 'OUT_OF_SERVICE',
|
||||||
|
components: [
|
||||||
|
{ type: 'Portes cabine / palières', designation: 'Sematic' },
|
||||||
|
{ type: 'Armoire de commande' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'M1', brand: 'Kone', model: 'TranSys', loadKg: 2000, floors: 3,
|
||||||
|
category: 'Monte-charge', site: 'Tour Atlas', zone: 'Parking sous-sol',
|
||||||
|
status: 'UNDER_MAINTENANCE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'C1', brand: 'Kone', model: 'MonoSpace 500', loadKg: 800, floors: 5,
|
||||||
|
category: 'Ascenseur électrique', site: 'Anfa Place', zone: 'Galerie commerciale',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'D1', brand: 'ThyssenKrupp', model: 'Evolution', loadKg: 1600, floors: 6,
|
||||||
|
category: 'Ascenseur électrique', site: 'Clinique Yasmine', zone: 'Bloc A',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reference: 'E2', brand: 'Otis', model: 'HydroFit', loadKg: 630, floors: 4,
|
||||||
|
category: 'Ascenseur hydraulique', site: 'Marina Center', zone: 'Hall B',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEAMS: { name: string; description: string; memberEmails: string[] }[] = [
|
||||||
|
{
|
||||||
|
name: 'Casablanca Centre',
|
||||||
|
description: 'Tour Atlas · Anfa Place · Clinique Yasmine',
|
||||||
|
memberEmails: ['technicien@demo.siop.ma', 'technicien-limite@demo.siop.ma'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Mohammedia',
|
||||||
|
description: 'Résidence Al Manar · Marina Center',
|
||||||
|
memberEmails: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function seedReferentiel(prisma: PrismaClient): Promise<void> {
|
||||||
|
const categoryIds = new Map<string, string>();
|
||||||
|
for (const [kind, names] of [
|
||||||
|
['EQUIPMENT', EQUIPMENT_CATEGORIES],
|
||||||
|
['COMPONENT_TYPE', COMPONENT_TYPES],
|
||||||
|
] as const) {
|
||||||
|
for (const name of names) {
|
||||||
|
const category = await prisma.category.upsert({
|
||||||
|
where: { kind_name: { kind, name } },
|
||||||
|
update: {},
|
||||||
|
create: { kind, name },
|
||||||
|
});
|
||||||
|
categoryIds.set(name, category.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pas d'unicité en base sur (parent, nom) : idempotence par findFirst.
|
||||||
|
const zoneIds = new Map<string, string>(); // « site / zone » → id
|
||||||
|
for (const site of SITES) {
|
||||||
|
const { zones, ...data } = site;
|
||||||
|
let root = await prisma.location.findFirst({
|
||||||
|
where: { name: site.name, parentId: null },
|
||||||
|
});
|
||||||
|
root ??= await prisma.location.create({ data });
|
||||||
|
for (const zoneName of zones) {
|
||||||
|
let zone = await prisma.location.findFirst({
|
||||||
|
where: { name: zoneName, parentId: root.id },
|
||||||
|
});
|
||||||
|
zone ??= await prisma.location.create({
|
||||||
|
data: { name: zoneName, parentId: root.id, city: site.city },
|
||||||
|
});
|
||||||
|
zoneIds.set(`${site.name} / ${zoneName}`, zone.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const asset of ASSETS) {
|
||||||
|
const created = await prisma.asset.upsert({
|
||||||
|
where: { reference: asset.reference },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
reference: asset.reference,
|
||||||
|
brand: asset.brand,
|
||||||
|
model: asset.model,
|
||||||
|
serialNumber: asset.serialNumber,
|
||||||
|
commissionedAt: asset.commissionedAt ? new Date(asset.commissionedAt) : undefined,
|
||||||
|
loadKg: asset.loadKg,
|
||||||
|
floors: asset.floors,
|
||||||
|
status: asset.status ?? 'IN_SERVICE',
|
||||||
|
categoryId: categoryIds.get(asset.category)!,
|
||||||
|
locationId: zoneIds.get(`${asset.site} / ${asset.zone}`)!,
|
||||||
|
},
|
||||||
|
include: { _count: { select: { components: true } } },
|
||||||
|
});
|
||||||
|
if (asset.components?.length && created._count.components === 0) {
|
||||||
|
await prisma.assetComponent.createMany({
|
||||||
|
data: asset.components.map((c) => ({
|
||||||
|
assetId: created.id,
|
||||||
|
typeId: categoryIds.get(c.type)!,
|
||||||
|
designation: c.designation,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const team of TEAMS) {
|
||||||
|
await prisma.team.upsert({
|
||||||
|
where: { name: team.name },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: team.name,
|
||||||
|
description: team.description,
|
||||||
|
members: { connect: team.memberEmails.map((email) => ({ email })) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* c8 ignore start — wrapper CLI */
|
/* c8 ignore start — wrapper CLI */
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|||||||
@@ -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] : []),
|
||||||
],
|
],
|
||||||
|
|||||||
73
apps/api/src/assets/assets.controller.ts
Normal file
73
apps/api/src/assets/assets.controller.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AssetComponentCreateSchema,
|
||||||
|
AssetCreateSchema,
|
||||||
|
AssetUpdateSchema,
|
||||||
|
type AssetComponentCreate,
|
||||||
|
type AssetCreate,
|
||||||
|
type AssetUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { AssetsService } from './assets.service';
|
||||||
|
|
||||||
|
@Controller('assets')
|
||||||
|
export class AssetsController {
|
||||||
|
constructor(private readonly assetsService: AssetsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('ASSETS', 'view')
|
||||||
|
list() {
|
||||||
|
return this.assetsService.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermission('ASSETS', 'view')
|
||||||
|
get(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.assetsService.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('ASSETS', 'create')
|
||||||
|
create(@Body(new ZodValidationPipe(AssetCreateSchema)) body: AssetCreate) {
|
||||||
|
return this.assetsService.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('ASSETS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(AssetUpdateSchema)) body: AssetUpdate,
|
||||||
|
) {
|
||||||
|
return this.assetsService.update(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/components')
|
||||||
|
@RequirePermission('ASSETS', 'edit')
|
||||||
|
addComponent(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(AssetComponentCreateSchema)) body: AssetComponentCreate,
|
||||||
|
) {
|
||||||
|
return this.assetsService.addComponent(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/components/:componentId')
|
||||||
|
@RequirePermission('ASSETS', 'edit')
|
||||||
|
@HttpCode(204)
|
||||||
|
removeComponent(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('componentId', ParseUUIDPipe) componentId: string,
|
||||||
|
) {
|
||||||
|
return this.assetsService.removeComponent(id, componentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/assets/assets.module.ts
Normal file
10
apps/api/src/assets/assets.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AssetsController } from './assets.controller';
|
||||||
|
import { AssetsService } from './assets.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AssetsController],
|
||||||
|
providers: [AssetsService],
|
||||||
|
exports: [AssetsService],
|
||||||
|
})
|
||||||
|
export class AssetsModule {}
|
||||||
183
apps/api/src/assets/assets.service.ts
Normal file
183
apps/api/src/assets/assets.service.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
AssetComponentCreate,
|
||||||
|
AssetComponentDto,
|
||||||
|
AssetCreate,
|
||||||
|
AssetDetail,
|
||||||
|
AssetDto,
|
||||||
|
AssetsResponse,
|
||||||
|
AssetUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const assetInclude = {
|
||||||
|
category: true,
|
||||||
|
location: { include: { parent: true } },
|
||||||
|
_count: { select: { components: true } },
|
||||||
|
} satisfies Prisma.AssetInclude;
|
||||||
|
|
||||||
|
type AssetRow = Prisma.AssetGetPayload<{ include: typeof assetInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AssetsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(): Promise<AssetsResponse> {
|
||||||
|
const rows = await this.prisma.asset.findMany({
|
||||||
|
include: assetInclude,
|
||||||
|
orderBy: { reference: 'asc' },
|
||||||
|
});
|
||||||
|
return { assets: rows.map((r) => this.toDto(r)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<AssetDetail> {
|
||||||
|
const row = await this.prisma.asset.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { ...assetInclude, components: { include: { type: true } } },
|
||||||
|
});
|
||||||
|
if (!row) throw new NotFoundException('Appareil inconnu');
|
||||||
|
return {
|
||||||
|
...this.toDto(row),
|
||||||
|
components: row.components.map((c) => this.toComponentDto(c)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: AssetCreate): Promise<AssetDetail> {
|
||||||
|
await this.assertEquipmentCategory(dto.categoryId);
|
||||||
|
await this.assertLocation(dto.locationId);
|
||||||
|
if (dto.components?.length) {
|
||||||
|
await this.assertComponentTypes(dto.components.map((c) => c.typeId));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.asset.create({
|
||||||
|
data: {
|
||||||
|
reference: dto.reference,
|
||||||
|
brand: dto.brand,
|
||||||
|
model: dto.model,
|
||||||
|
serialNumber: dto.serialNumber,
|
||||||
|
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
|
||||||
|
loadKg: dto.loadKg,
|
||||||
|
floors: dto.floors,
|
||||||
|
categoryId: dto.categoryId,
|
||||||
|
locationId: dto.locationId,
|
||||||
|
components: dto.components?.length
|
||||||
|
? { create: dto.components }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return this.get(created.id);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Cette référence est déjà utilisée');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: AssetUpdate): Promise<AssetDetail> {
|
||||||
|
if (dto.categoryId) await this.assertEquipmentCategory(dto.categoryId);
|
||||||
|
if (dto.locationId) await this.assertLocation(dto.locationId);
|
||||||
|
try {
|
||||||
|
await this.prisma.asset.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...dto,
|
||||||
|
commissionedAt: dto.commissionedAt ? new Date(dto.commissionedAt) : undefined,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Appareil inconnu');
|
||||||
|
}
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Cette référence est déjà utilisée');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return this.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addComponent(
|
||||||
|
assetId: string,
|
||||||
|
dto: AssetComponentCreate,
|
||||||
|
): Promise<AssetComponentDto> {
|
||||||
|
const asset = await this.prisma.asset.findUnique({ where: { id: assetId } });
|
||||||
|
if (!asset) throw new NotFoundException('Appareil inconnu');
|
||||||
|
await this.assertComponentTypes([dto.typeId]);
|
||||||
|
const created = await this.prisma.assetComponent.create({
|
||||||
|
data: { assetId, ...dto },
|
||||||
|
include: { type: true },
|
||||||
|
});
|
||||||
|
return this.toComponentDto(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeComponent(assetId: string, componentId: string): Promise<void> {
|
||||||
|
const { count } = await this.prisma.assetComponent.deleteMany({
|
||||||
|
where: { id: componentId, assetId },
|
||||||
|
});
|
||||||
|
if (count === 0) throw new NotFoundException('Organe inconnu');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertEquipmentCategory(categoryId: string): Promise<void> {
|
||||||
|
const category = await this.prisma.category.findUnique({ where: { id: categoryId } });
|
||||||
|
if (!category || category.kind !== 'EQUIPMENT' || !category.isActive) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'La catégorie choisie n’est pas une catégorie d’équipement active',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertLocation(locationId: string): Promise<void> {
|
||||||
|
const location = await this.prisma.location.findUnique({ where: { id: locationId } });
|
||||||
|
if (!location) throw new BadRequestException('Emplacement inconnu');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertComponentTypes(typeIds: string[]): Promise<void> {
|
||||||
|
const types = await this.prisma.category.findMany({
|
||||||
|
where: { id: { in: typeIds } },
|
||||||
|
});
|
||||||
|
const valid =
|
||||||
|
types.length === new Set(typeIds).size &&
|
||||||
|
types.every((t) => t.kind === 'COMPONENT_TYPE' && t.isActive);
|
||||||
|
if (!valid) {
|
||||||
|
throw new BadRequestException('Chaque organe doit avoir un type d’organe actif');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toComponentDto(c: { id: string; typeId: string; designation: string | null; type: { name: string } }): AssetComponentDto {
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
typeId: c.typeId,
|
||||||
|
typeName: c.type.name,
|
||||||
|
designation: c.designation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: AssetRow): AssetDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
reference: row.reference,
|
||||||
|
brand: row.brand,
|
||||||
|
model: row.model,
|
||||||
|
serialNumber: row.serialNumber,
|
||||||
|
commissionedAt: row.commissionedAt?.toISOString() ?? null,
|
||||||
|
loadKg: row.loadKg,
|
||||||
|
floors: row.floors,
|
||||||
|
status: row.status,
|
||||||
|
categoryId: row.categoryId,
|
||||||
|
categoryName: row.category.name,
|
||||||
|
locationId: row.locationId,
|
||||||
|
locationName: row.location.name,
|
||||||
|
siteName: row.location.parent?.name ?? row.location.name,
|
||||||
|
componentCount: row._count.components,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
|
import { 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 d’activation est invalide ou a expiré — demandez un nouveau lien',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const activated = await this.prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
passwordHash: await argon2.hash(password),
|
||||||
|
activationToken: null,
|
||||||
|
activationExpiresAt: null,
|
||||||
|
},
|
||||||
|
include: { role: true },
|
||||||
|
});
|
||||||
|
return this.issueToken(activated);
|
||||||
|
}
|
||||||
|
|
||||||
private async issueToken(user: UserWithRole): Promise<AuthResponse> {
|
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({
|
||||||
|
|||||||
44
apps/api/src/categories/categories.controller.ts
Normal file
44
apps/api/src/categories/categories.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
CategoryCreateSchema,
|
||||||
|
CategoryUpdateSchema,
|
||||||
|
type CategoryCreate,
|
||||||
|
type CategoryUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { CategoriesService } from './categories.service';
|
||||||
|
|
||||||
|
@Controller('categories')
|
||||||
|
export class CategoriesController {
|
||||||
|
constructor(private readonly categoriesService: CategoriesService) {}
|
||||||
|
|
||||||
|
/** Donnée de référence lue par les formulaires — authentification seule. */
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.categoriesService.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('SETTINGS', 'create')
|
||||||
|
create(@Body(new ZodValidationPipe(CategoryCreateSchema)) body: CategoryCreate) {
|
||||||
|
return this.categoriesService.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('SETTINGS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(CategoryUpdateSchema)) body: CategoryUpdate,
|
||||||
|
) {
|
||||||
|
return this.categoriesService.update(id, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/categories/categories.module.ts
Normal file
10
apps/api/src/categories/categories.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CategoriesController } from './categories.controller';
|
||||||
|
import { CategoriesService } from './categories.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CategoriesController],
|
||||||
|
providers: [CategoriesService],
|
||||||
|
exports: [CategoriesService],
|
||||||
|
})
|
||||||
|
export class CategoriesModule {}
|
||||||
75
apps/api/src/categories/categories.service.ts
Normal file
75
apps/api/src/categories/categories.service.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
CategoriesResponse,
|
||||||
|
Category,
|
||||||
|
CategoryCreate,
|
||||||
|
CategoryUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CategoriesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(): Promise<CategoriesResponse> {
|
||||||
|
const rows = await this.prisma.category.findMany({
|
||||||
|
include: { _count: { select: { assets: true, components: true } } },
|
||||||
|
orderBy: [{ kind: 'asc' }, { name: 'asc' }],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
categories: rows.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
kind: c.kind,
|
||||||
|
name: c.name,
|
||||||
|
isActive: c.isActive,
|
||||||
|
usageCount: c.kind === 'EQUIPMENT' ? c._count.assets : c._count.components,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CategoryCreate): Promise<Category> {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.category.create({ data: dto });
|
||||||
|
return { ...created, usageCount: 0 };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce nom existe déjà pour ce référentiel');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Renommage / (dés)activation — la suppression n'existe pas (invariant R1). */
|
||||||
|
async update(id: string, dto: CategoryUpdate): Promise<Category> {
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.category.update({
|
||||||
|
where: { id },
|
||||||
|
data: dto,
|
||||||
|
include: { _count: { select: { assets: true, components: true } } },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: updated.id,
|
||||||
|
kind: updated.kind,
|
||||||
|
name: updated.name,
|
||||||
|
isActive: updated.isActive,
|
||||||
|
usageCount:
|
||||||
|
updated.kind === 'EQUIPMENT'
|
||||||
|
? updated._count.assets
|
||||||
|
: updated._count.components,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Catégorie inconnue');
|
||||||
|
}
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce nom existe déjà pour ce référentiel');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/api/src/locations/locations.controller.ts
Normal file
44
apps/api/src/locations/locations.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
LocationCreateSchema,
|
||||||
|
LocationUpdateSchema,
|
||||||
|
type LocationCreate,
|
||||||
|
type LocationUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { LocationsService } from './locations.service';
|
||||||
|
|
||||||
|
@Controller('locations')
|
||||||
|
export class LocationsController {
|
||||||
|
constructor(private readonly locationsService: LocationsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('LOCATIONS', 'view')
|
||||||
|
list() {
|
||||||
|
return this.locationsService.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('LOCATIONS', 'create')
|
||||||
|
create(@Body(new ZodValidationPipe(LocationCreateSchema)) body: LocationCreate) {
|
||||||
|
return this.locationsService.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('LOCATIONS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(LocationUpdateSchema)) body: LocationUpdate,
|
||||||
|
) {
|
||||||
|
return this.locationsService.update(id, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/locations/locations.module.ts
Normal file
10
apps/api/src/locations/locations.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { LocationsController } from './locations.controller';
|
||||||
|
import { LocationsService } from './locations.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [LocationsController],
|
||||||
|
providers: [LocationsService],
|
||||||
|
exports: [LocationsService],
|
||||||
|
})
|
||||||
|
export class LocationsModule {}
|
||||||
113
apps/api/src/locations/locations.service.ts
Normal file
113
apps/api/src/locations/locations.service.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
LocationCreate,
|
||||||
|
LocationDto,
|
||||||
|
LocationsResponse,
|
||||||
|
LocationUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
type LocationRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parentId: string | null;
|
||||||
|
address: string | null;
|
||||||
|
city: string | null;
|
||||||
|
guardianName: string | null;
|
||||||
|
guardianPhone: string | null;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
_count: { assets: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocationsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** Liste plate ; l'assetCount d'un SITE inclut les appareils de ses zones. */
|
||||||
|
async list(): Promise<LocationsResponse> {
|
||||||
|
const rows: LocationRow[] = await this.prisma.location.findMany({
|
||||||
|
include: { _count: { select: { assets: true } } },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
const childAssets = new Map<string, number>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.parentId) {
|
||||||
|
childAssets.set(
|
||||||
|
row.parentId,
|
||||||
|
(childAssets.get(row.parentId) ?? 0) + row._count.assets,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
locations: rows.map((row) =>
|
||||||
|
this.toDto(row, row._count.assets + (childAssets.get(row.id) ?? 0)),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: LocationCreate): Promise<LocationDto> {
|
||||||
|
await this.assertDepth(dto.parentId);
|
||||||
|
const created = await this.prisma.location.create({
|
||||||
|
data: dto,
|
||||||
|
include: { _count: { select: { assets: true } } },
|
||||||
|
});
|
||||||
|
return this.toDto(created, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: LocationUpdate): Promise<LocationDto> {
|
||||||
|
const existing = await this.prisma.location.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { _count: { select: { children: true, assets: true } } },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException('Emplacement inconnu');
|
||||||
|
if (dto.parentId) {
|
||||||
|
if (dto.parentId === id) {
|
||||||
|
throw new BadRequestException('Un emplacement ne peut pas être son propre parent');
|
||||||
|
}
|
||||||
|
if (existing._count.children > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Ce site a des zones : il ne peut pas devenir une zone (hiérarchie limitée à 2 niveaux)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.assertDepth(dto.parentId);
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.location.update({
|
||||||
|
where: { id },
|
||||||
|
data: dto,
|
||||||
|
include: { _count: { select: { assets: true } } },
|
||||||
|
});
|
||||||
|
return this.toDto(updated, updated._count.assets);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invariant R1 : site → zone, jamais plus profond. */
|
||||||
|
private async assertDepth(parentId?: string): Promise<void> {
|
||||||
|
if (!parentId) return;
|
||||||
|
const parent = await this.prisma.location.findUnique({ where: { id: parentId } });
|
||||||
|
if (!parent) throw new BadRequestException('Emplacement parent inconnu');
|
||||||
|
if (parent.parentId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Hiérarchie limitée à 2 niveaux : une zone ne peut pas contenir d’emplacement',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: Omit<LocationRow, '_count'>, assetCount: number): LocationDto {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
parentId: row.parentId,
|
||||||
|
address: row.address,
|
||||||
|
city: row.city,
|
||||||
|
guardianName: row.guardianName,
|
||||||
|
guardianPhone: row.guardianPhone,
|
||||||
|
latitude: row.latitude,
|
||||||
|
longitude: row.longitude,
|
||||||
|
assetCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/api/src/teams/teams.controller.ts
Normal file
44
apps/api/src/teams/teams.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
TeamCreateSchema,
|
||||||
|
TeamUpdateSchema,
|
||||||
|
type TeamCreate,
|
||||||
|
type TeamUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { RequirePermission } from '../permissions/require-permission.decorator';
|
||||||
|
import { TeamsService } from './teams.service';
|
||||||
|
|
||||||
|
@Controller('teams')
|
||||||
|
export class TeamsController {
|
||||||
|
constructor(private readonly teamsService: TeamsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'view')
|
||||||
|
list() {
|
||||||
|
return this.teamsService.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'create')
|
||||||
|
create(@Body(new ZodValidationPipe(TeamCreateSchema)) body: TeamCreate) {
|
||||||
|
return this.teamsService.create(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@RequirePermission('PEOPLE_TEAMS', 'edit')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body(new ZodValidationPipe(TeamUpdateSchema)) body: TeamUpdate,
|
||||||
|
) {
|
||||||
|
return this.teamsService.update(id, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/teams/teams.module.ts
Normal file
10
apps/api/src/teams/teams.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TeamsController } from './teams.controller';
|
||||||
|
import { TeamsService } from './teams.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [TeamsController],
|
||||||
|
providers: [TeamsService],
|
||||||
|
exports: [TeamsService],
|
||||||
|
})
|
||||||
|
export class TeamsModule {}
|
||||||
92
apps/api/src/teams/teams.service.ts
Normal file
92
apps/api/src/teams/teams.service.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import type { Team, TeamCreate, TeamsResponse, TeamUpdate } from '@siop/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const teamInclude = {
|
||||||
|
members: { include: { role: true }, orderBy: { displayName: 'asc' } },
|
||||||
|
} satisfies Prisma.TeamInclude;
|
||||||
|
|
||||||
|
type TeamRow = Prisma.TeamGetPayload<{ include: typeof teamInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TeamsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(): Promise<TeamsResponse> {
|
||||||
|
const rows = await this.prisma.team.findMany({
|
||||||
|
include: teamInclude,
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
return { teams: rows.map((t) => this.toDto(t)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: TeamCreate): Promise<Team> {
|
||||||
|
try {
|
||||||
|
const created = await this.prisma.team.create({
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
members: dto.memberIds?.length
|
||||||
|
? { connect: dto.memberIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: teamInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(created);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce nom d’équipe existe déjà');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: TeamUpdate): Promise<Team> {
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.team.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
members: dto.memberIds
|
||||||
|
? { set: dto.memberIds.map((memberId) => ({ id: memberId })) }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: teamInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(updated);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Équipe inconnue');
|
||||||
|
}
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Ce nom d’équipe existe déjà');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: TeamRow): Team {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
members: row.members.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
displayName: m.displayName,
|
||||||
|
roleName: m.role.name,
|
||||||
|
initials: m.displayName
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0]!.toUpperCase())
|
||||||
|
.join(''),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,21 +1,41 @@
|
|||||||
import { Controller, Get, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
141
apps/api/src/users/users.service.ts
Normal file
141
apps/api/src/users/users.service.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
INVITATION_TTL_DAYS,
|
||||||
|
type InvitationCreate,
|
||||||
|
type InvitationResponse,
|
||||||
|
type RoleName,
|
||||||
|
type RolesResponse,
|
||||||
|
type UserAdmin,
|
||||||
|
type UsersResponse,
|
||||||
|
type UserUpdate,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const userInclude = {
|
||||||
|
role: true,
|
||||||
|
teams: { orderBy: { name: 'asc' } },
|
||||||
|
} satisfies Prisma.UserInclude;
|
||||||
|
|
||||||
|
type UserRow = Prisma.UserGetPayload<{ include: typeof userInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(): Promise<UsersResponse> {
|
||||||
|
const rows = await this.prisma.user.findMany({
|
||||||
|
include: userInclude,
|
||||||
|
orderBy: { displayName: 'asc' },
|
||||||
|
});
|
||||||
|
return { users: rows.map((u) => this.toDto(u)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async roles(): Promise<RolesResponse> {
|
||||||
|
const roles = await this.prisma.role.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
return { roles: roles.map((r) => ({ id: r.id, name: r.name as RoleName })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invitation : compte créé SANS mot de passe + lien d'activation 7 jours.
|
||||||
|
* L'envoi d'email viendra plus tard — le web affiche le lien à copier. */
|
||||||
|
async invite(dto: InvitationCreate): Promise<InvitationResponse> {
|
||||||
|
const role = await this.prisma.role.findUnique({ where: { id: dto.roleId } });
|
||||||
|
if (!role) throw new NotFoundException('Rôle inconnu');
|
||||||
|
try {
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: dto.email,
|
||||||
|
displayName: dto.displayName,
|
||||||
|
phone: dto.phone,
|
||||||
|
roleId: dto.roleId,
|
||||||
|
teams: dto.teamIds?.length
|
||||||
|
? { connect: dto.teamIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
|
...this.freshToken(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
activationToken: user.activationToken!,
|
||||||
|
expiresAt: user.activationExpiresAt!.toISOString(),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('Cet email a déjà un compte');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resendInvitation(userId: string): Promise<InvitationResponse> {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) throw new NotFoundException('Personne inconnue');
|
||||||
|
if (user.passwordHash) {
|
||||||
|
throw new ConflictException('Ce compte est déjà activé');
|
||||||
|
}
|
||||||
|
const updated = await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: this.freshToken(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
userId: updated.id,
|
||||||
|
activationToken: updated.activationToken!,
|
||||||
|
expiresAt: updated.activationExpiresAt!.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(userId: string, dto: UserUpdate): Promise<UserAdmin> {
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
displayName: dto.displayName,
|
||||||
|
phone: dto.phone,
|
||||||
|
roleId: dto.roleId,
|
||||||
|
isActive: dto.isActive,
|
||||||
|
teams: dto.teamIds
|
||||||
|
? { set: dto.teamIds.map((id) => ({ id })) }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: userInclude,
|
||||||
|
});
|
||||||
|
return this.toDto(updated);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') {
|
||||||
|
throw new NotFoundException('Personne inconnue');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private freshToken() {
|
||||||
|
return {
|
||||||
|
activationToken: randomBytes(32).toString('base64url'),
|
||||||
|
activationExpiresAt: new Date(
|
||||||
|
Date.now() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDto(row: UserRow): UserAdmin {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
email: row.email,
|
||||||
|
displayName: row.displayName,
|
||||||
|
phone: row.phone,
|
||||||
|
role: { id: row.role.id, name: row.role.name as RoleName },
|
||||||
|
teams: row.teams.map((t) => ({ id: t.id, name: t.name })),
|
||||||
|
status: !row.isActive
|
||||||
|
? 'disabled'
|
||||||
|
: row.passwordHash
|
||||||
|
? 'active'
|
||||||
|
: 'invited',
|
||||||
|
isDemo: row.isDemo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
221
apps/api/test/administration.e2e-spec.ts
Normal file
221
apps/api/test/administration.e2e-spec.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
/**
|
||||||
|
* E2E R1 — administration du référentiel : équipes, renommages, mises à jour
|
||||||
|
* et TOUS les refus (conflits 409, inconnus 404, invalides 400) — le contrat
|
||||||
|
* d'erreur fait partie du contrat.
|
||||||
|
*/
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { seed } from '../prisma/seed';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
const GHOST = '00000000-0000-4000-8000-000000000000';
|
||||||
|
|
||||||
|
describe('Administration du référentiel (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let admin: string;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const suffix = `adm-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await seed(prisma);
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const compte = body.accounts.find(
|
||||||
|
(a: { roleName: string }) => a.roleName === 'Administrateur',
|
||||||
|
);
|
||||||
|
admin = (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||||
|
.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.team.deleteMany({ where: { name: { contains: suffix } } });
|
||||||
|
await prisma.category.deleteMany({ where: { name: { contains: suffix } } });
|
||||||
|
await prisma.location.deleteMany({ where: { name: { contains: suffix } } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const auth = () => ({ Authorization: `Bearer ${admin}` });
|
||||||
|
|
||||||
|
it('équipes : création, doublon 409, membres, 404', async () => {
|
||||||
|
const { body: users } = await http().get('/users').set(auth());
|
||||||
|
const ahmed = users.users.find(
|
||||||
|
(u: { email: string }) => u.email === 'technicien@demo.siop.ma',
|
||||||
|
);
|
||||||
|
|
||||||
|
const team = await http()
|
||||||
|
.post('/teams')
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: `Équipe ${suffix}`, description: 'Zone de test' })
|
||||||
|
.expect(201);
|
||||||
|
expect(team.body.members).toHaveLength(0);
|
||||||
|
|
||||||
|
await http()
|
||||||
|
.post('/teams')
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: `Équipe ${suffix}` })
|
||||||
|
.expect(409);
|
||||||
|
|
||||||
|
const updated = await http()
|
||||||
|
.patch(`/teams/${team.body.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ memberIds: [ahmed.id], description: 'Zone mise à jour' })
|
||||||
|
.expect(200);
|
||||||
|
expect(updated.body.members.map((m: { id: string }) => m.id)).toEqual([ahmed.id]);
|
||||||
|
|
||||||
|
const { body: teams } = await http().get('/teams').set(auth()).expect(200);
|
||||||
|
expect(teams.teams.some((t: { id: string }) => t.id === team.body.id)).toBe(true);
|
||||||
|
|
||||||
|
await http().patch(`/teams/${GHOST}`).set(auth()).send({ name: 'X' }).expect(404);
|
||||||
|
await http()
|
||||||
|
.patch(`/teams/${team.body.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: 'Casablanca Centre' })
|
||||||
|
.expect(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catégories : création, doublon 409, renommage, 404', async () => {
|
||||||
|
const created = await http()
|
||||||
|
.post('/categories')
|
||||||
|
.set(auth())
|
||||||
|
.send({ kind: 'EQUIPMENT', name: `Catégorie ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post('/categories')
|
||||||
|
.set(auth())
|
||||||
|
.send({ kind: 'EQUIPMENT', name: `Catégorie ${suffix}` })
|
||||||
|
.expect(409);
|
||||||
|
const renamed = await http()
|
||||||
|
.patch(`/categories/${created.body.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: `Catégorie ${suffix} v2` })
|
||||||
|
.expect(200);
|
||||||
|
expect(renamed.body.name).toBe(`Catégorie ${suffix} v2`);
|
||||||
|
await http()
|
||||||
|
.patch(`/categories/${GHOST}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: 'X' })
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emplacements : renommage, parent inconnu, auto-parent, site avec zones, 404', async () => {
|
||||||
|
const site = await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: `Site ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
const zone = await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: `Zone ${suffix}`, parentId: site.body.id })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
await http()
|
||||||
|
.patch(`/locations/${zone.body.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ guardianName: 'Gardien Test', guardianPhone: '06 00 00 00 00' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set(auth())
|
||||||
|
.send({ name: `Orphelin ${suffix}`, parentId: GHOST })
|
||||||
|
.expect(400);
|
||||||
|
await http()
|
||||||
|
.patch(`/locations/${zone.body.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ parentId: zone.body.id })
|
||||||
|
.expect(400);
|
||||||
|
// Le site a une zone : il ne peut pas devenir zone lui-même
|
||||||
|
await http()
|
||||||
|
.patch(`/locations/${site.body.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ parentId: zone.body.id })
|
||||||
|
.expect(400);
|
||||||
|
await http().patch(`/locations/${GHOST}`).set(auth()).send({ name: 'X' }).expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appareils : statut, référence en conflit, emplacement inconnu, 404', async () => {
|
||||||
|
const { body } = await http().get('/assets').set(auth());
|
||||||
|
const a1 = body.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
const b1 = body.assets.find((a: { reference: string }) => a.reference === 'B1');
|
||||||
|
|
||||||
|
const updated = await http()
|
||||||
|
.patch(`/assets/${b1.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ status: 'UNDER_MAINTENANCE' })
|
||||||
|
.expect(200);
|
||||||
|
expect(updated.body.status).toBe('UNDER_MAINTENANCE');
|
||||||
|
await http()
|
||||||
|
.patch(`/assets/${b1.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ status: 'IN_SERVICE' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await http()
|
||||||
|
.patch(`/assets/${b1.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ reference: a1.reference })
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.patch(`/assets/${b1.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ locationId: GHOST })
|
||||||
|
.expect(400);
|
||||||
|
await http().get(`/assets/${GHOST}`).set(auth()).expect(404);
|
||||||
|
await http()
|
||||||
|
.patch(`/assets/${GHOST}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ brand: 'X' })
|
||||||
|
.expect(404);
|
||||||
|
await http()
|
||||||
|
.post(`/assets/${GHOST}/components`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ typeId: GHOST })
|
||||||
|
.expect(404);
|
||||||
|
await http()
|
||||||
|
.delete(`/assets/${b1.id}/components/${GHOST}`)
|
||||||
|
.set(auth())
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('personnes : mise à jour rôle/équipes, invitation sur rôle inconnu, 404', async () => {
|
||||||
|
const { body: users } = await http().get('/users').set(auth());
|
||||||
|
const youssef = users.users.find(
|
||||||
|
(u: { email: string }) => u.email === 'technicien-limite@demo.siop.ma',
|
||||||
|
);
|
||||||
|
const { body: teams } = await http().get('/teams').set(auth());
|
||||||
|
const mohammedia = teams.teams.find((t: { name: string }) => t.name === 'Mohammedia');
|
||||||
|
|
||||||
|
const updated = await http()
|
||||||
|
.patch(`/users/${youssef.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ phone: '06 12 34 56 78', teamIds: [mohammedia.id] })
|
||||||
|
.expect(200);
|
||||||
|
expect(updated.body.teams.map((t: { name: string }) => t.name)).toContain('Mohammedia');
|
||||||
|
// remise en l'état (seed idempotent : les équipes ne sont pas réécrites)
|
||||||
|
const casa = teams.teams.find((t: { name: string }) => t.name === 'Casablanca Centre');
|
||||||
|
await http()
|
||||||
|
.patch(`/users/${youssef.id}`)
|
||||||
|
.set(auth())
|
||||||
|
.send({ teamIds: [casa.id] })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await http().patch(`/users/${GHOST}`).set(auth()).send({ phone: '0' }).expect(404);
|
||||||
|
await http()
|
||||||
|
.post('/users/invitations')
|
||||||
|
.set(auth())
|
||||||
|
.send({ email: `x-${suffix}@spelev.ma`, displayName: 'X', roleId: GHOST })
|
||||||
|
.expect(404);
|
||||||
|
await http().post(`/users/${GHOST}/invitation`).set(auth()).expect(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
128
apps/api/test/invitation.e2e-spec.ts
Normal file
128
apps/api/test/invitation.e2e-spec.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* E2E R1 — invitation & activation : lien 7 jours, usage unique,
|
||||||
|
* aucun compte actif avant activation (décision maquettes R1).
|
||||||
|
*/
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { seed } from '../prisma/seed';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
describe('Invitation → activation (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let admin: string;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const email = `invite-${Date.now().toString(36)}@spelev.ma`;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await seed(prisma);
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const compte = body.accounts.find(
|
||||||
|
(a: { roleName: string }) => a.roleName === 'Administrateur',
|
||||||
|
);
|
||||||
|
admin = (await http().post('/auth/demo-login').send({ userId: compte.id })).body
|
||||||
|
.accessToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.user.deleteMany({ where: { email } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invite, empêche la connexion avant activation, active, connecte', async () => {
|
||||||
|
const { body: roles } = await http()
|
||||||
|
.get('/roles')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.expect(200);
|
||||||
|
const technicien = roles.roles.find(
|
||||||
|
(r: { name: string }) => r.name === 'Technicien',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Invitation → compte « invited », lien émis
|
||||||
|
const invitation = await http()
|
||||||
|
.post('/users/invitations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ email, displayName: 'Recrue Test', roleId: technicien.id })
|
||||||
|
.expect(201);
|
||||||
|
expect(invitation.body.activationToken).toBeTruthy();
|
||||||
|
|
||||||
|
const { body: users } = await http()
|
||||||
|
.get('/users')
|
||||||
|
.set('Authorization', `Bearer ${admin}`);
|
||||||
|
const invited = users.users.find((u: { email: string }) => u.email === email);
|
||||||
|
expect(invited.status).toBe('invited');
|
||||||
|
|
||||||
|
// 2. Pas de connexion possible avant activation
|
||||||
|
await http()
|
||||||
|
.post('/auth/login')
|
||||||
|
.send({ email, password: 'MotDePasse!123' })
|
||||||
|
.expect(401);
|
||||||
|
|
||||||
|
// 3. Renvoi du lien : l'ancien devient invalide (usage unique)
|
||||||
|
const renvoi = await http()
|
||||||
|
.post(`/users/${invitation.body.userId}/invitation`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post('/auth/activate')
|
||||||
|
.send({ token: invitation.body.activationToken, password: 'MotDePasse!123' })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
// 4. Activation → connecté directement, statut « active »
|
||||||
|
const activation = await http()
|
||||||
|
.post('/auth/activate')
|
||||||
|
.send({ token: renvoi.body.activationToken, password: 'MotDePasse!123' })
|
||||||
|
.expect(200);
|
||||||
|
expect(activation.body.user.role.name).toBe('Technicien');
|
||||||
|
await http()
|
||||||
|
.get('/users/me')
|
||||||
|
.set('Authorization', `Bearer ${activation.body.accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// 5. Le lien est consommé ; la connexion classique fonctionne désormais
|
||||||
|
await http()
|
||||||
|
.post('/auth/activate')
|
||||||
|
.send({ token: renvoi.body.activationToken, password: 'Autre!12345' })
|
||||||
|
.expect(400);
|
||||||
|
await http()
|
||||||
|
.post('/auth/login')
|
||||||
|
.send({ email, password: 'MotDePasse!123' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
// 6. Renvoyer un lien sur un compte activé → refus
|
||||||
|
await http()
|
||||||
|
.post(`/users/${invitation.body.userId}/invitation`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.expect(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse un email déjà connu (409) et un lien fantaisiste (400)', async () => {
|
||||||
|
const { body: roles } = await http()
|
||||||
|
.get('/roles')
|
||||||
|
.set('Authorization', `Bearer ${admin}`);
|
||||||
|
await http()
|
||||||
|
.post('/users/invitations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({
|
||||||
|
email: 'dispatcher@demo.siop.ma',
|
||||||
|
displayName: 'Doublon',
|
||||||
|
roleId: roles.roles[0].id,
|
||||||
|
})
|
||||||
|
.expect(409);
|
||||||
|
await http()
|
||||||
|
.post('/auth/activate')
|
||||||
|
.send({ token: 'jeton-inexistant-123', password: 'MotDePasse!123' })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
185
apps/api/test/referentiel.e2e-spec.ts
Normal file
185
apps/api/test/referentiel.e2e-spec.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* E2E R1 — référentiel : parcours de recette (site → zone → appareil → organes)
|
||||||
|
* + invariants (profondeur 2, types d'organes, matrice de permissions vivante).
|
||||||
|
*/
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { seed } from '../prisma/seed';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
describe('Référentiel (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let admin: string; // jetons
|
||||||
|
let technicien: string;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const http = () => request(app.getHttpServer());
|
||||||
|
const suffix = Date.now().toString(36); // données de test uniques et repérables
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await seed(prisma);
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
const { body } = await http().get('/auth/demo-accounts');
|
||||||
|
const login = async (roleName: string) => {
|
||||||
|
const compte = body.accounts.find((a: { roleName: string }) => a.roleName === roleName);
|
||||||
|
const res = await http().post('/auth/demo-login').send({ userId: compte.id });
|
||||||
|
return res.body.accessToken as string;
|
||||||
|
};
|
||||||
|
admin = await login('Administrateur');
|
||||||
|
technicien = await login('Technicien');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await prisma.asset.deleteMany({ where: { reference: { contains: suffix } } });
|
||||||
|
await prisma.location.deleteMany({ where: { name: { contains: suffix } } });
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parcours de recette : site → zone → appareil avec organes', async () => {
|
||||||
|
const site = await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({
|
||||||
|
name: `Site Recette ${suffix}`,
|
||||||
|
city: 'Casablanca',
|
||||||
|
latitude: 33.59,
|
||||||
|
longitude: -7.61,
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
expect(site.body.parentId).toBeNull();
|
||||||
|
|
||||||
|
const zone = await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ name: `Hall Recette ${suffix}`, parentId: site.body.id })
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
const { body: cats } = await http()
|
||||||
|
.get('/categories')
|
||||||
|
.set('Authorization', `Bearer ${admin}`);
|
||||||
|
const equipement = cats.categories.find(
|
||||||
|
(c: { kind: string }) => c.kind === 'EQUIPMENT',
|
||||||
|
);
|
||||||
|
const typeOrgane = cats.categories.find(
|
||||||
|
(c: { kind: string; isActive: boolean }) => c.kind === 'COMPONENT_TYPE' && c.isActive,
|
||||||
|
);
|
||||||
|
|
||||||
|
const asset = await http()
|
||||||
|
.post('/assets')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({
|
||||||
|
reference: `T-${suffix}`,
|
||||||
|
brand: 'Otis',
|
||||||
|
model: 'Gen2',
|
||||||
|
categoryId: equipement.id,
|
||||||
|
locationId: zone.body.id,
|
||||||
|
components: [{ typeId: typeOrgane.id, designation: 'Organe de test' }],
|
||||||
|
})
|
||||||
|
.expect(201);
|
||||||
|
expect(asset.body.components).toHaveLength(1);
|
||||||
|
expect(asset.body.siteName).toBe(`Site Recette ${suffix}`);
|
||||||
|
|
||||||
|
// Ajout puis retrait d'un organe
|
||||||
|
const organe = await http()
|
||||||
|
.post(`/assets/${asset.body.id}/components`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ typeId: typeOrgane.id })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.delete(`/assets/${asset.body.id}/components/${organe.body.id}`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.expect(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse une hiérarchie de profondeur 3 (site → zone → ?)', async () => {
|
||||||
|
const site = await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ name: `Site Profond ${suffix}` })
|
||||||
|
.expect(201);
|
||||||
|
const zone = await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ name: `Zone Profonde ${suffix}`, parentId: site.body.id })
|
||||||
|
.expect(201);
|
||||||
|
await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ name: `Sous-zone ${suffix}`, parentId: zone.body.id })
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse un appareil dont la catégorie n’est pas un équipement', async () => {
|
||||||
|
const { body: cats } = await http()
|
||||||
|
.get('/categories')
|
||||||
|
.set('Authorization', `Bearer ${admin}`);
|
||||||
|
const typeOrgane = cats.categories.find((c: { kind: string }) => c.kind === 'COMPONENT_TYPE');
|
||||||
|
const { body: locs } = await http()
|
||||||
|
.get('/locations')
|
||||||
|
.set('Authorization', `Bearer ${admin}`);
|
||||||
|
await http()
|
||||||
|
.post('/assets')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({
|
||||||
|
reference: `KO-${suffix}`,
|
||||||
|
brand: 'Test',
|
||||||
|
categoryId: typeOrgane.id, // kind COMPONENT_TYPE → refus
|
||||||
|
locationId: locs.locations[0].id,
|
||||||
|
})
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la matrice vit : un Technicien lit le parc mais ne crée rien', async () => {
|
||||||
|
await http().get('/assets').set('Authorization', `Bearer ${technicien}`).expect(200);
|
||||||
|
await http().get('/locations').set('Authorization', `Bearer ${technicien}`).expect(200);
|
||||||
|
await http()
|
||||||
|
.post('/locations')
|
||||||
|
.set('Authorization', `Bearer ${technicien}`)
|
||||||
|
.send({ name: `Interdit ${suffix}` })
|
||||||
|
.expect(403);
|
||||||
|
await http().get('/users').set('Authorization', `Bearer ${technicien}`).expect(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('le seed de la maquette est en place (A1 et ses organes, statuts)', async () => {
|
||||||
|
const { body } = await http()
|
||||||
|
.get('/assets')
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.expect(200);
|
||||||
|
const a1 = body.assets.find((a: { reference: string }) => a.reference === 'A1');
|
||||||
|
expect(a1.siteName).toBe('Résidence Al Manar');
|
||||||
|
expect(a1.componentCount).toBe(4);
|
||||||
|
const b2 = body.assets.find((a: { reference: string }) => a.reference === 'B2');
|
||||||
|
expect(b2.status).toBe('OUT_OF_SERVICE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catégorie utilisée : désactivable, jamais supprimable (la route n’existe pas)', async () => {
|
||||||
|
const { body: cats } = await http()
|
||||||
|
.get('/categories')
|
||||||
|
.set('Authorization', `Bearer ${admin}`);
|
||||||
|
const used = cats.categories.find((c: { usageCount: number }) => c.usageCount > 0);
|
||||||
|
await http()
|
||||||
|
.delete(`/categories/${used.id}`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.expect(404); // pas de DELETE dans le contrat
|
||||||
|
const off = await http()
|
||||||
|
.patch(`/categories/${used.id}`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ isActive: false })
|
||||||
|
.expect(200);
|
||||||
|
expect(off.body.isActive).toBe(false);
|
||||||
|
await http()
|
||||||
|
.patch(`/categories/${used.id}`)
|
||||||
|
.set('Authorization', `Bearer ${admin}`)
|
||||||
|
.send({ isActive: true })
|
||||||
|
.expect(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
1166
apps/web/src/api/schema.d.ts
vendored
1166
apps/web/src/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -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`.
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
2265
docs/openapi.json
2265
docs/openapi.json
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -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 d’organes)',
|
||||||
|
tags: ['categories'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Liste', name: 'CategoriesResponse', schema: CategoriesResponseSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'createCategory',
|
||||||
|
method: 'post',
|
||||||
|
path: '/categories',
|
||||||
|
summary: 'Ajouter une catégorie',
|
||||||
|
tags: ['categories'],
|
||||||
|
request: { name: 'CategoryCreate', schema: CategoryCreateSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Créée', name: 'Category', schema: CategorySchema },
|
||||||
|
409: { description: 'Nom déjà utilisé pour ce type' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'updateCategory',
|
||||||
|
method: 'patch',
|
||||||
|
path: '/categories/{id}',
|
||||||
|
summary: 'Renommer ou (dés)activer — jamais de suppression si utilisée',
|
||||||
|
tags: ['categories'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'CategoryUpdate', schema: CategoryUpdateSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Mise à jour', name: 'Category', schema: CategorySchema },
|
||||||
|
404: { description: 'Inconnue' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'listLocations',
|
||||||
|
method: 'get',
|
||||||
|
path: '/locations',
|
||||||
|
summary: 'Sites et zones (liste plate, le client construit l’arbre)',
|
||||||
|
tags: ['locations'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Liste', name: 'LocationsResponse', schema: LocationsResponseSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'createLocation',
|
||||||
|
method: 'post',
|
||||||
|
path: '/locations',
|
||||||
|
summary: 'Créer un site (sans parent) ou une zone (profondeur max 2)',
|
||||||
|
tags: ['locations'],
|
||||||
|
request: { name: 'LocationCreate', schema: LocationCreateSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Créé', name: 'Location', schema: LocationSchema },
|
||||||
|
400: { description: 'Hiérarchie trop profonde' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'updateLocation',
|
||||||
|
method: 'patch',
|
||||||
|
path: '/locations/{id}',
|
||||||
|
summary: 'Modifier un emplacement',
|
||||||
|
tags: ['locations'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'LocationUpdate', schema: LocationUpdateSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Mis à jour', name: 'Location', schema: LocationSchema },
|
||||||
|
404: { description: 'Inconnu' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'listAssets',
|
||||||
|
method: 'get',
|
||||||
|
path: '/assets',
|
||||||
|
summary: 'Inventaire des appareils',
|
||||||
|
tags: ['assets'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Liste', name: 'AssetsResponse', schema: AssetsResponseSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'getAsset',
|
||||||
|
method: 'get',
|
||||||
|
path: '/assets/{id}',
|
||||||
|
summary: 'Fiche appareil (identité + organes)',
|
||||||
|
tags: ['assets'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Fiche', name: 'AssetDetail', schema: AssetDetailSchema },
|
||||||
|
404: { description: 'Inconnu' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'createAsset',
|
||||||
|
method: 'post',
|
||||||
|
path: '/assets',
|
||||||
|
summary: 'Créer un appareil (avec ses organes) — le QR découle de la référence',
|
||||||
|
tags: ['assets'],
|
||||||
|
request: { name: 'AssetCreate', schema: AssetCreateSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Créé', name: 'AssetDetail', schema: AssetDetailSchema },
|
||||||
|
409: { description: 'Référence déjà utilisée' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'updateAsset',
|
||||||
|
method: 'patch',
|
||||||
|
path: '/assets/{id}',
|
||||||
|
summary: 'Modifier un appareil (dont son statut d’équipement)',
|
||||||
|
tags: ['assets'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'AssetUpdate', schema: AssetUpdateSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Mis à jour', name: 'AssetDetail', schema: AssetDetailSchema },
|
||||||
|
404: { description: 'Inconnu' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'addAssetComponent',
|
||||||
|
method: 'post',
|
||||||
|
path: '/assets/{id}/components',
|
||||||
|
summary: 'Ajouter un organe (jamais d’emplacement propre)',
|
||||||
|
tags: ['assets'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'AssetComponentCreate', schema: AssetComponentCreateSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Ajouté', name: 'AssetComponent', schema: AssetComponentSchema },
|
||||||
|
400: { description: 'Le type choisi n’est pas un type d’organe' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'removeAssetComponent',
|
||||||
|
method: 'delete',
|
||||||
|
path: '/assets/{id}/components/{componentId}',
|
||||||
|
summary: 'Retirer un organe',
|
||||||
|
tags: ['assets'],
|
||||||
|
pathParams: ['id', 'componentId'],
|
||||||
|
responses: {
|
||||||
|
204: { description: 'Retiré' },
|
||||||
|
404: { description: 'Inconnu' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'listTeams',
|
||||||
|
method: 'get',
|
||||||
|
path: '/teams',
|
||||||
|
summary: 'Équipes et leurs membres',
|
||||||
|
tags: ['teams'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Liste', name: 'TeamsResponse', schema: TeamsResponseSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'createTeam',
|
||||||
|
method: 'post',
|
||||||
|
path: '/teams',
|
||||||
|
summary: 'Créer une équipe',
|
||||||
|
tags: ['teams'],
|
||||||
|
request: { name: 'TeamCreate', schema: TeamCreateSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Créée', name: 'Team', schema: TeamSchema },
|
||||||
|
409: { description: 'Nom déjà utilisé' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'updateTeam',
|
||||||
|
method: 'patch',
|
||||||
|
path: '/teams/{id}',
|
||||||
|
summary: 'Modifier une équipe (nom, description, membres)',
|
||||||
|
tags: ['teams'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'TeamUpdate', schema: TeamUpdateSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Mise à jour', name: 'Team', schema: TeamSchema },
|
||||||
|
404: { description: 'Inconnue' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'listUsers',
|
||||||
|
method: 'get',
|
||||||
|
path: '/users',
|
||||||
|
summary: 'Personnes (statut dérivé : actif / invité / désactivé)',
|
||||||
|
tags: ['users'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Liste', name: 'UsersResponse', schema: UsersResponseSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'listRoles',
|
||||||
|
method: 'get',
|
||||||
|
path: '/roles',
|
||||||
|
summary: 'Les 7 rôles (pour l’invitation)',
|
||||||
|
tags: ['users'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Liste', name: 'RolesResponse', schema: RolesResponseSchema },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'inviteUser',
|
||||||
|
method: 'post',
|
||||||
|
path: '/users/invitations',
|
||||||
|
summary: 'Inviter — crée le compte inactif et émet le lien d’activation (7 j)',
|
||||||
|
tags: ['users'],
|
||||||
|
request: { name: 'InvitationCreate', schema: InvitationCreateSchema },
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Invitation émise', name: 'InvitationResponse', schema: InvitationResponseSchema },
|
||||||
|
409: { description: 'Email déjà utilisé' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'resendInvitation',
|
||||||
|
method: 'post',
|
||||||
|
path: '/users/{id}/invitation',
|
||||||
|
summary: 'Régénérer le lien d’activation d’un compte non activé',
|
||||||
|
tags: ['users'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
responses: {
|
||||||
|
201: { description: 'Nouveau lien', name: 'InvitationResponse', schema: InvitationResponseSchema },
|
||||||
|
409: { description: 'Compte déjà activé' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'updateUser',
|
||||||
|
method: 'patch',
|
||||||
|
path: '/users/{id}',
|
||||||
|
summary: 'Modifier une personne (rôle, équipes, activation du compte)',
|
||||||
|
tags: ['users'],
|
||||||
|
pathParams: ['id'],
|
||||||
|
request: { name: 'UserUpdate', schema: UserUpdateSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Mise à jour', name: 'UserAdmin', schema: UserAdminSchema },
|
||||||
|
404: { description: 'Inconnue' },
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
operationId: 'getHealth',
|
operationId: 'getHealth',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
29
packages/shared/src/referentiel.ts
Normal file
29
packages/shared/src/referentiel.ts
Normal 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: 'À l’arrêt',
|
||||||
|
UNDER_MAINTENANCE: 'En maintenance',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CATEGORY_KINDS = ['EQUIPMENT', 'COMPONENT_TYPE'] as const;
|
||||||
|
export type CategoryKind = (typeof CATEGORY_KINDS)[number];
|
||||||
|
|
||||||
|
export const CATEGORY_KIND_LABELS: Record<CategoryKind, string> = {
|
||||||
|
EQUIPMENT: 'Catégorie d’équipement',
|
||||||
|
COMPONENT_TYPE: 'Type d’organe',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hiérarchie d'emplacements : site → zone, jamais plus profond (invariant R1). */
|
||||||
|
export const LOCATION_MAX_DEPTH = 2;
|
||||||
|
|
||||||
|
/** Durée de validité d'un lien d'activation (invitation). */
|
||||||
|
export const INVITATION_TTL_DAYS = 7;
|
||||||
158
packages/shared/src/schemas/referentiel.ts
Normal file
158
packages/shared/src/schemas/referentiel.ts
Normal 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>;
|
||||||
65
packages/shared/src/schemas/users-admin.ts
Normal file
65
packages/shared/src/schemas/users-admin.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user