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:
pr-daaif
2026-07-15 21:55:04 +01:00
parent ddc9dc52b5
commit 8adb561b63
62 changed files with 8509 additions and 2 deletions

3
.gitignore vendored
View File

@@ -2,3 +2,6 @@
node_modules/
dist/
.env
.turbo/
coverage/
*.tsbuildinfo

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
24

View File

@@ -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.
- 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
- 🔄 **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 ».
- Dépôt GitHub : `siop-spelev/siop2` (privé). Jalons R0R5 créés.
- **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-`).
- **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 R0R5.

20
apps/api/.env.example Normal file
View 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
View 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
View 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
View 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"
}
}

View File

@@ -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;

View 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"

View 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
View 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 */

View 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 },
],
};
}
}

View 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);
}
}

View 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 {}

View 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 nest 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('');
}
}

View 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;
},
);

View 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);
}
}

View 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.',
);
}
}

View 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é');
}
}
}

View 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);

View 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;
}
}

View 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);
});
});

View 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).',
);
}
}

View 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>;
}

View 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 {}

View 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();
}
}

View 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';
}
}
}

View 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
View 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();

View 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;
}
}

View 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 {}

View 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à lexpiration 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);
});
});

View 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();
}
}

View 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);

View 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 {}

View 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();
}
}

View 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),
};
}
}

View File

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

View 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('lAPI 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);
});
});

View 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);
});
});

View 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';

View File

@@ -0,0 +1,5 @@
{
"extends": "./tsconfig.json",
"include": ["src"],
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
}

18
apps/api/tsconfig.json Normal file
View 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"]
}

View File

@@ -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
**Actions**

479
docs/openapi.json Normal file
View 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 nest 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 nest 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 nest 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
View 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
View 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:

View 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
View 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
View 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"
}
}

View 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"
}
}

View 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 nest 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)`);

View 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 nest 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,
},
},
},
];

View File

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

View 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>;

View 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>;

View 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>;

View 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>;

View 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

File diff suppressed because it is too large Load Diff

13
pnpm-workspace.yaml Normal file
View 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
View 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"] }
}
}