mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r0.10): apps/api — auth fermée par défaut, matrice en base, démo-login ADR-002, seed
- packages/shared : rôles/catégories, schémas Zod, contrat d'API ; pnpm contract → docs/openapi.json committée (règle d'or ADR-001) - apps/api : NestJS 11 + Prisma 6, migration r0_identity (Role/Permission/User) ; guard JWT global + @Public() ; PermissionsGuard (@RequirePermission, matrice relue en base, cache 60 s) ; FileStorage (seul import MinIO) ; /health - démo-login ADR-002 : module conditionnel DEMO_MODE (404 sinon, testé e2e), double verrou production, refus des comptes isDemo=false - seed idempotent : 7 rôles, matrice complète (70 lignes), 7 comptes démo - 19 tests Jest (unit + e2e) ; smoke test sur build de prod Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,3 +2,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
.env
|
.env
|
||||||
|
.turbo/
|
||||||
|
coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|||||||
10
CLAUDE.md
10
CLAUDE.md
@@ -28,7 +28,13 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
|
|||||||
- Conformité loi 09-08 : géolocalisation limitée au service, purge des audios, consentements.
|
- Conformité loi 09-08 : géolocalisation limitée au service, purge des audios, consentements.
|
||||||
- IA : l'IA propose, un humain valide (sauf urgence « personne bloquée ») ; toute réponse RAG cite sa source.
|
- IA : l'IA propose, un humain valide (sauf urgence « personne bloquée ») ; toute réponse RAG cite sa source.
|
||||||
|
|
||||||
|
## Conventions d'infrastructure
|
||||||
|
|
||||||
|
- **Docker/Dokploy** : tous les services et conteneurs sont préfixés **`siop2-`** (demande du référent, leçon v1 : sur le réseau partagé Dokploy, un service nommé `postgres`/`api` collisionne avec les autres projets).
|
||||||
|
|
||||||
## État d'avancement
|
## État d'avancement
|
||||||
|
|
||||||
- 🔄 **R0 — Fondations** (en cours) : vision + cadrage + design (charte, tokens, maquettes HD) rédigés ; **en attente de validation des maquettes par le référent avant tout code applicatif**. Suivront : monorepo, CI, auth + permissions + démo-login, déploiement Dokploy « hello ».
|
- ✅ **R0 (1/2)** : playbook 00-vision, 01-cadrage, 02-design — **charte + tokens + maquettes HD VALIDÉES par le référent le 15/07/2026** (artefact : maquette-web.html ; 7 écrans, bi-thème). 03-architecture rédigé (ADR-001 stack, ADR-002 démo-login, C4, modèle R0). Racine monorepo posée (package.json/pnpm-workspace/turbo/.nvmrc) + `infra/` (compose PostgreSQL pgvector+PostGIS, Redis, MinIO — services préfixés `siop2-`).
|
||||||
- Dépôt GitHub : `siop-spelev/siop2` (privé). Jalons R0→R5 créés.
|
- ✅ **R0.10 `apps/api`** : NestJS + Prisma (migration `r0_identity`), guard JWT global fermé par défaut + `@Public()`, PermissionsGuard (matrice en base, cache 60 s), démo-login ADR-002 (module conditionnel, 404 sinon, double verrou prod), seed idempotent (7 rôles, 70 lignes de matrice, 7 comptes démo), `FileStorage`/health ; `packages/shared` (Zod) + `pnpm contract` → `docs/openapi.json` committée ; 19 tests Jest verts + smoke test build prod.
|
||||||
|
- 🔄 **R0 (2/2) — reprise ici** : R0.11 `apps/web` (login + sélecteur démo, coquille sidebar/topbar fidèle aux maquettes, page /design, client typé depuis `docs/openapi.json`) → R0.12 tests + CI (dont `ci-contract`) → R0.13 Dockerfiles + runbook Dokploy (le déploiement réel attend les accès au serveur du partenaire).
|
||||||
|
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5.
|
||||||
|
|||||||
20
apps/api/.env.example
Normal file
20
apps/api/.env.example
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Copier en .env pour le dev local (valeurs alignées sur infra/docker-compose.yml).
|
||||||
|
NODE_ENV=development
|
||||||
|
PORT=3000
|
||||||
|
DATABASE_URL=postgresql://siop:siop@localhost:5432/siop
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
|
# 16 caractères minimum — générer un vrai secret hors dev : openssl rand -hex 32
|
||||||
|
JWT_SECRET=dev-only-secret-0123456789abcdef
|
||||||
|
JWT_EXPIRES_IN=8h
|
||||||
|
|
||||||
|
# ADR-002 — sélecteur de compte démo. En production : ABSENT.
|
||||||
|
# (DEMO_MODE=true + NODE_ENV=production exige DEMO_MODE_I_KNOW=true.)
|
||||||
|
DEMO_MODE=true
|
||||||
|
# Mot de passe commun des comptes seedés (connexion classique) :
|
||||||
|
SEED_DEMO_PASSWORD=Demo!2026
|
||||||
|
|
||||||
|
MINIO_ENDPOINT=localhost
|
||||||
|
MINIO_PORT=9000
|
||||||
|
MINIO_ACCESS_KEY=siop
|
||||||
|
MINIO_SECRET_KEY=siop-minio
|
||||||
14
apps/api/jest.config.js
Normal file
14
apps/api/jest.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/** @type {import('jest').Config} */
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
rootDir: '.',
|
||||||
|
testMatch: ['<rootDir>/src/**/*.spec.ts', '<rootDir>/test/**/*.e2e-spec.ts'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
// Les tests consomment la source du contrat, pas le dist
|
||||||
|
'^@siop/shared$': '<rootDir>/../../packages/shared/src/index.ts',
|
||||||
|
},
|
||||||
|
setupFiles: ['<rootDir>/test/setup-env.ts'],
|
||||||
|
collectCoverageFrom: ['src/**/*.ts', '!src/main.ts'],
|
||||||
|
testTimeout: 30000,
|
||||||
|
};
|
||||||
9
apps/api/nest-cli.json
Normal file
9
apps/api/nest-cli.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsConfigPath": "tsconfig.build.json",
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
49
apps/api/package.json
Normal file
49
apps/api/package.json
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "@siop/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "SIOP V2 — API NestJS (auth fermée par défaut, matrice de permissions en base, démo-login ADR-002)",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"dev": "nest start --watch",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"test": "jest",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^11.1.0",
|
||||||
|
"@nestjs/core": "^11.1.0",
|
||||||
|
"@nestjs/jwt": "^11.0.0",
|
||||||
|
"@nestjs/platform-express": "^11.1.0",
|
||||||
|
"@prisma/client": "^6.8.0",
|
||||||
|
"@siop/shared": "workspace:*",
|
||||||
|
"argon2": "^0.43.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"ioredis": "^5.4.0",
|
||||||
|
"minio": "^8.0.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.1.0",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/node": "^24.0.0",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"prisma": "^6.8.0",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.3.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Role" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Role_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Permission" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"roleId" UUID NOT NULL,
|
||||||
|
"objectCategory" TEXT NOT NULL,
|
||||||
|
"canView" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"canViewOther" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"canCreate" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"canEdit" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"canDelete" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
|
CONSTRAINT "Permission_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"displayName" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT,
|
||||||
|
"roleId" UUID NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"isDemo" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Permission_roleId_objectCategory_key" ON "Permission"("roleId", "objectCategory");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "User_roleId_idx" ON "User"("roleId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Permission" ADD CONSTRAINT "Permission_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "User" ADD CONSTRAINT "User_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
3
apps/api/prisma/migrations/migration_lock.toml
Normal file
3
apps/api/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
47
apps/api/prisma/schema.prisma
Normal file
47
apps/api/prisma/schema.prisma
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Modèle R0 — identité & permissions (docs/03-architecture/modele-donnees.md).
|
||||||
|
// La matrice rôles × objets × droits vit EN BASE ; le JWT ne porte jamais de droits.
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Role {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
name String @unique // 7 rôles seedés — voir @siop/shared ROLE_NAMES
|
||||||
|
users User[]
|
||||||
|
permissions Permission[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Permission {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
roleId String @db.Uuid
|
||||||
|
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
|
||||||
|
objectCategory String // enum applicatif — voir @siop/shared OBJECT_CATEGORIES
|
||||||
|
canView Boolean @default(false)
|
||||||
|
canViewOther Boolean @default(false) // « voir autre » : au-delà de ses propres objets
|
||||||
|
canCreate Boolean @default(false)
|
||||||
|
canEdit Boolean @default(false)
|
||||||
|
canDelete Boolean @default(false)
|
||||||
|
|
||||||
|
@@unique([roleId, objectCategory])
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
email String @unique
|
||||||
|
displayName String
|
||||||
|
passwordHash String? // null tant que le compte n'est pas activé (R1)
|
||||||
|
roleId String @db.Uuid
|
||||||
|
role Role @relation(fields: [roleId], references: [id])
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
isDemo Boolean @default(false) // seul un compte isDemo est empruntable (ADR-002)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([roleId])
|
||||||
|
}
|
||||||
168
apps/api/prisma/seed.ts
Normal file
168
apps/api/prisma/seed.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/**
|
||||||
|
* Seed R0 — idempotent (upserts) : 7 rôles, matrice COMPLÈTE (une ligne par
|
||||||
|
* rôle × catégorie, invariant R0) et 7 comptes de démonstration (ADR-002).
|
||||||
|
* La matrice ci-dessous est le point de DÉPART pédagogique : elle vit en base
|
||||||
|
* et sera administrable (R1) — la modifier ici ne change pas une base déjà
|
||||||
|
* seedée (les lignes existantes ne sont pas écrasées, voir plus bas).
|
||||||
|
* Usage : pnpm seed (ou prisma db seed)
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
OBJECT_CATEGORIES,
|
||||||
|
ROLE_NAMES,
|
||||||
|
type ObjectCategory,
|
||||||
|
type RoleName,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
|
||||||
|
type Grant = Partial<{
|
||||||
|
view: boolean;
|
||||||
|
viewOther: boolean;
|
||||||
|
create: boolean;
|
||||||
|
edit: boolean;
|
||||||
|
delete: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const FULL: Grant = { view: true, viewOther: true, create: true, edit: true, delete: true };
|
||||||
|
const READ: Grant = { view: true, viewOther: true };
|
||||||
|
|
||||||
|
/** Matrice de départ (rôle → catégorie → droits ; absent = tout à false). */
|
||||||
|
const MATRIX: Record<RoleName, Partial<Record<ObjectCategory, Grant>>> = {
|
||||||
|
Administrateur: Object.fromEntries(
|
||||||
|
OBJECT_CATEGORIES.map((c) => [c, FULL]),
|
||||||
|
) as Record<ObjectCategory, Grant>,
|
||||||
|
Gestionnaire: {
|
||||||
|
WORK_ORDERS: FULL,
|
||||||
|
REQUESTS: FULL,
|
||||||
|
ASSETS: FULL,
|
||||||
|
LOCATIONS: FULL,
|
||||||
|
METERS: FULL,
|
||||||
|
PARTS: FULL,
|
||||||
|
PURCHASE_ORDERS: FULL,
|
||||||
|
PEOPLE_TEAMS: { view: true, viewOther: true, create: true, edit: true },
|
||||||
|
ANALYTICS: READ,
|
||||||
|
SETTINGS: { view: true },
|
||||||
|
},
|
||||||
|
Dispatcher: {
|
||||||
|
WORK_ORDERS: { view: true, viewOther: true, create: true, edit: true },
|
||||||
|
REQUESTS: { view: true, viewOther: true, create: true, edit: true },
|
||||||
|
ASSETS: READ,
|
||||||
|
LOCATIONS: READ,
|
||||||
|
METERS: READ,
|
||||||
|
PARTS: { view: true },
|
||||||
|
PEOPLE_TEAMS: READ,
|
||||||
|
ANALYTICS: READ,
|
||||||
|
},
|
||||||
|
Technicien: {
|
||||||
|
WORK_ORDERS: { view: true, edit: true }, // ses OT uniquement (viewOther=false)
|
||||||
|
REQUESTS: { view: true },
|
||||||
|
ASSETS: READ,
|
||||||
|
LOCATIONS: READ,
|
||||||
|
METERS: { view: true, viewOther: true, create: true }, // relevés
|
||||||
|
PARTS: { view: true },
|
||||||
|
},
|
||||||
|
'Technicien limité': {
|
||||||
|
WORK_ORDERS: { view: true, edit: true }, // ses OT, sans consultation du parc
|
||||||
|
ASSETS: { view: true },
|
||||||
|
},
|
||||||
|
Demandeur: {
|
||||||
|
REQUESTS: { view: true, create: true }, // ses demandes uniquement
|
||||||
|
},
|
||||||
|
'Vue seule': {
|
||||||
|
WORK_ORDERS: READ,
|
||||||
|
REQUESTS: READ,
|
||||||
|
ASSETS: READ,
|
||||||
|
LOCATIONS: READ,
|
||||||
|
METERS: READ,
|
||||||
|
PARTS: READ,
|
||||||
|
PURCHASE_ORDERS: READ,
|
||||||
|
PEOPLE_TEAMS: READ,
|
||||||
|
ANALYTICS: READ,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 7 comptes démo — un par rôle (personas des maquettes 02-design). */
|
||||||
|
const DEMO_USERS: { email: string; displayName: string; role: RoleName }[] = [
|
||||||
|
{ email: 'admin@demo.siop.ma', displayName: 'Amina Benali', role: 'Administrateur' },
|
||||||
|
{ email: 'dispatcher@demo.siop.ma', displayName: 'Salma Radi', role: 'Dispatcher' },
|
||||||
|
{ email: 'technicien@demo.siop.ma', displayName: 'Ahmed Meskini', role: 'Technicien' },
|
||||||
|
{ email: 'technicien-limite@demo.siop.ma', displayName: 'Yassine Bouzid', role: 'Technicien limité' },
|
||||||
|
{ email: 'gestionnaire@demo.siop.ma', displayName: 'Nadia Cherkaoui', role: 'Gestionnaire' },
|
||||||
|
{ email: 'demandeur@demo.siop.ma', displayName: 'Karim El Fassi', role: 'Demandeur' },
|
||||||
|
{ email: 'vue-seule@demo.siop.ma', displayName: 'Omar Senhaji', role: 'Vue seule' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function seed(prisma: PrismaClient): Promise<void> {
|
||||||
|
const roleIds = new Map<RoleName, string>();
|
||||||
|
for (const name of ROLE_NAMES) {
|
||||||
|
const role = await prisma.role.upsert({
|
||||||
|
where: { name },
|
||||||
|
update: {},
|
||||||
|
create: { name },
|
||||||
|
});
|
||||||
|
roleIds.set(name, role.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matrice complète : une ligne par rôle × catégorie. Les lignes existantes
|
||||||
|
// ne sont PAS écrasées (la base est la source de vérité, pas ce fichier).
|
||||||
|
for (const name of ROLE_NAMES) {
|
||||||
|
const roleId = roleIds.get(name)!;
|
||||||
|
for (const category of OBJECT_CATEGORIES) {
|
||||||
|
const g = MATRIX[name][category] ?? {};
|
||||||
|
await prisma.permission.upsert({
|
||||||
|
where: { roleId_objectCategory: { roleId, objectCategory: category } },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
roleId,
|
||||||
|
objectCategory: category,
|
||||||
|
canView: g.view ?? false,
|
||||||
|
canViewOther: g.viewOther ?? false,
|
||||||
|
canCreate: g.create ?? false,
|
||||||
|
canEdit: g.edit ?? false,
|
||||||
|
canDelete: g.delete ?? false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mot de passe commun des comptes démo (la connexion classique reste testable).
|
||||||
|
const passwordHash = await argon2.hash(
|
||||||
|
process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026',
|
||||||
|
);
|
||||||
|
for (const u of DEMO_USERS) {
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: u.email },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: u.email,
|
||||||
|
displayName: u.displayName,
|
||||||
|
passwordHash,
|
||||||
|
roleId: roleIds.get(u.role)!,
|
||||||
|
isDemo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* c8 ignore start — wrapper CLI */
|
||||||
|
if (require.main === module) {
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
seed(prisma)
|
||||||
|
.then(async () => {
|
||||||
|
const [roles, permissions, users] = await Promise.all([
|
||||||
|
prisma.role.count(),
|
||||||
|
prisma.permission.count(),
|
||||||
|
prisma.user.count({ where: { isDemo: true } }),
|
||||||
|
]);
|
||||||
|
console.log(
|
||||||
|
`Seed OK — ${roles} rôles, ${permissions} lignes de matrice, ${users} comptes démo.`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
41
apps/api/src/app.module.ts
Normal file
41
apps/api/src/app.module.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { DynamicModule, Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { DemoAuthModule } from './auth/demo/demo-auth.module';
|
||||||
|
import { JwtAuthGuard } from './auth/jwt-auth.guard';
|
||||||
|
import { demoModeEnabled } from './config/env';
|
||||||
|
import { FilesModule } from './files/files.module';
|
||||||
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { PermissionsGuard } from './permissions/permissions.guard';
|
||||||
|
import { PermissionsModule } from './permissions/permissions.module';
|
||||||
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
|
import { UsersModule } from './users/users.module';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composé via forRoot() (et non statiquement) pour que l'enregistrement
|
||||||
|
* conditionnel de DemoAuthModule (ADR-002) soit décidé au démarrage —
|
||||||
|
* et testable dans les deux états (module présent / routes 404).
|
||||||
|
*/
|
||||||
|
@Module({})
|
||||||
|
export class AppModule {
|
||||||
|
static forRoot(): DynamicModule {
|
||||||
|
return {
|
||||||
|
module: AppModule,
|
||||||
|
imports: [
|
||||||
|
PrismaModule,
|
||||||
|
PermissionsModule,
|
||||||
|
FilesModule,
|
||||||
|
AuthModule,
|
||||||
|
UsersModule,
|
||||||
|
HealthModule,
|
||||||
|
// ADR-002 : hors DEMO_MODE, le module n'est pas enregistré → 404
|
||||||
|
...(demoModeEnabled() ? [DemoAuthModule] : []),
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
// Ordre significatif : authentification puis permissions
|
||||||
|
{ provide: APP_GUARD, useClass: JwtAuthGuard },
|
||||||
|
{ provide: APP_GUARD, useClass: PermissionsGuard },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
17
apps/api/src/auth/auth.controller.ts
Normal file
17
apps/api/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
|
||||||
|
import { LoginRequestSchema, type LoginRequest } from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
import { Public } from './public.decorator';
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('login')
|
||||||
|
@HttpCode(200)
|
||||||
|
login(@Body(new ZodValidationPipe(LoginRequestSchema)) body: LoginRequest) {
|
||||||
|
return this.authService.login(body.email, body.password);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
apps/api/src/auth/auth.module.ts
Normal file
26
apps/api/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule, type JwtSignOptions } from '@nestjs/jwt';
|
||||||
|
import { loadEnv } from '../config/env';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
global: true,
|
||||||
|
useFactory: () => {
|
||||||
|
const env = loadEnv();
|
||||||
|
return {
|
||||||
|
secret: env.JWT_SECRET,
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: env.JWT_EXPIRES_IN as JwtSignOptions['expiresIn'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService],
|
||||||
|
exports: [AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
104
apps/api/src/auth/auth.service.ts
Normal file
104
apps/api/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import type {
|
||||||
|
AuthResponse,
|
||||||
|
DemoAccountsResponse,
|
||||||
|
RoleName,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
type UserWithRole = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
isDemo: boolean;
|
||||||
|
role: { id: string; name: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async login(email: string, password: string): Promise<AuthResponse> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
include: { role: true },
|
||||||
|
});
|
||||||
|
// argon2.verify même sur compte inconnu = temps de réponse homogène
|
||||||
|
const valid =
|
||||||
|
user?.passwordHash != null &&
|
||||||
|
(await argon2.verify(user.passwordHash, password));
|
||||||
|
if (!user || !user.isActive || !valid) {
|
||||||
|
throw new UnauthorizedException('Identifiants invalides');
|
||||||
|
}
|
||||||
|
return this.issueToken(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ADR-002 — liste publique en DEMO_MODE, jamais de secret. */
|
||||||
|
async listDemoAccounts(): Promise<DemoAccountsResponse> {
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { isDemo: true, isActive: true },
|
||||||
|
include: { role: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
accounts: users.map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
displayName: u.displayName,
|
||||||
|
roleName: u.role.name as RoleName,
|
||||||
|
initials: this.initials(u.displayName),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ADR-002 — refuse tout compte isDemo=false, quoi qu'il arrive. */
|
||||||
|
async demoLogin(userId: string): Promise<AuthResponse> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: { role: true },
|
||||||
|
});
|
||||||
|
if (!user || !user.isActive) throw new NotFoundException('Compte inconnu');
|
||||||
|
if (!user.isDemo) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Ce compte n’est pas un compte de démonstration',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.issueToken(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async issueToken(user: UserWithRole): Promise<AuthResponse> {
|
||||||
|
// Identité seule — les droits restent en base (invariant R0)
|
||||||
|
const accessToken = await this.jwtService.signAsync({
|
||||||
|
sub: user.id,
|
||||||
|
roleId: user.role.id,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
role: { id: user.role.id, name: user.role.name as RoleName },
|
||||||
|
isDemo: user.isDemo,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private initials(displayName: string): string {
|
||||||
|
return displayName
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0]!.toUpperCase())
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/auth/current-user.decorator.ts
Normal file
14
apps/api/src/auth/current-user.decorator.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
/** Identité portée par le JWT — jamais de droits (invariant R0) :
|
||||||
|
* la matrice est relue en base par PermissionsGuard. */
|
||||||
|
export interface AuthenticatedUser {
|
||||||
|
userId: string;
|
||||||
|
roleId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(_: unknown, ctx: ExecutionContext): AuthenticatedUser => {
|
||||||
|
return ctx.switchToHttp().getRequest().user;
|
||||||
|
},
|
||||||
|
);
|
||||||
27
apps/api/src/auth/demo/demo-auth.controller.ts
Normal file
27
apps/api/src/auth/demo/demo-auth.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
|
||||||
|
import { DemoLoginRequestSchema, type DemoLoginRequest } from '@siop/shared';
|
||||||
|
import { ZodValidationPipe } from '../../common/zod-validation.pipe';
|
||||||
|
import { AuthService } from '../auth.service';
|
||||||
|
import { Public } from '../public.decorator';
|
||||||
|
|
||||||
|
/** ADR-002 — ces routes n'existent que si DemoAuthModule est enregistré
|
||||||
|
* (DEMO_MODE=true) ; sinon : 404, vérifié par test e2e. */
|
||||||
|
@Controller('auth')
|
||||||
|
export class DemoAuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('demo-accounts')
|
||||||
|
listDemoAccounts() {
|
||||||
|
return this.authService.listDemoAccounts();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('demo-login')
|
||||||
|
@HttpCode(200)
|
||||||
|
demoLogin(
|
||||||
|
@Body(new ZodValidationPipe(DemoLoginRequestSchema)) body: DemoLoginRequest,
|
||||||
|
) {
|
||||||
|
return this.authService.demoLogin(body.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
apps/api/src/auth/demo/demo-auth.module.ts
Normal file
27
apps/api/src/auth/demo/demo-auth.module.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { assertDemoModeAllowed } from '../../config/env';
|
||||||
|
import { AuthModule } from '../auth.module';
|
||||||
|
import { DemoAuthController } from './demo-auth.controller';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADR-002 : module enregistré UNIQUEMENT quand DEMO_MODE=true
|
||||||
|
* (voir AppModule.forRoot). Double verrou : refuse de démarrer en production
|
||||||
|
* sans DEMO_MODE_I_KNOW=true.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [DemoAuthController],
|
||||||
|
})
|
||||||
|
export class DemoAuthModule implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(DemoAuthModule.name);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
assertDemoModeAllowed();
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.logger.warn(
|
||||||
|
'DEMO_MODE actif — /auth/demo-accounts et /auth/demo-login sont exposées.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
apps/api/src/auth/jwt-auth.guard.ts
Normal file
46
apps/api/src/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { IS_PUBLIC_KEY } from './public.decorator';
|
||||||
|
|
||||||
|
/** Guard global : API fermée par défaut, ouverture explicite via @Public(). */
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (isPublic) return true;
|
||||||
|
|
||||||
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
|
const [scheme, token] = request.headers.authorization?.split(' ') ?? [];
|
||||||
|
if (scheme !== 'Bearer' || !token) {
|
||||||
|
throw new UnauthorizedException('Jeton manquant');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = await this.jwtService.verifyAsync<{
|
||||||
|
sub: string;
|
||||||
|
roleId: string;
|
||||||
|
}>(token);
|
||||||
|
(request as Request & { user: unknown }).user = {
|
||||||
|
userId: payload.sub,
|
||||||
|
roleId: payload.roleId,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Jeton invalide ou expiré');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/auth/public.decorator.ts
Normal file
7
apps/api/src/auth/public.decorator.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
|
||||||
|
/** L'API est fermée par défaut (guard JWT global) : toute route publique
|
||||||
|
* doit l'être EXPLICITEMENT via ce décorateur. */
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
22
apps/api/src/common/zod-validation.pipe.ts
Normal file
22
apps/api/src/common/zod-validation.pipe.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { BadRequestException, Injectable, PipeTransform } from '@nestjs/common';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
/** Valide un body contre un schéma du contrat (@siop/shared). */
|
||||||
|
@Injectable()
|
||||||
|
export class ZodValidationPipe implements PipeTransform {
|
||||||
|
constructor(private readonly schema: z.ZodType) {}
|
||||||
|
|
||||||
|
transform(value: unknown) {
|
||||||
|
const result = this.schema.safeParse(value);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: 'Corps de requête invalide',
|
||||||
|
issues: result.error.issues.map((i) => ({
|
||||||
|
path: i.path.join('.'),
|
||||||
|
message: i.message,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
apps/api/src/config/env.spec.ts
Normal file
37
apps/api/src/config/env.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { assertDemoModeAllowed, demoModeEnabled } from './env';
|
||||||
|
|
||||||
|
describe('ADR-002 — double verrou DEMO_MODE', () => {
|
||||||
|
const saved = { ...process.env };
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...saved };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse DEMO_MODE=true en production sans DEMO_MODE_I_KNOW', () => {
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
delete process.env.DEMO_MODE_I_KNOW;
|
||||||
|
expect(() => assertDemoModeAllowed()).toThrow(/ADR-002/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepte DEMO_MODE=true en production AVEC DEMO_MODE_I_KNOW=true (instance démo)', () => {
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
process.env.DEMO_MODE_I_KNOW = 'true';
|
||||||
|
expect(() => assertDemoModeAllowed()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepte DEMO_MODE=true hors production', () => {
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
process.env.NODE_ENV = 'development';
|
||||||
|
expect(() => assertDemoModeAllowed()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("demoModeEnabled ne s'active que sur la valeur exacte « true »", () => {
|
||||||
|
process.env.DEMO_MODE = '1';
|
||||||
|
expect(demoModeEnabled()).toBe(false);
|
||||||
|
process.env.DEMO_MODE = 'true';
|
||||||
|
expect(demoModeEnabled()).toBe(true);
|
||||||
|
delete process.env.DEMO_MODE;
|
||||||
|
expect(demoModeEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
52
apps/api/src/config/env.ts
Normal file
52
apps/api/src/config/env.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const EnvSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||||
|
PORT: z.coerce.number().default(3000),
|
||||||
|
DATABASE_URL: z.string().min(1),
|
||||||
|
REDIS_URL: z.string().default('redis://localhost:6379'),
|
||||||
|
JWT_SECRET: z.string().min(16, 'JWT_SECRET : 16 caractères minimum'),
|
||||||
|
JWT_EXPIRES_IN: z.string().default('8h'),
|
||||||
|
MINIO_ENDPOINT: z.string().default('localhost'),
|
||||||
|
MINIO_PORT: z.coerce.number().default(9000),
|
||||||
|
MINIO_USE_SSL: z.string().optional(),
|
||||||
|
MINIO_ACCESS_KEY: z.string().default('siop'),
|
||||||
|
MINIO_SECRET_KEY: z.string().default('siop-minio'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Env = z.infer<typeof EnvSchema>;
|
||||||
|
|
||||||
|
/** Validée au démarrage — l'API refuse de booter sur une config invalide. */
|
||||||
|
export function loadEnv(): Env {
|
||||||
|
const parsed = EnvSchema.safeParse(process.env);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw new Error(
|
||||||
|
`Configuration invalide :\n${parsed.error.issues
|
||||||
|
.map((i) => ` - ${i.path.join('.')}: ${i.message}`)
|
||||||
|
.join('\n')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lues à l'appel (et non à l'import) pour rester testables. */
|
||||||
|
export function demoModeEnabled(): boolean {
|
||||||
|
return process.env.DEMO_MODE === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ADR-002, double verrou : DEMO_MODE=true en production exige l'aveu explicite
|
||||||
|
* DEMO_MODE_I_KNOW=true (réservé à l'instance de démonstration publique).
|
||||||
|
*/
|
||||||
|
export function assertDemoModeAllowed(): void {
|
||||||
|
if (
|
||||||
|
demoModeEnabled() &&
|
||||||
|
process.env.NODE_ENV === 'production' &&
|
||||||
|
process.env.DEMO_MODE_I_KNOW !== 'true'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'DEMO_MODE=true en production sans DEMO_MODE_I_KNOW=true — démarrage refusé (ADR-002).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/files/file-storage.ts
Normal file
12
apps/api/src/files/file-storage.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Interface de stockage de fichiers (ADR-001) : MinIO n'est JAMAIS importé
|
||||||
|
* ailleurs que dans son implémentation — le reste de l'API dépend de cette
|
||||||
|
* interface (règle lint à venir en R0.12). R0 : santé seulement ;
|
||||||
|
* les opérations arrivent avec les photos/documents (R2-R3).
|
||||||
|
*/
|
||||||
|
export const FILE_STORAGE = Symbol('FILE_STORAGE');
|
||||||
|
|
||||||
|
export interface FileStorage {
|
||||||
|
/** Lève une exception si le stockage est injoignable. */
|
||||||
|
healthCheck(): Promise<void>;
|
||||||
|
}
|
||||||
10
apps/api/src/files/files.module.ts
Normal file
10
apps/api/src/files/files.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { FILE_STORAGE } from './file-storage';
|
||||||
|
import { MinioStorageService } from './minio-storage.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [{ provide: FILE_STORAGE, useClass: MinioStorageService }],
|
||||||
|
exports: [FILE_STORAGE],
|
||||||
|
})
|
||||||
|
export class FilesModule {}
|
||||||
25
apps/api/src/files/minio-storage.service.ts
Normal file
25
apps/api/src/files/minio-storage.service.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import * as Minio from 'minio';
|
||||||
|
import { loadEnv } from '../config/env';
|
||||||
|
import type { FileStorage } from './file-storage';
|
||||||
|
|
||||||
|
/** Seul fichier du dépôt autorisé à importer le SDK MinIO. */
|
||||||
|
@Injectable()
|
||||||
|
export class MinioStorageService implements FileStorage {
|
||||||
|
private readonly client: Minio.Client;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const env = loadEnv();
|
||||||
|
this.client = new Minio.Client({
|
||||||
|
endPoint: env.MINIO_ENDPOINT,
|
||||||
|
port: env.MINIO_PORT,
|
||||||
|
useSSL: env.MINIO_USE_SSL === 'true',
|
||||||
|
accessKey: env.MINIO_ACCESS_KEY,
|
||||||
|
secretKey: env.MINIO_SECRET_KEY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<void> {
|
||||||
|
await this.client.listBuckets();
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/api/src/health/health.controller.ts
Normal file
53
apps/api/src/health/health.controller.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Controller, Get, Inject } from '@nestjs/common';
|
||||||
|
import type { HealthResponse } from '@siop/shared';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { Public } from '../auth/public.decorator';
|
||||||
|
import { loadEnv } from '../config/env';
|
||||||
|
import { FILE_STORAGE, type FileStorage } from '../files/file-storage';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
@Inject(FILE_STORAGE) private readonly storage: FileStorage,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get()
|
||||||
|
async health(): Promise<HealthResponse> {
|
||||||
|
const [database, redis, storage] = await Promise.all([
|
||||||
|
this.check(() => this.prisma.$queryRaw`SELECT 1`),
|
||||||
|
this.check(async () => {
|
||||||
|
const client = new Redis(loadEnv().REDIS_URL, {
|
||||||
|
lazyConnect: true,
|
||||||
|
maxRetriesPerRequest: 0,
|
||||||
|
connectTimeout: 2000,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
await client.ping();
|
||||||
|
} finally {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
this.check(() => this.storage.healthCheck()),
|
||||||
|
]);
|
||||||
|
const services = { database, redis, storage };
|
||||||
|
return {
|
||||||
|
status: Object.values(services).every((s) => s === 'up')
|
||||||
|
? 'ok'
|
||||||
|
: 'degraded',
|
||||||
|
services,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async check(fn: () => Promise<unknown>): Promise<'up' | 'down'> {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
return 'up';
|
||||||
|
} catch {
|
||||||
|
return 'down';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/health/health.module.ts
Normal file
7
apps/api/src/health/health.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class HealthModule {}
|
||||||
17
apps/api/src/main.ts
Normal file
17
apps/api/src/main.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { assertDemoModeAllowed, loadEnv } from './config/env';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const env = loadEnv();
|
||||||
|
assertDemoModeAllowed(); // ADR-002 — double verrou avant toute écoute réseau
|
||||||
|
|
||||||
|
const app = await NestFactory.create(AppModule.forRoot());
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
await app.listen(env.PORT);
|
||||||
|
new Logger('Bootstrap').log(`API SIOP V2 démarrée sur :${env.PORT}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
void bootstrap();
|
||||||
48
apps/api/src/permissions/permissions.guard.ts
Normal file
48
apps/api/src/permissions/permissions.guard.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import type { AuthenticatedUser } from '../auth/current-user.decorator';
|
||||||
|
import {
|
||||||
|
PERMISSION_KEY,
|
||||||
|
RequiredPermission,
|
||||||
|
} from './require-permission.decorator';
|
||||||
|
import { PermissionsService } from './permissions.service';
|
||||||
|
|
||||||
|
/** Second guard global (après JwtAuthGuard) : applique @RequirePermission
|
||||||
|
* en relisant la matrice EN BASE — le JWT ne porte jamais de droits. */
|
||||||
|
@Injectable()
|
||||||
|
export class PermissionsGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly permissionsService: PermissionsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const required = this.reflector.getAllAndOverride<RequiredPermission>(
|
||||||
|
PERMISSION_KEY,
|
||||||
|
[context.getHandler(), context.getClass()],
|
||||||
|
);
|
||||||
|
if (!required) return true; // pas d'exigence déclarée (ex. routes @Public)
|
||||||
|
|
||||||
|
const user: AuthenticatedUser | undefined = context
|
||||||
|
.switchToHttp()
|
||||||
|
.getRequest().user;
|
||||||
|
if (!user) throw new ForbiddenException();
|
||||||
|
|
||||||
|
const allowed = await this.permissionsService.can(
|
||||||
|
user.roleId,
|
||||||
|
required.category,
|
||||||
|
required.right,
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
`Droit manquant : ${required.category}.${required.right}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/permissions/permissions.module.ts
Normal file
9
apps/api/src/permissions/permissions.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PermissionsService } from './permissions.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PermissionsService],
|
||||||
|
exports: [PermissionsService],
|
||||||
|
})
|
||||||
|
export class PermissionsModule {}
|
||||||
62
apps/api/src/permissions/permissions.service.spec.ts
Normal file
62
apps/api/src/permissions/permissions.service.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { PermissionsService } from './permissions.service';
|
||||||
|
|
||||||
|
const row = (overrides: Partial<Record<string, unknown>> = {}) => ({
|
||||||
|
id: 'p1',
|
||||||
|
roleId: 'role-1',
|
||||||
|
objectCategory: 'WORK_ORDERS',
|
||||||
|
canView: true,
|
||||||
|
canViewOther: false,
|
||||||
|
canCreate: false,
|
||||||
|
canEdit: true,
|
||||||
|
canDelete: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PermissionsService (matrice en base, cache 60 s)', () => {
|
||||||
|
let findMany: jest.Mock;
|
||||||
|
let service: PermissionsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
findMany = jest.fn().mockResolvedValue([row()]);
|
||||||
|
service = new PermissionsService({
|
||||||
|
permission: { findMany },
|
||||||
|
} as unknown as PrismaService);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => jest.useRealTimers());
|
||||||
|
|
||||||
|
it('applique les cinq droits de la ligne', async () => {
|
||||||
|
await expect(service.can('role-1', 'WORK_ORDERS', 'view')).resolves.toBe(true);
|
||||||
|
await expect(service.can('role-1', 'WORK_ORDERS', 'edit')).resolves.toBe(true);
|
||||||
|
await expect(service.can('role-1', 'WORK_ORDERS', 'viewOther')).resolves.toBe(false);
|
||||||
|
await expect(service.can('role-1', 'WORK_ORDERS', 'create')).resolves.toBe(false);
|
||||||
|
await expect(service.can('role-1', 'WORK_ORDERS', 'delete')).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse une catégorie sans ligne (fermé par défaut)', async () => {
|
||||||
|
await expect(service.can('role-1', 'SETTINGS', 'view')).resolves.toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne relit la base qu’à l’expiration du cache (60 s)', async () => {
|
||||||
|
await service.can('role-1', 'WORK_ORDERS', 'view');
|
||||||
|
await service.can('role-1', 'WORK_ORDERS', 'edit');
|
||||||
|
expect(findMany).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(59_000);
|
||||||
|
await service.can('role-1', 'WORK_ORDERS', 'view');
|
||||||
|
expect(findMany).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(2_000);
|
||||||
|
await service.can('role-1', 'WORK_ORDERS', 'view');
|
||||||
|
expect(findMany).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidate() force la relecture immédiate', async () => {
|
||||||
|
await service.can('role-1', 'WORK_ORDERS', 'view');
|
||||||
|
service.invalidate('role-1');
|
||||||
|
await service.can('role-1', 'WORK_ORDERS', 'view');
|
||||||
|
expect(findMany).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
66
apps/api/src/permissions/permissions.service.ts
Normal file
66
apps/api/src/permissions/permissions.service.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type {
|
||||||
|
ObjectCategory,
|
||||||
|
PermissionEntry,
|
||||||
|
PermissionRight,
|
||||||
|
} from '@siop/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 60_000; // invariant R0 : la matrice est relue au plus toutes les 60 s
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
expiresAt: number;
|
||||||
|
permissions: PermissionEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PermissionsService {
|
||||||
|
private readonly cache = new Map<string, CacheEntry>();
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getForRole(roleId: string): Promise<PermissionEntry[]> {
|
||||||
|
const cached = this.cache.get(roleId);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return cached.permissions;
|
||||||
|
|
||||||
|
const rows = await this.prisma.permission.findMany({ where: { roleId } });
|
||||||
|
const permissions = rows.map((r) => ({
|
||||||
|
objectCategory: r.objectCategory as ObjectCategory,
|
||||||
|
canView: r.canView,
|
||||||
|
canViewOther: r.canViewOther,
|
||||||
|
canCreate: r.canCreate,
|
||||||
|
canEdit: r.canEdit,
|
||||||
|
canDelete: r.canDelete,
|
||||||
|
}));
|
||||||
|
this.cache.set(roleId, { expiresAt: Date.now() + CACHE_TTL_MS, permissions });
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async can(
|
||||||
|
roleId: string,
|
||||||
|
category: ObjectCategory,
|
||||||
|
right: PermissionRight,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const permissions = await this.getForRole(roleId);
|
||||||
|
const entry = permissions.find((p) => p.objectCategory === category);
|
||||||
|
if (!entry) return false;
|
||||||
|
switch (right) {
|
||||||
|
case 'view':
|
||||||
|
return entry.canView;
|
||||||
|
case 'viewOther':
|
||||||
|
return entry.canViewOther;
|
||||||
|
case 'create':
|
||||||
|
return entry.canCreate;
|
||||||
|
case 'edit':
|
||||||
|
return entry.canEdit;
|
||||||
|
case 'delete':
|
||||||
|
return entry.canDelete;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** À appeler après toute modification de la matrice (admin, R1+). */
|
||||||
|
invalidate(roleId?: string): void {
|
||||||
|
if (roleId) this.cache.delete(roleId);
|
||||||
|
else this.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
apps/api/src/permissions/require-permission.decorator.ts
Normal file
16
apps/api/src/permissions/require-permission.decorator.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import type { ObjectCategory, PermissionRight } from '@siop/shared';
|
||||||
|
|
||||||
|
export const PERMISSION_KEY = 'requiredPermission';
|
||||||
|
|
||||||
|
export interface RequiredPermission {
|
||||||
|
category: ObjectCategory;
|
||||||
|
right: PermissionRight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exige un droit de la matrice (relue en base, cache 60 s) :
|
||||||
|
* @RequirePermission('WORK_ORDERS', 'create') */
|
||||||
|
export const RequirePermission = (
|
||||||
|
category: ObjectCategory,
|
||||||
|
right: PermissionRight,
|
||||||
|
) => SetMetadata(PERMISSION_KEY, { category, right } satisfies RequiredPermission);
|
||||||
9
apps/api/src/prisma/prisma.module.ts
Normal file
9
apps/api/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
16
apps/api/src/prisma/prisma.service.ts
Normal file
16
apps/api/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService
|
||||||
|
extends PrismaClient
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
34
apps/api/src/users/users.controller.ts
Normal file
34
apps/api/src/users/users.controller.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Controller, Get, NotFoundException } from '@nestjs/common';
|
||||||
|
import type { MeResponse, RoleName } from '@siop/shared';
|
||||||
|
import {
|
||||||
|
AuthenticatedUser,
|
||||||
|
CurrentUser,
|
||||||
|
} from '../auth/current-user.decorator';
|
||||||
|
import { PermissionsService } from '../permissions/permissions.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
@Controller('users')
|
||||||
|
export class UsersController {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissionsService: PermissionsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** R0 : lecture du profil courant (gestion complète des utilisateurs en R1). */
|
||||||
|
@Get('me')
|
||||||
|
async me(@CurrentUser() current: AuthenticatedUser): Promise<MeResponse> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: current.userId },
|
||||||
|
include: { role: true },
|
||||||
|
});
|
||||||
|
if (!user || !user.isActive) throw new NotFoundException();
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
role: { id: user.role.id, name: user.role.name as RoleName },
|
||||||
|
isDemo: user.isDemo,
|
||||||
|
permissions: await this.permissionsService.getForRole(user.roleId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/users/users.module.ts
Normal file
7
apps/api/src/users/users.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { UsersController } from './users.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [UsersController],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
||||||
124
apps/api/test/auth.e2e-spec.ts
Normal file
124
apps/api/test/auth.e2e-spec.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* E2E auth — nécessite l'infra locale (pnpm infra:up) : base seedée au beforeAll.
|
||||||
|
* DEMO_MODE est activé AVANT la composition d'AppModule (voir demo-off.e2e-spec
|
||||||
|
* pour l'état inverse : les routes démo doivent répondre 404).
|
||||||
|
*/
|
||||||
|
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('Auth (e2e, DEMO_MODE=true)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let http: () => request.Agent;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await seed(prisma);
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
http = () => request(app.getHttpServer());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app?.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /health est publique', async () => {
|
||||||
|
const res = await http().get('/health').expect(200);
|
||||||
|
expect(res.body.services.database).toBe('up');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('l’API est fermée par défaut (401 sans jeton)', async () => {
|
||||||
|
await http().get('/users/me').expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /auth/demo-accounts liste les 7 comptes seedés, sans secret', async () => {
|
||||||
|
const res = await http().get('/auth/demo-accounts').expect(200);
|
||||||
|
expect(res.body.accounts).toHaveLength(7);
|
||||||
|
for (const account of res.body.accounts) {
|
||||||
|
expect(Object.keys(account).sort()).toEqual([
|
||||||
|
'displayName',
|
||||||
|
'id',
|
||||||
|
'initials',
|
||||||
|
'roleName',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('demo-login émet un jeton utilisable sur /users/me (critère < 3 s)', async () => {
|
||||||
|
const start = Date.now();
|
||||||
|
const { body: accounts } = await http().get('/auth/demo-accounts');
|
||||||
|
const admin = accounts.accounts.find(
|
||||||
|
(a: { roleName: string }) => a.roleName === 'Administrateur',
|
||||||
|
);
|
||||||
|
const login = await http()
|
||||||
|
.post('/auth/demo-login')
|
||||||
|
.send({ userId: admin.id })
|
||||||
|
.expect(200);
|
||||||
|
const me = await http()
|
||||||
|
.get('/users/me')
|
||||||
|
.set('Authorization', `Bearer ${login.body.accessToken}`)
|
||||||
|
.expect(200);
|
||||||
|
expect(Date.now() - start).toBeLessThan(3000);
|
||||||
|
expect(me.body.role.name).toBe('Administrateur');
|
||||||
|
expect(me.body.isDemo).toBe(true);
|
||||||
|
// La matrice complète accompagne le profil (une ligne par catégorie)
|
||||||
|
expect(me.body.permissions).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('demo-login refuse un compte isDemo=false (403)', async () => {
|
||||||
|
const role = await prisma.role.findUniqueOrThrow({
|
||||||
|
where: { name: 'Administrateur' },
|
||||||
|
});
|
||||||
|
const real = await prisma.user.upsert({
|
||||||
|
where: { email: 'real@spelev.ma' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'real@spelev.ma',
|
||||||
|
displayName: 'Compte Réel',
|
||||||
|
roleId: role.id,
|
||||||
|
isDemo: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await http().post('/auth/demo-login').send({ userId: real.id }).expect(403);
|
||||||
|
} finally {
|
||||||
|
await prisma.user.delete({ where: { id: real.id } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('demo-login sur un id inconnu → 404', async () => {
|
||||||
|
await http()
|
||||||
|
.post('/auth/demo-login')
|
||||||
|
.send({ userId: '00000000-0000-4000-8000-000000000000' })
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la connexion classique fonctionne (seed) et rejette un mauvais mot de passe', async () => {
|
||||||
|
const password = process.env.SEED_DEMO_PASSWORD ?? 'Demo!2026';
|
||||||
|
const ok = await http()
|
||||||
|
.post('/auth/login')
|
||||||
|
.send({ email: 'dispatcher@demo.siop.ma', password })
|
||||||
|
.expect(200);
|
||||||
|
expect(ok.body.user.role.name).toBe('Dispatcher');
|
||||||
|
|
||||||
|
await http()
|
||||||
|
.post('/auth/login')
|
||||||
|
.send({ email: 'dispatcher@demo.siop.ma', password: 'mauvais' })
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('valide les corps de requête via le contrat Zod (400)', async () => {
|
||||||
|
await http().post('/auth/demo-login').send({ userId: 'pas-un-uuid' }).expect(400);
|
||||||
|
await http().post('/auth/login').send({ email: 'x' }).expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
47
apps/api/test/demo-off.e2e-spec.ts
Normal file
47
apps/api/test/demo-off.e2e-spec.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* ADR-002, test dédié : quand DEMO_MODE n'est pas actif, les routes démo
|
||||||
|
* N'EXISTENT PAS (404) — le module n'est pas enregistré.
|
||||||
|
*/
|
||||||
|
// « false » plutôt que delete : dotenv (importé par config/env.ts) repeuplerait
|
||||||
|
// une variable supprimée depuis .env, mais n'écrase jamais une valeur existante.
|
||||||
|
process.env.DEMO_MODE = 'false';
|
||||||
|
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
describe('Auth démo (e2e, DEMO_MODE absent)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppModule.forRoot()],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /auth/demo-accounts → 404', async () => {
|
||||||
|
await request(app.getHttpServer()).get('/auth/demo-accounts').expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /auth/demo-login → 404', async () => {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/auth/demo-login')
|
||||||
|
.send({ userId: '00000000-0000-4000-8000-000000000000' })
|
||||||
|
.expect(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('la connexion classique, elle, reste disponible', async () => {
|
||||||
|
// 401 (identifiants) et non 404 : la route existe bien
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post('/auth/login')
|
||||||
|
.send({ email: 'nobody@spelev.ma', password: 'x' })
|
||||||
|
.expect(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
5
apps/api/test/setup-env.ts
Normal file
5
apps/api/test/setup-env.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.JWT_SECRET ??= 'test-secret-0123456789abcdef';
|
||||||
|
process.env.DATABASE_URL ??= 'postgresql://siop:siop@localhost:5432/siop';
|
||||||
5
apps/api/tsconfig.build.json
Normal file
5
apps/api/tsconfig.build.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
|
||||||
|
}
|
||||||
18
apps/api/tsconfig.json
Normal file
18
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"strict": true,
|
||||||
|
"strictPropertyInitialization": false,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"incremental": true
|
||||||
|
},
|
||||||
|
"include": ["src", "test", "prisma", "scripts"]
|
||||||
|
}
|
||||||
@@ -4,6 +4,41 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-07-15 — Pr. Daaif (+ Claude) — R0.10 : apps/api complète (auth, matrice, démo-login, seed)
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
|
||||||
|
- `packages/shared` : vocabulaires (7 rôles, 10 catégories d'objets), schémas Zod (auth, profil, santé) et **contrat d'API** ; `pnpm contract` génère `docs/openapi.json` (committée — règle d'or ADR-001).
|
||||||
|
- `apps/api` (NestJS 11 + Prisma 6) : migration `r0_identity` (Role/Permission/User) ; **guard JWT global fermé par défaut** (+ `@Public()` explicite) ; **PermissionsGuard** (`@RequirePermission`, matrice relue en base, cache 60 s) ; `FileStorage` (seul point d'import MinIO) ; `/health` (base, Redis, stockage).
|
||||||
|
- **Démo-login ADR-002** : module enregistré uniquement si `DEMO_MODE=true` (sinon routes **404**), double verrou production (`DEMO_MODE_I_KNOW`), refus des comptes `isDemo=false`.
|
||||||
|
- **Seed idempotent** : 7 rôles, matrice complète (70 lignes), 7 comptes démo (mot de passe commun `SEED_DEMO_PASSWORD` pour la connexion classique).
|
||||||
|
- **Vérifié bout-en-bout** : 19 tests Jest verts (dont e2e démo on/off) ; smoke test sur build de prod — démo-login → `/users/me` avec matrice, 401 sans jeton, health `ok`.
|
||||||
|
|
||||||
|
**Décisions**
|
||||||
|
|
||||||
|
- `AppModule.forRoot()` (module dynamique) pour rendre l'enregistrement conditionnel du module démo **testable dans les deux états** — le e2e « routes absentes » est l'exigence n°1 de l'ADR-002.
|
||||||
|
- Le seed n'écrase jamais une ligne de matrice existante : **la base est la source de vérité des droits**, le fichier n'est que le point de départ.
|
||||||
|
|
||||||
|
**Prochaine étape** : R0.11 `apps/web` — login + sélecteur de comptes démo (fidèle à maquette-web.html), coquille sidebar/topbar avec bandeau « DÉMO », page /design, client typé généré depuis `docs/openapi.json`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-15 — Pr. Daaif (+ Claude) — R0 : maquettes VALIDÉES ; architecture + squelette
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
|
||||||
|
- **Maquettes HD validées par le référent** → le design est la loi des revues pixel.
|
||||||
|
- 03-architecture : ADR-001 (stack), ADR-002 (démo-login `DEMO_MODE`, double verrou prod), vue C4, modèle de données R0 (Role/Permission/User, `isDemo`).
|
||||||
|
- Racine monorepo (pnpm + turbo, Node 24) ; `infra/` : compose local (PostgreSQL 18 pgvector+PostGIS via Dockerfile dédié, Redis, MinIO).
|
||||||
|
|
||||||
|
**Décisions**
|
||||||
|
|
||||||
|
- **Convention `siop2-`** pour tous services/conteneurs Docker (référent — collisions sur le réseau partagé Dokploy en v1).
|
||||||
|
|
||||||
|
**Prochaine étape (reprise)** : R0.10 `apps/api` (auth JWT + matrice permissions + démo-login + seed) → R0.11 `apps/web` (login + sélecteur démo + coquille + /design) → R0.12 CI → R0.13 prépa Dokploy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-07-15 — Pr. Daaif (+ Claude) — R0 : kickoff, vision, cadrage, design
|
## 2026-07-15 — Pr. Daaif (+ Claude) — R0 : kickoff, vision, cadrage, design
|
||||||
|
|
||||||
**Actions**
|
**Actions**
|
||||||
|
|||||||
479
docs/openapi.json
Normal file
479
docs/openapi.json
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.1.0",
|
||||||
|
"info": {
|
||||||
|
"title": "SIOP V2 API",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "GMAO ascenseurs — contrat R0 (auth, démo-login ADR-002, profil, santé). Généré depuis packages/shared/src/contract.ts — NE PAS ÉDITER À LA MAIN."
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"/auth/login": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "login",
|
||||||
|
"summary": "Connexion par e-mail et mot de passe",
|
||||||
|
"tags": [
|
||||||
|
"auth"
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/LoginRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Jeton émis",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AuthResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Identifiants invalides ou compte inactif"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/auth/demo-accounts": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listDemoAccounts",
|
||||||
|
"summary": "Comptes de démonstration (ADR-002 — jamais de secret)",
|
||||||
|
"tags": [
|
||||||
|
"auth",
|
||||||
|
"demo"
|
||||||
|
],
|
||||||
|
"x-demo-only": true,
|
||||||
|
"description": "ADR-002 : cette route est absente (404) quand DEMO_MODE n’est pas actif.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Comptes isDemo actifs",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DemoAccountsResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/auth/demo-login": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "demoLogin",
|
||||||
|
"summary": "Connexion 1 clic sur un compte de démonstration (ADR-002)",
|
||||||
|
"tags": [
|
||||||
|
"auth",
|
||||||
|
"demo"
|
||||||
|
],
|
||||||
|
"x-demo-only": true,
|
||||||
|
"description": "ADR-002 : cette route est absente (404) quand DEMO_MODE n’est pas actif.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/DemoLoginRequest"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Jeton émis",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/AuthResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Le compte n’est pas un compte de démonstration"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Compte inconnu"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/users/me": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getMe",
|
||||||
|
"summary": "Profil courant + matrice de permissions du rôle",
|
||||||
|
"tags": [
|
||||||
|
"users"
|
||||||
|
],
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Profil",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/MeResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Non authentifié"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/health": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getHealth",
|
||||||
|
"summary": "État des dépendances (base, Redis, stockage)",
|
||||||
|
"tags": [
|
||||||
|
"health"
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "État agrégé",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/HealthResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"schemas": {
|
||||||
|
"AuthResponse": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"accessToken": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "email",
|
||||||
|
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
|
||||||
|
},
|
||||||
|
"displayName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"Administrateur",
|
||||||
|
"Dispatcher",
|
||||||
|
"Technicien",
|
||||||
|
"Technicien limité",
|
||||||
|
"Gestionnaire",
|
||||||
|
"Demandeur",
|
||||||
|
"Vue seule"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"isDemo": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"email",
|
||||||
|
"displayName",
|
||||||
|
"role",
|
||||||
|
"isDemo"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"accessToken",
|
||||||
|
"user"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"LoginRequest": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"email": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "email",
|
||||||
|
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"email",
|
||||||
|
"password"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"DemoAccountsResponse": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"accounts": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
|
||||||
|
},
|
||||||
|
"displayName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"roleName": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"Administrateur",
|
||||||
|
"Dispatcher",
|
||||||
|
"Technicien",
|
||||||
|
"Technicien limité",
|
||||||
|
"Gestionnaire",
|
||||||
|
"Demandeur",
|
||||||
|
"Vue seule"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"initials": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"maxLength": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"displayName",
|
||||||
|
"roleName",
|
||||||
|
"initials"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"accounts"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"DemoLoginRequest": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"userId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"userId"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"MeResponse": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "email",
|
||||||
|
"pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$"
|
||||||
|
},
|
||||||
|
"displayName": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid",
|
||||||
|
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"Administrateur",
|
||||||
|
"Dispatcher",
|
||||||
|
"Technicien",
|
||||||
|
"Technicien limité",
|
||||||
|
"Gestionnaire",
|
||||||
|
"Demandeur",
|
||||||
|
"Vue seule"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"isDemo": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"objectCategory": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"WORK_ORDERS",
|
||||||
|
"REQUESTS",
|
||||||
|
"ASSETS",
|
||||||
|
"LOCATIONS",
|
||||||
|
"METERS",
|
||||||
|
"PARTS",
|
||||||
|
"PURCHASE_ORDERS",
|
||||||
|
"PEOPLE_TEAMS",
|
||||||
|
"ANALYTICS",
|
||||||
|
"SETTINGS"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"canView": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"canViewOther": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"canCreate": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"canEdit": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"canDelete": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"objectCategory",
|
||||||
|
"canView",
|
||||||
|
"canViewOther",
|
||||||
|
"canCreate",
|
||||||
|
"canEdit",
|
||||||
|
"canDelete"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"email",
|
||||||
|
"displayName",
|
||||||
|
"role",
|
||||||
|
"isDemo",
|
||||||
|
"permissions"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"HealthResponse": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"ok",
|
||||||
|
"degraded"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"database": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"up",
|
||||||
|
"down"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"up",
|
||||||
|
"down"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"up",
|
||||||
|
"down"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"database",
|
||||||
|
"redis",
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"status",
|
||||||
|
"services"
|
||||||
|
],
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"securitySchemes": {
|
||||||
|
"bearerAuth": {
|
||||||
|
"type": "http",
|
||||||
|
"scheme": "bearer",
|
||||||
|
"bearerFormat": "JWT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
infra/.env.example
Normal file
6
infra/.env.example
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Infrastructure locale (valeurs de développement — jamais utilisées en production)
|
||||||
|
POSTGRES_USER=siop
|
||||||
|
POSTGRES_PASSWORD=siop
|
||||||
|
POSTGRES_DB=siop
|
||||||
|
MINIO_ROOT_USER=siop
|
||||||
|
MINIO_ROOT_PASSWORD=siop-minio
|
||||||
60
infra/docker-compose.yml
Normal file
60
infra/docker-compose.yml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Infrastructure locale SIOP V2 : PostgreSQL (pgvector+PostGIS), Redis, MinIO.
|
||||||
|
# Usage : pnpm infra:up / infra:down / infra:reset
|
||||||
|
#
|
||||||
|
# CONVENTION (demande du référent, leçon v1) : tous les services et conteneurs
|
||||||
|
# sont préfixés « siop2- » — sur le réseau partagé de Dokploy, un service nommé
|
||||||
|
# « postgres » ou « api » entre en collision avec les autres projets.
|
||||||
|
name: siop2-infra
|
||||||
|
|
||||||
|
services:
|
||||||
|
siop2-postgres:
|
||||||
|
container_name: siop2-postgres
|
||||||
|
build: ./postgres
|
||||||
|
image: siop2/postgres:18-pgvector-postgis
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-siop}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-siop}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-siop}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pg-data:/var/lib/postgresql
|
||||||
|
- ./postgres/init.sql:/docker-entrypoint-initdb.d/10-extensions.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U siop -d siop"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
siop2-redis:
|
||||||
|
container_name: siop2-redis
|
||||||
|
image: redis:7.4.9-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
siop2-minio:
|
||||||
|
container_name: siop2-minio
|
||||||
|
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-siop}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-siop-minio}
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- minio-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mc", "ready", "local"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg-data:
|
||||||
|
minio-data:
|
||||||
6
infra/postgres/Dockerfile
Normal file
6
infra/postgres/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# PostgreSQL 18 + pgvector (RAG, R5) + PostGIS (carte, R1) — une seule base pour tout.
|
||||||
|
FROM pgvector/pgvector:0.8.4-pg18-trixie
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends postgresql-18-postgis-3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
3
infra/postgres/init.sql
Normal file
3
infra/postgres/init.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-- Extensions activées à la création de la base (idempotent).
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "siop2",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "SIOP V2 — GMAO ascenseurs (produit + playbook)",
|
||||||
|
"packageManager": "pnpm@11.10.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "turbo dev",
|
||||||
|
"build": "turbo build",
|
||||||
|
"lint": "turbo lint",
|
||||||
|
"typecheck": "turbo typecheck",
|
||||||
|
"test": "turbo test",
|
||||||
|
"contract": "pnpm --filter @siop/shared contract",
|
||||||
|
"infra:up": "docker compose -f infra/docker-compose.yml up -d",
|
||||||
|
"infra:down": "docker compose -f infra/docker-compose.yml down",
|
||||||
|
"infra:reset": "docker compose -f infra/docker-compose.yml down -v"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"turbo": "^2.5.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
packages/shared/package.json
Normal file
20
packages/shared/package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "@siop/shared",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Contrat SIOP V2 — schémas Zod, référentiel rôles/permissions, génération OpenAPI (règle d'or ADR-001)",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"contract": "tsx scripts/generate-openapi.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^4.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
84
packages/shared/scripts/generate-openapi.ts
Normal file
84
packages/shared/scripts/generate-openapi.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Règle d'or (ADR-001) : Zod → OpenAPI → clients typés.
|
||||||
|
* Génère docs/openapi.json (COMMITTÉE) depuis src/contract.ts.
|
||||||
|
* Usage : pnpm --filter @siop/shared contract (raccourci racine : pnpm contract)
|
||||||
|
* La CI (ci-contract, R0.12) échoue si le fichier committé diffère de la génération.
|
||||||
|
*/
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { API_CONTRACT } from '../src/contract';
|
||||||
|
|
||||||
|
const OUT = resolve(__dirname, '../../../docs/openapi.json');
|
||||||
|
|
||||||
|
const schemas: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
function register(name: string, schema: z.ZodType): { $ref: string } {
|
||||||
|
if (!(name in schemas)) {
|
||||||
|
schemas[name] = z.toJSONSchema(schema, { target: 'draft-2020-12' });
|
||||||
|
}
|
||||||
|
return { $ref: `#/components/schemas/${name}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const paths: Record<string, Record<string, unknown>> = {};
|
||||||
|
|
||||||
|
for (const op of API_CONTRACT) {
|
||||||
|
const responses: Record<string, unknown> = {};
|
||||||
|
for (const [status, res] of Object.entries(op.responses)) {
|
||||||
|
responses[status] = {
|
||||||
|
description: res.description,
|
||||||
|
...(res.schema && res.name
|
||||||
|
? { content: { 'application/json': { schema: register(res.name, res.schema) } } }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
paths[op.path] = {
|
||||||
|
...(paths[op.path] ?? {}),
|
||||||
|
[op.method]: {
|
||||||
|
operationId: op.operationId,
|
||||||
|
summary: op.summary,
|
||||||
|
tags: op.tags,
|
||||||
|
...(op.isPublic ? {} : { security: [{ bearerAuth: [] }] }),
|
||||||
|
...(op.demoOnly
|
||||||
|
? {
|
||||||
|
'x-demo-only': true,
|
||||||
|
description:
|
||||||
|
'ADR-002 : cette route est absente (404) quand DEMO_MODE n’est pas actif.',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(op.request
|
||||||
|
? {
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
'application/json': { schema: register(op.request.name, op.request.schema) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
responses,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = {
|
||||||
|
openapi: '3.1.0',
|
||||||
|
info: {
|
||||||
|
title: 'SIOP V2 API',
|
||||||
|
version: '0.1.0',
|
||||||
|
description:
|
||||||
|
'GMAO ascenseurs — contrat R0 (auth, démo-login ADR-002, profil, santé). ' +
|
||||||
|
'Généré depuis packages/shared/src/contract.ts — NE PAS ÉDITER À LA MAIN.',
|
||||||
|
},
|
||||||
|
paths,
|
||||||
|
components: {
|
||||||
|
schemas,
|
||||||
|
securitySchemes: {
|
||||||
|
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mkdirSync(dirname(OUT), { recursive: true });
|
||||||
|
writeFileSync(OUT, JSON.stringify(doc, null, 2) + '\n');
|
||||||
|
console.log(`OpenAPI générée : ${OUT} (${API_CONTRACT.length} opérations)`);
|
||||||
104
packages/shared/src/contract.ts
Normal file
104
packages/shared/src/contract.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import type { z } from 'zod';
|
||||||
|
import {
|
||||||
|
AuthResponseSchema,
|
||||||
|
DemoAccountsResponseSchema,
|
||||||
|
DemoLoginRequestSchema,
|
||||||
|
LoginRequestSchema,
|
||||||
|
} from './schemas/auth';
|
||||||
|
import { MeResponseSchema } from './schemas/users';
|
||||||
|
import { HealthResponseSchema } from './schemas/health';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contrat d'API R0 — source unique de vérité (règle d'or ADR-001).
|
||||||
|
* `scripts/generate-openapi.ts` en dérive `docs/openapi.json` (committée) ;
|
||||||
|
* les clients web/mobile sont générés depuis cette spec, dans le même commit.
|
||||||
|
*/
|
||||||
|
export interface ApiOperation {
|
||||||
|
operationId: string;
|
||||||
|
method: 'get' | 'post' | 'put' | 'patch' | 'delete';
|
||||||
|
path: string;
|
||||||
|
summary: string;
|
||||||
|
tags: string[];
|
||||||
|
/** Route annotée @Public() côté API (pas de JWT requis). */
|
||||||
|
isPublic?: boolean;
|
||||||
|
/** ADR-002 : la route N'EXISTE PAS (404) si DEMO_MODE n'est pas actif. */
|
||||||
|
demoOnly?: boolean;
|
||||||
|
request?: { name: string; schema: z.ZodType };
|
||||||
|
responses: Record<
|
||||||
|
number,
|
||||||
|
{ description: string; name?: string; schema?: z.ZodType }
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const API_CONTRACT: ApiOperation[] = [
|
||||||
|
{
|
||||||
|
operationId: 'login',
|
||||||
|
method: 'post',
|
||||||
|
path: '/auth/login',
|
||||||
|
summary: 'Connexion par e-mail et mot de passe',
|
||||||
|
tags: ['auth'],
|
||||||
|
isPublic: true,
|
||||||
|
request: { name: 'LoginRequest', schema: LoginRequestSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Jeton émis', name: 'AuthResponse', schema: AuthResponseSchema },
|
||||||
|
401: { description: 'Identifiants invalides ou compte inactif' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'listDemoAccounts',
|
||||||
|
method: 'get',
|
||||||
|
path: '/auth/demo-accounts',
|
||||||
|
summary: 'Comptes de démonstration (ADR-002 — jamais de secret)',
|
||||||
|
tags: ['auth', 'demo'],
|
||||||
|
isPublic: true,
|
||||||
|
demoOnly: true,
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'Comptes isDemo actifs',
|
||||||
|
name: 'DemoAccountsResponse',
|
||||||
|
schema: DemoAccountsResponseSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'demoLogin',
|
||||||
|
method: 'post',
|
||||||
|
path: '/auth/demo-login',
|
||||||
|
summary: 'Connexion 1 clic sur un compte de démonstration (ADR-002)',
|
||||||
|
tags: ['auth', 'demo'],
|
||||||
|
isPublic: true,
|
||||||
|
demoOnly: true,
|
||||||
|
request: { name: 'DemoLoginRequest', schema: DemoLoginRequestSchema },
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Jeton émis', name: 'AuthResponse', schema: AuthResponseSchema },
|
||||||
|
403: { description: 'Le compte n’est pas un compte de démonstration' },
|
||||||
|
404: { description: 'Compte inconnu' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'getMe',
|
||||||
|
method: 'get',
|
||||||
|
path: '/users/me',
|
||||||
|
summary: 'Profil courant + matrice de permissions du rôle',
|
||||||
|
tags: ['users'],
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Profil', name: 'MeResponse', schema: MeResponseSchema },
|
||||||
|
401: { description: 'Non authentifié' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
operationId: 'getHealth',
|
||||||
|
method: 'get',
|
||||||
|
path: '/health',
|
||||||
|
summary: 'État des dépendances (base, Redis, stockage)',
|
||||||
|
tags: ['health'],
|
||||||
|
isPublic: true,
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'État agrégé',
|
||||||
|
name: 'HealthResponse',
|
||||||
|
schema: HealthResponseSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
5
packages/shared/src/index.ts
Normal file
5
packages/shared/src/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './permissions';
|
||||||
|
export * from './schemas/auth';
|
||||||
|
export * from './schemas/users';
|
||||||
|
export * from './schemas/health';
|
||||||
|
export * from './contract';
|
||||||
53
packages/shared/src/permissions.ts
Normal file
53
packages/shared/src/permissions.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Référentiel identité & permissions (R0).
|
||||||
|
* La matrice rôles × objets × droits vit EN BASE (table Permission) ; ici ne
|
||||||
|
* vivent que les vocabulaires partagés entre l'API, le seed et les clients.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ROLE_NAMES = [
|
||||||
|
'Administrateur',
|
||||||
|
'Dispatcher',
|
||||||
|
'Technicien',
|
||||||
|
'Technicien limité',
|
||||||
|
'Gestionnaire',
|
||||||
|
'Demandeur',
|
||||||
|
'Vue seule',
|
||||||
|
] as const;
|
||||||
|
export type RoleName = (typeof ROLE_NAMES)[number];
|
||||||
|
|
||||||
|
/** Catégories d'objets de la matrice — la matrice est complète dès R0, les
|
||||||
|
* écrans correspondants arrivent release par release (R1 → R3). */
|
||||||
|
export const OBJECT_CATEGORIES = [
|
||||||
|
'WORK_ORDERS',
|
||||||
|
'REQUESTS',
|
||||||
|
'ASSETS',
|
||||||
|
'LOCATIONS',
|
||||||
|
'METERS',
|
||||||
|
'PARTS',
|
||||||
|
'PURCHASE_ORDERS',
|
||||||
|
'PEOPLE_TEAMS',
|
||||||
|
'ANALYTICS',
|
||||||
|
'SETTINGS',
|
||||||
|
] as const;
|
||||||
|
export type ObjectCategory = (typeof OBJECT_CATEGORIES)[number];
|
||||||
|
|
||||||
|
export const PERMISSION_RIGHTS = [
|
||||||
|
'view',
|
||||||
|
'viewOther',
|
||||||
|
'create',
|
||||||
|
'edit',
|
||||||
|
'delete',
|
||||||
|
] as const;
|
||||||
|
export type PermissionRight = (typeof PERMISSION_RIGHTS)[number];
|
||||||
|
|
||||||
|
export const PermissionEntrySchema = z.object({
|
||||||
|
objectCategory: z.enum(OBJECT_CATEGORIES),
|
||||||
|
canView: z.boolean(),
|
||||||
|
canViewOther: z.boolean(),
|
||||||
|
canCreate: z.boolean(),
|
||||||
|
canEdit: z.boolean(),
|
||||||
|
canDelete: z.boolean(),
|
||||||
|
});
|
||||||
|
export type PermissionEntry = z.infer<typeof PermissionEntrySchema>;
|
||||||
42
packages/shared/src/schemas/auth.ts
Normal file
42
packages/shared/src/schemas/auth.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { ROLE_NAMES } from '../permissions';
|
||||||
|
|
||||||
|
export const AuthUserSchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
email: z.email(),
|
||||||
|
displayName: z.string(),
|
||||||
|
role: z.object({ id: z.uuid(), name: z.enum(ROLE_NAMES) }),
|
||||||
|
isDemo: z.boolean(),
|
||||||
|
});
|
||||||
|
export type AuthUser = z.infer<typeof AuthUserSchema>;
|
||||||
|
|
||||||
|
export const LoginRequestSchema = z.object({
|
||||||
|
email: z.email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
export type LoginRequest = z.infer<typeof LoginRequestSchema>;
|
||||||
|
|
||||||
|
export const AuthResponseSchema = z.object({
|
||||||
|
accessToken: z.string(),
|
||||||
|
user: AuthUserSchema,
|
||||||
|
});
|
||||||
|
export type AuthResponse = z.infer<typeof AuthResponseSchema>;
|
||||||
|
|
||||||
|
/** ADR-002 — jamais de secret dans cette liste. */
|
||||||
|
export const DemoAccountSchema = z.object({
|
||||||
|
id: z.uuid(),
|
||||||
|
displayName: z.string(),
|
||||||
|
roleName: z.enum(ROLE_NAMES),
|
||||||
|
initials: z.string().min(1).max(3),
|
||||||
|
});
|
||||||
|
export type DemoAccount = z.infer<typeof DemoAccountSchema>;
|
||||||
|
|
||||||
|
export const DemoAccountsResponseSchema = z.object({
|
||||||
|
accounts: z.array(DemoAccountSchema),
|
||||||
|
});
|
||||||
|
export type DemoAccountsResponse = z.infer<typeof DemoAccountsResponseSchema>;
|
||||||
|
|
||||||
|
export const DemoLoginRequestSchema = z.object({
|
||||||
|
userId: z.uuid(),
|
||||||
|
});
|
||||||
|
export type DemoLoginRequest = z.infer<typeof DemoLoginRequestSchema>;
|
||||||
13
packages/shared/src/schemas/health.ts
Normal file
13
packages/shared/src/schemas/health.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const ServiceStateSchema = z.enum(['up', 'down']);
|
||||||
|
|
||||||
|
export const HealthResponseSchema = z.object({
|
||||||
|
status: z.enum(['ok', 'degraded']),
|
||||||
|
services: z.object({
|
||||||
|
database: ServiceStateSchema,
|
||||||
|
redis: ServiceStateSchema,
|
||||||
|
storage: ServiceStateSchema,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
export type HealthResponse = z.infer<typeof HealthResponseSchema>;
|
||||||
11
packages/shared/src/schemas/users.ts
Normal file
11
packages/shared/src/schemas/users.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { AuthUserSchema } from './auth';
|
||||||
|
import { PermissionEntrySchema } from '../permissions';
|
||||||
|
|
||||||
|
/** Profil courant : identité + matrice du rôle (le JWT, lui, ne porte jamais
|
||||||
|
* de droits — le web lit cette réponse pour adapter l'UI, l'API re-vérifie
|
||||||
|
* chaque requête via PermissionsGuard). */
|
||||||
|
export const MeResponseSchema = AuthUserSchema.extend({
|
||||||
|
permissions: z.array(PermissionEntrySchema),
|
||||||
|
});
|
||||||
|
export type MeResponse = z.infer<typeof MeResponseSchema>;
|
||||||
16
packages/shared/tsconfig.json
Normal file
16
packages/shared/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
6139
pnpm-lock.yaml
generated
Normal file
6139
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
pnpm-workspace.yaml
Normal file
13
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
|
# Autorisations explicites des scripts post-install (sécurité pnpm).
|
||||||
|
allowBuilds:
|
||||||
|
'@prisma/engines': true
|
||||||
|
argon2: true
|
||||||
|
prisma: true
|
||||||
|
esbuild: true
|
||||||
|
'@tailwindcss/oxide': true
|
||||||
|
unrs-resolver: true
|
||||||
|
'@scarf/scarf': false # télémétrie — bloquée
|
||||||
|
'@prisma/client': true
|
||||||
10
turbo.json
Normal file
10
turbo.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://turborepo.dev/schema.json",
|
||||||
|
"tasks": {
|
||||||
|
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
|
||||||
|
"dev": { "cache": false, "persistent": true },
|
||||||
|
"lint": { "dependsOn": ["^build"] },
|
||||||
|
"typecheck": { "dependsOn": ["^build"] },
|
||||||
|
"test": { "dependsOn": ["^build"] }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user