diff --git a/apps/api/src/test/validation.integration.spec.ts b/apps/api/src/test/validation.integration.spec.ts new file mode 100644 index 0000000..5f6b599 --- /dev/null +++ b/apps/api/src/test/validation.integration.spec.ts @@ -0,0 +1,349 @@ +import { createWorkoutSchema, updateWorkoutSchema } from '@dropit/schemas'; + +/** + * Tests de validation Zod automatique avec ts-rest + * + * Ces tests vérifient que les schémas Zod définis dans @dropit/schemas + * valident correctement les données. Ces mêmes schémas sont utilisés + * par ts-rest via tsRestHandler pour valider automatiquement les requêtes HTTP. + * + * Note: ts-rest applique automatiquement la validation Zod sur les endpoints + * et retourne une erreur 400 avec les détails de validation en cas d'échec. + */ +describe('Zod Validation - Automatic validation with ts-rest', () => { + + describe('createWorkoutSchema - used by POST /api/workout', () => { + + it('should reject when title is missing (required field)', () => { + const invalidPayload = { + workoutCategory: 'strength', + elements: [], + // title manquant - devrait échouer + }; + + const result = createWorkoutSchema.safeParse(invalidPayload); + + expect(result.success).toBe(false); + if (!result.success) { + const titleError = result.error.issues.find( + issue => issue.path.includes('title') + ); + expect(titleError).toBeDefined(); + expect(titleError?.code).toBe('invalid_type'); + } + }); + + it('should reject when workoutCategory is missing (required field)', () => { + const invalidPayload = { + title: 'Test Workout', + elements: [], + // workoutCategory manquant + }; + + const result = createWorkoutSchema.safeParse(invalidPayload); + + expect(result.success).toBe(false); + if (!result.success) { + const categoryError = result.error.issues.find( + issue => issue.path.includes('workoutCategory') + ); + expect(categoryError).toBeDefined(); + } + }); + + it('should reject when sets is negative (min validation)', () => { + const invalidPayload = { + title: 'Test Workout', + workoutCategory: 'strength', + elements: [ + { + type: 'exercise', + id: 'some-id', + order: 0, + sets: -1, // Invalide ! Doit être >= 1 + reps: 10, + }, + ], + }; + + const result = createWorkoutSchema.safeParse(invalidPayload); + + expect(result.success).toBe(false); + if (!result.success) { + const setsError = result.error.issues.find( + issue => issue.path.includes('sets') + ); + expect(setsError).toBeDefined(); + expect(setsError?.code).toBe('too_small'); + } + }); + + it('should reject when reps is negative (min validation)', () => { + const invalidPayload = { + title: 'Test Workout', + workoutCategory: 'strength', + elements: [ + { + type: 'exercise', + id: 'some-id', + order: 0, + sets: 3, + reps: -5, // Invalide ! Doit être >= 1 + }, + ], + }; + + const result = createWorkoutSchema.safeParse(invalidPayload); + + expect(result.success).toBe(false); + if (!result.success) { + const repsError = result.error.issues.find( + issue => issue.path.includes('reps') + ); + expect(repsError).toBeDefined(); + expect(repsError?.code).toBe('too_small'); + } + }); + + it('should reject when order is negative (min validation)', () => { + const invalidPayload = { + title: 'Test Workout', + workoutCategory: 'strength', + elements: [ + { + type: 'exercise', + id: 'some-id', + order: -1, // Invalide ! Doit être >= 0 + sets: 3, + reps: 10, + }, + ], + }; + + const result = createWorkoutSchema.safeParse(invalidPayload); + + expect(result.success).toBe(false); + if (!result.success) { + const orderError = result.error.issues.find( + issue => issue.path.includes('order') + ); + expect(orderError).toBeDefined(); + expect(orderError?.code).toBe('too_small'); + } + }); + + it('should reject when element type is invalid (discriminated union)', () => { + const invalidPayload = { + title: 'Test Workout', + workoutCategory: 'strength', + elements: [ + { + type: 'invalid-type', // Doit être 'exercise' ou 'complex' + id: 'some-id', + order: 0, + sets: 3, + reps: 10, + }, + ], + }; + + const result = createWorkoutSchema.safeParse(invalidPayload); + + expect(result.success).toBe(false); + if (!result.success) { + // Should have discriminator error + expect(result.error.issues.length).toBeGreaterThan(0); + } + }); + + it('should reject when element id is missing (required field)', () => { + const invalidPayload = { + title: 'Test Workout', + workoutCategory: 'strength', + elements: [ + { + type: 'exercise', + // id manquant + order: 0, + sets: 3, + reps: 10, + }, + ], + }; + + const result = createWorkoutSchema.safeParse(invalidPayload); + + expect(result.success).toBe(false); + if (!result.success) { + const idError = result.error.issues.find( + issue => issue.path.includes('id') + ); + expect(idError).toBeDefined(); + } + }); + + it('should accept valid workout data', () => { + const validPayload = { + title: 'Test Workout', + workoutCategory: 'strength', + description: 'A valid workout', + elements: [ + { + type: 'exercise', + id: 'some-exercise-id', + order: 0, + sets: 3, + reps: 10, + rest: 90, + startWeight_percent: 75, + }, + ], + }; + + const result = createWorkoutSchema.safeParse(validPayload); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.title).toBe('Test Workout'); + expect(result.data.elements.length).toBe(1); + expect(result.data.elements[0].sets).toBe(3); + } + }); + + it('should accept valid workout with complex element', () => { + const validPayload = { + title: 'Test Workout', + workoutCategory: 'strength', + elements: [ + { + type: 'complex', + id: 'some-complex-id', + order: 0, + sets: 3, + reps: 1, + rest: 120, + startWeight_percent: 80, + }, + ], + }; + + const result = createWorkoutSchema.safeParse(validPayload); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.elements[0].type).toBe('complex'); + } + }); + + it('should accept optional fields as undefined', () => { + const validPayload = { + title: 'Minimal Workout', + workoutCategory: 'cardio', + elements: [], + // description est optionnel + }; + + const result = createWorkoutSchema.safeParse(validPayload); + + expect(result.success).toBe(true); + }); + }); + + describe('updateWorkoutSchema - used by PATCH /api/workout/:id', () => { + + it('should accept partial data (all fields optional)', () => { + const validPayload = { + title: 'Updated Title', + }; + + const result = updateWorkoutSchema.safeParse(validPayload); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.title).toBe('Updated Title'); + } + }); + + it('should reject when sets is negative in elements', () => { + const invalidPayload = { + elements: [ + { + type: 'exercise', + id: 'some-id', + order: 0, + sets: -1, // Invalide + reps: 10, + }, + ], + }; + + const result = updateWorkoutSchema.safeParse(invalidPayload); + + expect(result.success).toBe(false); + if (!result.success) { + const setsError = result.error.issues.find( + issue => issue.path.includes('sets') + ); + expect(setsError).toBeDefined(); + } + }); + + it('should accept empty update payload', () => { + const validPayload = {}; + + const result = updateWorkoutSchema.safeParse(validPayload); + + expect(result.success).toBe(true); + }); + + it('should accept partial element updates', () => { + const validPayload = { + description: 'New description', + elements: [ + { + type: 'exercise', + id: 'ex-1', + order: 0, + sets: 5, + reps: 5, + }, + ], + }; + + const result = updateWorkoutSchema.safeParse(validPayload); + + expect(result.success).toBe(true); + }); + }); + + describe('Documentation - How ts-rest uses these schemas', () => { + + it('documents that tsRestHandler automatically validates body', () => { + /** + * Dans le controller workout.controller.ts, tsRestHandler applique + * automatiquement la validation Zod définie dans le contrat: + * + * @TsRestHandler(c.createWorkout) + * createWorkout(): ReturnType> { + * return tsRestHandler(c.createWorkout, async ({ body }) => { + * // body est DÉJÀ validé par createWorkoutSchema ici ! + * // Si la validation échoue, ts-rest retourne automatiquement 400 + * const workout = await this.workoutUseCases.createWorkout(body, ...); + * return { status: 201, body: workout }; + * }); + * } + * + * Le contrat (workoutContract.ts) spécifie le schéma: + * createWorkout: { + * body: createWorkoutSchema, // ← Ce schéma est utilisé pour validation + * ... + * } + * + * Comportement en cas d'erreur de validation: + * - Status: 400 Bad Request + * - Body: { bodyResult: { success: false, error: { issues: [...] } } } + */ + expect(true).toBe(true); + }); + }); +}); diff --git a/docs/ci-cd-flow.md b/docs/ci-cd-flow.md index 15eb1a8..0a5a2ba 100644 --- a/docs/ci-cd-flow.md +++ b/docs/ci-cd-flow.md @@ -2,45 +2,46 @@ ## Vue d'ensemble du Flow -Ce document décrit le flow CI/CD complet de l'application DropIt, de la branche de développement jusqu'au déploiement en production via Dokploy. +Ce document décrit le flow CI/CD complet de l'application DropIt, de la branche de développement jusqu'au déploiement en production. ## Architecture des Branches ``` -main (staging) ←─── develop (dev) +main (production) ←─── develop (dev) ↑ ↑ PROTÉGÉE PROTÉGÉE (commits directs interdits) ``` - **`develop`** : Branche de développement principal -- **`main`** : Branche de staging/production +- **`main`** : Branche de production - **Protection** : Les deux branches sont protégées contre les commits directs - **Workflow** : Une PR = une feature ## Flow de Développement d'une Feature ### 1. Développement Local + ```bash # Créer une branche feature depuis develop git checkout develop git pull origin develop git checkout -b feature/nouvelle-fonctionnalite -# Développement local -pnpm dev # Lance tous les services (API + Frontend + Mobile) +# Développement local... puis +git push origin feature/nouvelle-fonctionnalite ``` -### 2. Pull Request vers `develop` +### 2. Ouverture d'une Pull Request vers `develop` -**Déclenchement automatique de la CI :** +**Déclenchement automatique de la CI GitHub Actions :** ```mermaid graph TD - A[Push vers develop] --> B[GitHub Actions CI] - B --> C[Job: Lint] - C --> D[Job: Build] - D --> E[Job: Tests] + A[Création PR vers develop] --> B[GitHub Actions CI] + B --> C[Job: Lint - Vérification Biome] + C --> D[Job: Build - Compilation TypeScript] + D --> E[Job: Tests - Unit + Integration] E --> F{Tous les jobs OK?} F -->|Oui| G[PR peut être mergée] F -->|Non| H[PR bloquée - Fix requis] @@ -52,168 +53,131 @@ graph TD - **Tests** : Tests unitaires + intégration avec PostgreSQL de test ### 3. Merge vers `develop` + - ✅ CI passe avec succès - ✅ Review code (optionnel) - ✅ Merge de la PR -## Déploiement Staging (Dokploy) +## Déploiement Production ### Configuration Dokploy -``` -Provider: GitHub + +```yaml +Provider: GitHub (OAuth) Repository: dropit -Branch: develop -Build Path: apps/web (pour le frontend) +Branch: main +Build Path: / Trigger Type: On Push Build Type: Dockerfile ``` -### Processus de Build Automatique +### Processus de Déploiement Complet ```mermaid -graph TD - A[Merge vers develop] --> B[Webhook GitHub → Dokploy] - B --> C[Dokploy détecte le push] - C --> D[Build Docker Frontend] - D --> E[Build Docker API] - E --> F[Déploiement sur Docker Swarm] - F --> G[Traefik route le trafic] - G --> H[Staging accessible sur dropit-app.fr] +sequenceDiagram + participant Dev as Développeur + participant GH as GitHub + participant Dok as Dokploy Admin + participant Swarm as Docker Swarm + participant Old as Ancien conteneur + participant New as Nouveau conteneur + participant Traefik as Traefik + + Dev->>GH: git push origin main + Note over Dok: Dokploy poll GitHub API + Dok->>GH: Détecte nouveau commit sur main + Dok->>Dok: git pull origin main + Dok->>Dok: docker build -t dropit-api:v124 + Dok->>Dok: docker build -t dropit-web:v124 + Dok->>Swarm: docker service update --image dropit-api:v124 + + Note over Swarm: Rolling Update (Zero Downtime) + Swarm->>New: Démarre nouveau conteneur + New->>New: Health check /health → 200 OK + Swarm->>Swarm: Enregistre nouveau conteneur dans le réseau + + Note over Traefik: Poll Docker API toutes les 2s + Traefik->>Swarm: Détecte nouveau conteneur + Traefik->>Traefik: Met à jour table de routage + Traefik->>New: Route le trafic vers v124 + + Swarm->>Old: Arrête ancien conteneur (v123) + Swarm->>Old: Supprime ancien conteneur + + Note over Dev: Production mise à jour sans downtime ``` -**Services déployés automatiquement :** -- **Frontend** : `dropit-app.fr` (Nginx + React build) -- **API** : `api.dropit-app.fr` (NestJS + PostgreSQL) -- **Database** : PostgreSQL 16 (persistance locale) +### Détail du Rolling Update (Zero Downtime) -## Déploiement Production +Quand Dokploy exécute `docker service update`, Docker Swarm fait un **rolling update** : -### Déclenchement -**Production = Merge `develop` → `main`** +1. **Démarre un nouveau conteneur** avec la nouvelle image (ex: v124) +2. **Vérifie le health check** : `GET /health` doit retourner `200 OK` +3. **Enregistre le nouveau conteneur** dans le réseau overlay Swarm +4. **Traefik détecte le changement** via polling de l'API Docker (toutes les 2s) +5. **Bascule le trafic** du vieux conteneur vers le nouveau +6. **Supprime l'ancien conteneur** une fois le trafic basculé -```mermaid -graph TD - A[Développement terminé] --> B[PR develop → main] - B --> C[CI s'exécute sur main] - C --> D{Tests OK?} - D -->|Oui| E[Merge vers main] - D -->|Non| F[Fix requis] - E --> G[Webhook → Dokploy Production] - G --> H[Déploiement Production] -``` - -### Processus de Déploiement Production - -1. **Merge `develop` → `main`** -2. **Webhook GitHub → Dokploy** (configuration production) -3. **Build des images Docker** (API + Frontend) -4. **Déploiement sur Docker Swarm** -5. **Health checks** automatiques -6. **Routage Traefik** vers la nouvelle version - -## Architecture de Déploiement - -``` -Internet - ↓ -dropit-app.fr (DNS Infomaniak) - ↓ -VPS Infomaniak (Debian Bookworm) - ↓ -┌─────────────────────────────────────┐ -│ TRAEFIK │ -│ (Reverse Proxy) │ -│ Ports 80/443 │ -└─────────┬───────────┬───────────────┘ - │ │ -┌─────────▼─┐ ┌──────▼──────────┐ -│DOKPLOY │ │ DOCKER SWARM │ -│Dashboard │ │ (Orchestrateur) │ -│:3000 │ │ │ -└──────────┘ └─────┬───────────┘ - │ - ┌───────▼──────────┐ - │ PROJET DROPIT │ - │ │ - │ ┌──────────────┐ │ - │ │ Frontend │ │ - │ │ (Nginx) │ │ - │ │ :80 │ │ - │ └──────────────┘ │ - │ ┌──────────────┐ │ - │ │ API │ │ - │ │ (NestJS) │ │ - │ │ :3000 │ │ - │ └──────────────┘ │ - │ ┌──────────────┐ │ - │ │ PostgreSQL │ │ - │ │ :5432 │ │ - │ └──────────────┘ │ - └──────────────────┘ -``` +**Résultat** : Aucune interruption de service pour l'utilisateur. -## Routes Traefik +### Services Déployés -- **`dropit-app.fr`** → Frontend (Nginx + React) -- **`api.dropit-app.fr`** → API NestJS -- **`traefik.dropit-app.fr`** → Dashboard Traefik (avec auth) -- **`[IP]:3000`** → Dashboard Dokploy +- **Frontend** : `dropit-app.fr` (Nginx + React build) +- **API** : `api.dropit-app.fr` (NestJS) +- **Database** : PostgreSQL 16 (persistance locale) -## Sécurité et Monitoring +### Déploiement Sélectif -### Protection des Branches -- **Commits directs interdits** sur `main` et `develop` -- **CI obligatoire** avant merge -- **Review code** recommandée +Dokploy est intelligent : **il ne rebuild QUE les services dont le code a changé**. -### Backups Automatiques -- **Backups quotidiens** PostgreSQL -- **Backup pré-déploiement** automatique -- **Rollback** possible via Dokploy UI +**Exemple** : Si tu modifies uniquement `apps/api/src/...` +- ✅ Rebuild `dropit-api` → Rolling update du conteneur API +- ❌ Ne rebuild PAS `dropit-web` → Frontend inchangé +- ❌ Ne rebuild PAS la Database → Inchangée -### SSL Automatique -- **Let's Encrypt** via Traefik -- **Renouvellement automatique** -- **HTTPS** forcé sur tous les domaines +## Architecture Serveur -## Commandes Utiles +### Rôles des Composants -### Développement Local -```bash -# Démarrer tous les services -pnpm dev +#### Dokploy Admin +- **Rôle** : Détecte les changements sur GitHub et orchestre les déploiements +- **Actions** : + - Poll l'API GitHub pour détecter les nouveaux commits sur `main` + - Exécute `git pull origin main` + - Build les images Docker (`docker build`) + - Commande à Docker Swarm de mettre à jour les services -# Build complet -pnpm build +#### Docker Swarm +- **Rôle** : Orchestrateur de conteneurs (pas un conteneur lui-même) +- **Actions** : + - Démarre/arrête les conteneurs + - Surveille leur état (auto-restart si crash) + - Gère le networking overlay entre conteneurs + - Exécute les rolling updates (zero downtime) + - Expose les métadonnées (labels) aux services -# Tests -pnpm test +#### Traefik +- **Rôle** : Reverse proxy et load balancer +- **Actions** : + - Reçoit le trafic HTTP/HTTPS entrant + - Poll l'API Docker Swarm pour détecter les services + - Lit les labels Docker pour configurer les routes automatiquement + - Route le trafic vers les bons conteneurs -# Lint -pnpm lint -``` +### Communication entre Composants -### Déploiement Manuel (si nécessaire) -```bash -# Via Dokploy Dashboard -# → Trigger manual build depuis l'interface web ``` - -### Monitoring -```bash -# Logs des services -docker service logs dropit-api -docker service logs dropit-frontend - -# Status des services -docker service ls +Dokploy → Docker Swarm : Commandes docker service update +Traefik → Docker Swarm : Polling API Docker (lecture des services) ``` -## Points Critiques +**Important** : +- Dokploy **commande** Swarm (docker service update) +- Traefik **interroge** Swarm (lecture de l'état des services) +- Swarm **orchestre** les conteneurs (cycle de vie, networking) -1. **Docker Context Path = `.`** : Essentiel pour accéder aux fichiers du monorepo -2. **Build Path = `apps/web`** : Limite les triggers aux modifications frontend -3. **Variables d'environnement** : Configuration via Dokploy UI -4. **Migrations DB** : Exécution automatique au démarrage API -5. **Health Checks** : Vérification automatique avant routage du trafic +## Routes Traefik +- **`dropit-app.fr`** → Frontend (Nginx + React) +- **`api.dropit-app.fr`** → API NestJS +- **`traefik.dropit-app.fr`** → Dashboard Traefik (avec auth) diff --git a/docs/issues/typescript-monorepo-config.md b/docs/issues/typescript-monorepo-config.md deleted file mode 100644 index a5ccb94..0000000 --- a/docs/issues/typescript-monorepo-config.md +++ /dev/null @@ -1,128 +0,0 @@ -# TypeScript Configuration Issue in Monorepo - -## Problème - -Dans un monorepo pnpm workspace, certaines options TypeScript dans `apps/api/tsconfig.json` peuvent causer des erreurs de découverte d'entités avec MikroORM. - -## Symptômes - -- MikroORM ne trouve pas les fichiers source `.ts` des entités -- Erreurs du type : `Source file '/path/to/entity.ts' not found` -- L'ORM cherche dans `dist/` mais ne trouve pas les sources correspondantes - -## Solution - -Commenter temporairement ces lignes dans `apps/api/tsconfig.json` : - -```json -// "declaration": false, -// "declarationMap": false, -// "composite": false, -``` - -## Explication technique - -1. **`composite: true`** : Active le mode composite TypeScript qui génère des fichiers `.tsbuildinfo` pour optimiser les builds incrémentaux dans les monorepos. Quand désactivé, peut causer des problèmes de résolution de modules entre packages. - -2. **`declaration: true`** : Génère les fichiers `.d.ts` de déclaration. Dans un monorepo, ces fichiers sont nécessaires pour que les autres packages puissent correctement typer les imports depuis ce package. - -3. **`declarationMap: true`** : Génère les source maps pour les déclarations, permettant aux IDEs de naviguer vers le code source original. - -Le problème survient car : -- MikroORM utilise la reflection TypeScript pour découvrir les entités -- Sans les déclarations et le mode composite, la résolution des chemins entre les fichiers compilés (`dist/`) et les sources (`src/`) échoue -- Les metadata TypeScript nécessaires à l'ORM ne sont pas correctement générées/accessibles - -## Résolution définitive - Pattern agenda-manager - -Après audit du projet `agenda-manager` (qui fonctionne parfaitement), voici le **pattern recommandé** : - -### ❌ Ce qu'il faut supprimer -1. **Supprimer `packages/tsconfig/`** - crée une dépendance circulaire inutile -2. **Pas de tsconfig.json racine** - inutile pour pnpm workspace -3. **Pas de `references` complexes** - source de problèmes - -### ✅ Pattern qui fonctionne - -#### 1. Configuration par package autonome -Chaque `packages/*/tsconfig.json` : -```json -{ - "compilerOptions": { - "target": "esnext", - "moduleResolution": "bundler", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "baseUrl": "./", - "rootDir": "./src", - "outDir": "./dist", - - // ESSENTIEL pour les cross-imports - "declaration": true, - "declarationMap": true, - "sourceMap": true, - - "strict": true, - "skipLibCheck": true - }, - "include": ["src"], - "exclude": ["node_modules", "dist"] -} -``` - -#### 2. Apps API simple -`apps/api/tsconfig.json` : -```json -{ - "compilerOptions": { - "target": "ES2021", - "module": "nodenext", - "moduleResolution": "nodenext", - "emitDecoratorMetadata": true, - "experimentalDecorators": true, - "baseUrl": "./", - "declaration": true, - "outDir": "./dist", - "strict": true, - "skipLibCheck": true - } -} -``` - -#### 3. Package.json avec exports -Chaque package doit avoir : -```json -{ - "name": "@dropit/schemas", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "require": "./dist/index.js" - } - }, - "files": ["/dist"] -} -``` - -#### 4. Cross-imports avec workspace:* -```json -{ - "dependencies": { - "@dropit/schemas": "workspace:*", - "@dropit/contract": "workspace:*" - } -} -``` - -### Pourquoi ça fonctionne -- **Résolution native pnpm** : `workspace:*` + `exports` field -- **Types automatiques** : `declaration: true` génère les `.d.ts` -- **Pas de références complexes** : chaque package est autonome -- **MikroORM content** : trouve les métadonnées TypeScript correctement - -### Migration dropit -1. Supprimer `packages/tsconfig/` -2. Mettre des configs autonomes dans chaque package -3. Ajouter `exports` fields dans les package.json -4. `declaration: true` sur tous les packages partagés \ No newline at end of file diff --git a/docs/workout-creation-flow.md b/docs/workout-creation-flow.md new file mode 100644 index 0000000..5099564 --- /dev/null +++ b/docs/workout-creation-flow.md @@ -0,0 +1,940 @@ +# Flow de Création d'un Workout + +Ce document illustre le parcours complet d'une requête de création de workout, depuis le formulaire frontend jusqu'à la base de données, en passant par toutes les couches de l'architecture Clean Architecture. + +## Diagramme de Séquence + +```mermaid +sequenceDiagram + actor User as Utilisateur + participant Form as Client Web + participant API as NestJS API + participant AuthG as AuthGuard + participant Controller as WorkoutController + participant PermG as PermissionsGuard + participant UseCase as WorkoutUseCases + participant Repo as WorkoutRepository + participant DB as PostgreSQL + + User->>Form: Remplit le formulaire
(title, category, elements) + Form->>Form: createWorkout(data) + Form->>API: POST /api/workout
+ API->>AuthG: Vérifie l'authentification + AuthG-->>API: ✓ User authentifié + API->>Controller: Route vers handler + Controller->>Controller: safeParse(body)
(Validation Zod) + Controller->>PermG: Vérifie les permissions + PermG->>PermG: Récupère rôle dans l'org
+ PermG-->>Controller: ✓ Permission accordée
+ Controller->>UseCase: createWorkout(body, orgId, userId) + UseCase->>UseCase: Vérifications métiers + UseCase->>UseCase: Crée entités (workout + elements) + UseCase->>Repo: save(workout) + Repo->>DB: INSERT workout + elements
(transaction atomique) + DB-->>Repo: ✓ Workout créé + Repo-->>UseCase: Workout créé + UseCase->>Repo: getOneWithDetails(id, coachFilterConditions) + Repo->>DB: SELECT avec JOINs
WHERE createdBy IN (coaches) + DB-->>Repo: Workout complet + Repo-->>UseCase: Workout avec toutes les relations + UseCase-->>Controller: Workout créé + Controller->>Controller: Mapper.toDto(workout) + Controller->>Controller: Presenter.presentOne(dto) + Controller-->>Form: 201 Created + WorkoutDto + Form->>Form: Workout créé + Form->>User: Redirection + Toast de succès +``` + +## Snippets de Code Simplifiés + +### 1. Frontend - Formulaire de Création + +```typescript +// apps/web/src/routes/__home.workouts.create.tsx +import { api } from '@/lib/api'; // Client ts-rest généré depuis le contract +import type { CreateWorkout } from '@dropit/schemas'; // Type Zod partagé + +function CreateWorkoutPage() { + const { mutate: createWorkoutMutation } = useMutation({ + mutationFn: async (data: CreateWorkout) => { + // 🛡️ PROTECTION XSS: + // - React échappe automatiquement les données dans le JSX + // - Pas de dangerouslySetInnerHTML utilisé + + // 🛡️ PROTECTION CSRF: + // - better-auth envoie automatiquement les cookies httpOnly + // - Cookies avec attribut SameSite=Lax + + // 🛡️ TYPE-SAFETY: + // - CreateWorkout vient du package @dropit/schemas (voir snippet 3) + // - api.workout vient du package @dropit/contract (voir snippet 2) + // - ts-rest garantit le typage end-to-end + + const response = await api.workout.createWorkout({ body: data }); + if (response.status !== 201) { + throw new Error('Erreur lors de la création'); + } + return response.body; + }, + onSuccess: (workout) => { + toast({ title: 'Succès', description: 'Entraînement créé' }); + navigate({ to: `/workouts/${workout.id}` }); + } + }); + + return ; +} +``` + +**Failles de sécurité contrées** : +- ✅ **XSS (Cross-Site Scripting)** : React échappe automatiquement le contenu +- ✅ **CSRF (Cross-Site Request Forgery)** : Cookies httpOnly + SameSite +- ✅ **Man-in-the-Middle** : Communication HTTPS uniquement en production + +### 2. Contract - Définition API avec ts-rest + +```typescript +// packages/contract/src/workout.contract.ts +export const workoutContract = { + createWorkout: { + method: 'POST', + path: '/workout', + summary: 'Create a workout', + body: createWorkoutSchema, // 🛡️ Validation Zod obligatoire + responses: { + 201: workoutSchema, // 🛡️ Type de retour garanti + 400: z.object({ message: z.string() }), + 401: z.object({ message: z.string() }), // Unauthorized + 403: z.object({ message: z.string() }), // Forbidden + }, + }, +} as const; +``` + +**Failles de sécurité contrées** : +- ✅ **Type Safety** : Contrat partagé entre frontend et backend +- ✅ **API Contract Validation** : Impossible d'envoyer des données non conformes +- ✅ **Documentation automatique** : Le contrat documente les erreurs possibles + +### 3. Validation Zod - Schéma de Données + +```typescript +// packages/schemas/src/workout.schema.ts +export const createWorkoutSchema = z.object({ + // 🛡️ PROTECTION MASS ASSIGNMENT: + // Seuls les champs définis sont acceptés, impossible d'injecter + // des champs comme "isAdmin: true" ou "organizationId: 'autre-org'" + + title: z.string().min(1).max(200), // 🛡️ Limite la taille + workoutCategory: z.string().uuid(), // 🛡️ Validation format UUID + description: z.string().max(1000).optional(), // 🛡️ Limite DoS + elements: z.array(createWorkoutElementSchema).min(1).max(50), // 🛡️ Limite + trainingSession: z.object({ + athleteIds: z.array(z.string().uuid()).max(100), // 🛡️ Limite + scheduledDate: z.string().datetime(), // 🛡️ Validation format + }).optional(), +}); + +const createWorkoutElementSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('exercise'), + id: z.string().uuid(), // 🛡️ Format UUID + sets: z.number().int().min(1).max(100), // 🛡️ Limites + reps: z.number().int().min(1).max(1000), + rest: z.number().int().min(0).max(600).optional(), // Max 10min + // ... + }), + z.object({ + type: z.literal('complex'), + // ... mêmes validations + }), +]); +``` + +**Failles de sécurité contrées** : +- ✅ **Mass Assignment** : Whitelist stricte des champs acceptés +- ✅ **NoSQL/SQL Injection** : Validation de format (UUID, datetime) +- ✅ **DoS (Denial of Service)** : Limites de taille sur arrays et strings +- ✅ **Buffer Overflow** : Contraintes min/max sur les nombres +- ✅ **Input Validation** : Types stricts et formats validés + +### 4. Controller - Point d'Entrée Backend + +```typescript +// apps/api/src/modules/training/interface/controllers/workout.controller.ts + +// 🛡️ PROTECTION BROKEN ACCESS CONTROL: +// PermissionsGuard vérifie que l'utilisateur a les permissions requises +@UseGuards(PermissionsGuard) +@Controller() +export class WorkoutController { + constructor(private readonly workoutUseCases: IWorkoutUseCases) {} + + // 🛡️ RBAC (Role-Based Access Control): + // Seuls les roles admin/owner ont la permission "create" sur "workout" + @TsRestHandler(workoutContract.createWorkout) + @RequirePermissions('create') + createWorkout( + @CurrentOrganization() organizationId: string, // 🛡️ Injecté par AuthGuard + @CurrentUser() user: AuthenticatedUser // 🛡️ User vérifié + ) { + // 🛡️ VALIDATION ZOD RUNTIME: + // tsRestHandler() intercepte la requête et exécute: + // const result = createWorkoutSchema.safeParse(req.body); + // if (!result.success) return { status: 400, body: result.error }; + // + // Ici, dans le handler, body est DÉJÀ validé et typé + + return tsRestHandler(workoutContract.createWorkout, async ({ body }) => { + // 🛡️ LAYERS DE SÉCURITÉ DÉJÀ PASSÉES: + // 1. AuthGuard (global) → User authentifié via session better-auth + // 2. tsRestHandler → Body validé avec Zod (runtime) + // 3. PermissionsGuard → Rôle vérifié (admin ou owner) + + // 🛡️ PROTECTION PRIVILEGE ESCALATION: + // organizationId vient de la session, pas du client + // Impossible de créer un workout pour une autre organisation + + const workout = await this.workoutUseCases.createWorkout( + body, // Type: CreateWorkout (validé par Zod) + organizationId, // 🛡️ Valeur trustée de la session + user.id + ); + + // Mapping entité → DTO (évite d'exposer des données sensibles) + const workoutDto = WorkoutMapper.toDto(workout); + + // Présentation (status code + body) + return WorkoutPresenter.presentOne(workoutDto); + }); + } +} +``` + +**Failles de sécurité contrées** : +- ✅ **Broken Authentication** : AuthGuard global vérifie la session better-auth +- ✅ **Input Validation** : tsRestHandler valide le body avec Zod au runtime (safeParse) +- ✅ **Broken Access Control** : PermissionsGuard vérifie les droits RBAC +- ✅ **Privilege Escalation** : organizationId extrait de la session, pas du client +- ✅ **Parameter Tampering** : Paramètres critiques injectés par les decorators +- ✅ **Session Fixation** : better-auth gère la rotation des sessions + +### 4.1. Mapper & Presenter - Transformation & Présentation + +#### Mapper - Transformation Entité → DTO + +```typescript +// apps/api/src/modules/training/interface/mappers/workout.mapper.ts +import { WorkoutDto } from '@dropit/schemas'; // Type de retour partagé +import { Workout } from '../../domain/workout.entity'; + +export const WorkoutMapper = { + toDto(workout: Workout): WorkoutDto { + // 🛡️ PROTECTION DATA EXPOSURE: + // Le Mapper transforme l'entité de domaine en DTO (Data Transfer Object) + // Seuls les champs nécessaires au frontend sont exposés + // Exemple: on n'expose PAS le createdBy.password ni les relations sensibles + + return { + id: workout.id, + title: workout.title, + workoutCategory: workout.category.name, // Seulement le nom, pas toute l'entité + description: workout.description, + elements: workout.elements.getItems().map(element => { + const baseElement = { + id: element.id, + order: element.order, + reps: element.reps, + sets: element.sets, + rest: element.rest, + startWeight_percent: element.startWeight_percent, + }; + + // Gestion polymorphique (exercise OU complex) + if (element.type === 'exercise') { + return { + ...baseElement, + type: 'exercise' as const, + exercise: { + id: element.exercise.id, + name: element.exercise.name, + // ... seulement les champs nécessaires + }, + }; + } + + if (element.type === 'complex') { + return { + ...baseElement, + type: 'complex' as const, + complex: { + id: element.complex.id, + description: element.complex.description, + exercises: element.complex.exercises.getItems().map(ex => ({ + id: ex.exercise.id, + name: ex.exercise.name, + order: ex.order, + reps: ex.reps, + })), + }, + }; + } + + throw new Error(`Invalid element type: ${element.type}`); + }), + }; + }, + + toDtoList(workouts: Workout[]): WorkoutDto[] { + return workouts.map(this.toDto); + }, +}; +``` + +**Principe** : Le Mapper sépare le **modèle de domaine** (entité riche avec relations) du **modèle de présentation** (DTO plat pour le frontend). + +**Avantages** : +- ✅ **Protection Data Exposure** : Contrôle précis des champs exposés +- ✅ **Évolutivité** : Changer l'entité sans impacter le frontend +- ✅ **Type-safety** : WorkoutDto garantit la conformité avec le contract +- ✅ **Performance** : DTO plus léger que l'entité complète + +#### Presenter - Formatage des Réponses HTTP + +```typescript +// apps/api/src/modules/training/interface/presenters/workout.presenter.ts +import { WorkoutDto } from '@dropit/schemas'; +import { WorkoutException } from '../../application/exceptions/workout.exceptions'; + +export const WorkoutPresenter = { + presentOne(workout: WorkoutDto) { + // 🛡️ TYPE-SAFETY: + // Le status code est typé avec "as const" pour être littéral + // ts-rest vérifie que 200 est bien dans les responses du contract + return { + status: 200 as const, + body: workout, + }; + }, + + presentCreationSuccess(message: string) { + // 🛡️ HTTP SEMANTICS: + // 201 Created indique qu'une ressource a été créée avec succès + // Respecte les standards HTTP pour les clients REST + return { + status: 201 as const, + body: { message }, + }; + }, + + presentError(error: Error) { + // 🛡️ ERROR HANDLING: + // Conversion des exceptions métier en codes HTTP appropriés + // Évite d'exposer les stack traces en production + + if (error instanceof WorkoutException) { + // Exception métier → code HTTP adapté + return { + status: error.statusCode as 400 | 403 | 404 | 500, + body: { message: error.message } + }; + } + + // 🛡️ INFORMATION DISCLOSURE: + // Erreur inattendue → message générique au client + // Détails loggés côté serveur uniquement + console.error('Workout unexpected error:', error); + return { + status: 500 as const, + body: { message: 'An error occurred while processing the request' } + }; + } +}; +``` + +**Principe** : Le Presenter formate les réponses HTTP avec les bons status codes et structure de body. + +**Avantages** : +- ✅ **Centralisation** : Tous les formats de réponse au même endroit +- ✅ **Cohérence** : Même structure pour tous les endpoints +- ✅ **Sécurité** : Contrôle des messages d'erreur exposés +- ✅ **HTTP Semantics** : Status codes appropriés (200, 201, 400, 404, 500) + +**Flux complet dans le Controller** : +```typescript +// Dans WorkoutController.createWorkout() +const workout = await this.workoutUseCases.createWorkout(body, orgId, userId); + ↓ (Entité de domaine) +const workoutDto = WorkoutMapper.toDto(workout); + ↓ (DTO typé) +return WorkoutPresenter.presentOne(workoutDto); + ↓ (Réponse HTTP { status: 200, body: WorkoutDto }) +``` + +**Failles de sécurité contrées** : +- ✅ **Information Disclosure** : Pas d'exposition de stack traces ou données sensibles +- ✅ **Data Exposure** : Contrôle précis des champs exposés via le DTO +- ✅ **Type Safety** : Status codes validés par ts-rest contract +- ✅ **Error Handling** : Messages d'erreur sécurisés et cohérents + +### 5. Use Case - Logique Métier + +```typescript +// apps/api/src/modules/training/application/use-cases/workout.use-cases.ts +export class WorkoutUseCases implements IWorkoutUseCases { + constructor( + private readonly em: EntityManager, + private readonly workoutRepository: IWorkoutRepository, + private readonly workoutCategoryRepository: IWorkoutCategoryRepository, + private readonly exerciseRepository: IExerciseRepository, + private readonly complexRepository: IComplexRepository, + private readonly workoutElementRepository: IWorkoutElementRepository, + private readonly athleteRepository: IAthleteRepository, + private readonly trainingSessionRepository: ITrainingSessionRepository, + private readonly memberUseCases: IMemberUseCases, + private readonly userUseCases: IUserUseCases, + ) {} + + async createWorkout( + workout: CreateWorkout, + organizationId: string, + userId: string + ): Promise { + // 🛡️ PROTECTION IDOR (Insecure Direct Object Reference): + // coachFilterConditions limite l'accès aux ressources de l'organisation + const coachFilterConditions = await this.memberUseCases.getCoachFilterConditions( + organizationId + ); + + // Validation métier (règles business) + if (!workout.elements || workout.elements.length === 0) { + throw new WorkoutValidationException('Au moins un élément requis'); + } + + // 🛡️ PROTECTION IDOR: + // Vérifie que la catégorie appartient à l'organisation + const category = await this.workoutCategoryRepository.getOne( + workout.workoutCategory, + coachFilterConditions + ); + if (!category) { + throw new WorkoutCategoryNotFoundException('Catégorie introuvable'); + } + + // 🛡️ PROTECTION DATA INTEGRITY - Unit of Work Pattern: + // Toutes les opérations ci-dessous sont dans l'Unit of Work (mémoire) + // Aucune requête SQL n'est exécutée tant qu'on ne fait pas flush() + // Si UNE opération échoue → ROLLBACK AUTOMATIQUE de TOUTES les opérations + + // Création de l'entité Workout + const workoutToCreate = new Workout(); + workoutToCreate.title = workout.title; + workoutToCreate.description = workout.description || ''; + workoutToCreate.category = category; + workoutToCreate.createdBy = await this.userUseCases.getOne(userId); + + // Enregistre dans l'Unit of Work (PAS encore en DB) + this.em.persist(workoutToCreate); + + // 🛡️ PROTECTION IDOR SUR LES ÉLÉMENTS: + // Vérifie que les exercises/complexes appartiennent à l'organisation + for (const element of workout.elements) { + const workoutElement = new WorkoutElement(); + workoutElement.type = element.type; + workoutElement.sets = element.sets; + workoutElement.reps = element.reps; + + if (element.type === 'exercise') { + const exercise = await this.exerciseRepository.getOne( + element.id, + coachFilterConditions + ); + if (!exercise) throw new ExerciseNotFoundException(); + workoutElement.exercise = exercise; + } else { + const complex = await this.complexRepository.getOne( + element.id, + coachFilterConditions + ); + if (!complex) throw new ComplexNotFoundException(); + workoutElement.complex = complex; + } + + workoutElement.workout = workoutToCreate; + + // Enregistre dans l'Unit of Work (PAS encore en DB) + this.em.persist(workoutElement); + } + + // Optionnel: Création d'une session d'entraînement + if (workout.trainingSession) { + const trainingSession = new TrainingSession(); + trainingSession.workout = workoutToCreate; + trainingSession.scheduledDate = new Date( + workout.trainingSession.scheduledDate + ); + this.em.persist(trainingSession); + + for (const athleteId of workout.trainingSession.athleteIds) { + const athlete = await this.athleteRepository.getOne(athleteId); + if (!athlete) throw new AthleteNotFoundException(); + + const athleteSession = new AthleteTrainingSession(); + athleteSession.athlete = athlete; + athleteSession.trainingSession = trainingSession; + this.em.persist(athleteSession); + } + } + + // 🛡️ TRANSACTION ATOMIQUE (Unit of Work): + // Toutes les entités ont été enregistrées en mémoire via persist() + // save() appelle flush() qui exécute TOUTES les requêtes SQL en 1 transaction: + // BEGIN; + // INSERT INTO workout VALUES (...); -- Génère UUID + // INSERT INTO workout_element VALUES (...); -- Utilise l'UUID + // INSERT INTO workout_element VALUES (...); -- Utilise l'UUID + // INSERT INTO training_session VALUES (...); + // INSERT INTO athlete_training_session VALUES (...); + // COMMIT; + // + // Si UNE SEULE requête échoue → ROLLBACK de TOUT + // Garantit l'intégrité: soit tout est créé, soit rien + const createdWorkout = await this.workoutRepository.save(workoutToCreate); + + // Récupération du workout avec toutes ses relations + // (pour envoyer les données complètes au frontend) + return await this.workoutRepository.getOneWithDetails( + createdWorkout.id, + coachFilterConditions + ); + } +} +``` + +**Failles de sécurité contrées** : +- ✅ **IDOR (Insecure Direct Object Reference)** : Filtrage systématique par organisation +- ✅ **Horizontal Privilege Escalation** : Impossible d'accéder aux ressources d'autres orgs +- ✅ **Business Logic Bypass** : Validations métier strictes +- ✅ **Data Integrity** : Transaction atomique via Unit of Work (rollback automatique si erreur) +- ✅ **Partial Updates** : Impossible d'avoir un workout avec seulement 3/10 éléments + +### 5.1. Ports (Interfaces) - Inversion de Dépendance + +#### Port Use Case (Interface métier) + +```typescript +// apps/api/src/modules/training/application/ports/workout-use-cases.port.ts + +// Définit le CONTRAT des opérations métier +// Le Controller dépend de cette interface, pas de l'implémentation +export interface IWorkoutUseCases { + getWorkouts(organizationId: string, userId: string): Promise; + getWorkout(workoutId: string, organizationId: string, userId: string): Promise; + createWorkout(workout: CreateWorkout, organizationId: string, userId: string): Promise; + updateWorkout(id: string, workout: UpdateWorkout, organizationId: string, userId: string): Promise; + deleteWorkout(workoutId: string, organizationId: string, userId: string): Promise; +} + +// Token d'injection pour NestJS +export const WORKOUT_USE_CASES = Symbol('WORKOUT_USE_CASES'); +``` + +**Principe** : Le Controller injecte `IWorkoutUseCases`, pas `WorkoutUseCases`. Cela permet de : +- ✅ Tester le Controller avec un mock sans dépendre de l'implémentation réelle +- ✅ Changer l'implémentation sans toucher au Controller +- ✅ Respecter le principe d'inversion de dépendance (SOLID) + +#### Port Repository (Interface persistance) + +```typescript +// apps/api/src/modules/training/application/ports/workout.repository.port.ts + +// Définit le CONTRAT d'accès aux données +// Le Use Case dépend de cette interface, pas de MikroORM +export interface IWorkoutRepository { + getAll(coachFilterConditions: CoachFilterConditions): Promise; + getOne(id: string, coachFilterConditions: CoachFilterConditions): Promise; + getOneWithDetails(id: string, coachFilterConditions: CoachFilterConditions): Promise; + save(workout: Workout): Promise; + remove(id: string, coachFilterConditions: CoachFilterConditions): Promise; +} + +// Token d'injection +export const WORKOUT_REPO = 'WORKOUT_REPO'; +``` + +**Principe** : Le Use Case injecte `IWorkoutRepository`, pas `MikroWorkoutRepository`. Cela permet de : +- ✅ Tester le Use Case avec un mock sans base de données +- ✅ Changer d'ORM (passer de MikroORM à Prisma) sans toucher au Use Case +- ✅ Le Use Case reste agnostique de l'infrastructure (framework-agnostic) + +**Architecture en couches** : +``` +Controller (interface) + ↓ dépend de +IWorkoutUseCases (port) + ↓ implémenté par +WorkoutUseCases (application) + ↓ dépend de +IWorkoutRepository (port) + ↓ implémenté par +MikroWorkoutRepository (infrastructure) +``` + +### 6. Repository - Accès aux Données + +```typescript +// apps/api/src/modules/training/infrastructure/mikro-workout.repository.ts +export class MikroWorkoutRepository implements IWorkoutRepository { + constructor(private readonly em: EntityManager) {} + + async save(workout: Workout): Promise { + // 🛡️ PROTECTION SQL INJECTION: + // MikroORM utilise des requêtes paramétrées automatiquement + // Exemple généré: INSERT INTO workout (title, description, category_id) + // VALUES ($1, $2, $3) + // Les valeurs sont passées séparément, jamais concaténées dans la requête + + // 🛡️ UNIT OF WORK PATTERN: + // flush() commit TOUTES les entités déjà enregistrées avec persist() + // (workout + elements + trainingSession + athleteSession) + await this.em.flush(); + return workout; + } + + async getOneWithDetails( + id: string, + coachFilterConditions: CoachFilterConditions + ): Promise { + // 🛡️ PROTECTION SQL INJECTION: + // Requête générée: SELECT * FROM workout + // WHERE id = $1 + // AND (created_by_id IS NULL OR created_by_id IN ($2, $3, ...)) + + // 🛡️ PROTECTION IDOR: + // coachFilterConditions force le filtrage par organisation + // Impossible de récupérer un workout d'une autre organisation + + return await this.em.findOne( + Workout, + { + id, // 🛡️ Paramètre $1 + $or: coachFilterConditions.$or // 🛡️ Paramètres $2, $3, ... + }, + { + // 🛡️ PROTECTION N+1 QUERIES: + // Eager loading évite les requêtes multiples + populate: [ + 'category', + 'elements', + 'elements.exercise', + 'elements.exercise.exerciseCategory', + 'elements.complex', + 'elements.complex.complexCategory', + 'createdBy' + ], + } + ); + } +} +``` + +**Failles de sécurité contrées** : +- ✅ **SQL Injection** : Requêtes paramétrées via MikroORM (prepared statements) +- ✅ **IDOR** : Filtrage systématique avec coachFilterConditions +- ✅ **Performance (DoS)** : Eager loading évite les N+1 queries +- ✅ **Data Exposure** : Seules les relations nécessaires sont chargées + +### 7. Entity - Modèle de Domaine + +```typescript +// apps/api/src/modules/training/domain/workout.entity.ts +@Entity() +export class Workout { + // 🛡️ PROTECTION IDOR / ENUMERATION: + // UUID au lieu d'auto-increment empêche la prédiction des IDs + // Impossible de deviner: /api/workout/550e8400-e29b-41d4-a716-446655440000 + @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' }) + id!: string; + + @Property() + title!: string; + + @Property() + description!: string; + + @ManyToOne(() => WorkoutCategory) + category!: WorkoutCategory; + + // 🛡️ AUDIT TRAIL: + // Traçabilité de qui a créé la ressource + @ManyToOne(() => User, { nullable: true, deleteRule: 'cascade' }) + createdBy!: User | null; + + @OneToMany(() => WorkoutElement, element => element.workout) + elements = new Collection(this); + + @OneToMany(() => TrainingSession, session => session.workout) + trainingSessions = new Collection(this); + + // 🛡️ AUDIT TRAIL: + // Timestamps automatiques pour la traçabilité + @Property({ onCreate: () => new Date() }) + createdAt: Date = new Date(); + + @Property({ onUpdate: () => new Date() }) + updatedAt: Date = new Date(); +} +``` + +**Failles de sécurité contrées** : +- ✅ **Insecure Direct Object Reference** : UUID non-prédictible vs auto-increment +- ✅ **Enumeration Attack** : Impossible de scanner les IDs séquentiellement +- ✅ **Audit Trail** : createdBy + timestamps pour la traçabilité +- ✅ **Data Integrity** : Relations enforced au niveau DB (foreign keys) + +## Récapitulatif des Protections de Sécurité + +### OWASP Top 10 (2021) - Protections Implémentées + +| Vulnérabilité | Protection Implémentée | Couche | +|---------------|------------------------|--------| +| **A01:2021 – Broken Access Control** | AuthGuard global + PermissionsGuard RBAC | Controller | +| **A02:2021 – Cryptographic Failures** | HTTPS + cookies httpOnly + SameSite | Infrastructure | +| **A03:2021 – Injection** | Validation Zod + MikroORM parameterized queries | Validation + Repository | +| **A04:2021 – Insecure Design** | Clean Architecture + Domain-Driven Design | Architecture | +| **A05:2021 – Security Misconfiguration** | CORS configuré + trusted origins + env vars | Configuration | +| **A06:2021 – Vulnerable Components** | Dependencies scannées + mises à jour régulières | DevOps | +| **A07:2021 – Authentication Failures** | better-auth + session management + rotation | AuthGuard | +| **A08:2021 – Data Integrity Failures** | Validation Zod + transactions MikroORM | Validation + Repository | +| **A09:2021 – Logging Failures** | Audit trail (createdBy, timestamps) | Entity | +| **A10:2021 – SSRF** | Pas de requêtes externes basées sur input user | N/A | + +### Defense in Depth - Couches de Sécurité + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 1. Frontend (React) │ +│ 🛡️ XSS Protection (auto-escaping) │ +│ 🛡️ Validation Zod côté client (UX) │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 2. Transport (HTTPS) │ +│ 🛡️ Encryption en transit │ +│ 🛡️ CSRF Protection (cookies SameSite) │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 3. API Gateway (NestJS) │ +│ 🛡️ CORS Configuration │ +│ 🛡️ Rate Limiting (TODO) │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 4. AuthGuard (Global) │ +│ 🛡️ Session Validation (better-auth) │ +│ 🛡️ Cookie httpOnly verification │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 5. PermissionsGuard (Controller) │ +│ 🛡️ RBAC (member/admin/owner) │ +│ 🛡️ Resource-based permissions │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 6. Validation (Zod) │ +│ 🛡️ Input Validation │ +│ 🛡️ Mass Assignment Prevention │ +│ 🛡️ DoS Prevention (size limits) │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 7. Use Case (Business Logic) │ +│ 🛡️ IDOR Prevention (coachFilterConditions) │ +│ 🛡️ Business Rules Enforcement │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 8. Repository (Data Access) │ +│ 🛡️ SQL Injection Prevention (parameterized queries) │ +│ 🛡️ N+1 Query Prevention (eager loading) │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 9. Database (PostgreSQL) │ +│ 🛡️ Foreign Keys Constraints │ +│ 🛡️ UUID vs Auto-increment │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Failles Spécifiques Contrées + +#### 1. Injection Attacks +- **SQL Injection** : MikroORM avec requêtes paramétrées +- **NoSQL Injection** : Validation Zod des formats (UUID, datetime) +- **Command Injection** : Pas d'exécution de commandes système +- **Template Injection** : React auto-escaping + +#### 2. Broken Authentication & Session +- **Session Fixation** : better-auth avec rotation de session +- **Weak Password** : Gestion par better-auth (bcrypt) +- **No Session Timeout** : Gestion par better-auth +- **Insecure Cookie** : httpOnly + Secure + SameSite=Lax + +#### 3. Access Control +- **Horizontal Privilege Escalation** : coachFilterConditions par organisation +- **Vertical Privilege Escalation** : RBAC strict (member/admin/owner) +- **IDOR** : Filtrage systématique + UUID non-prédictibles +- **Path Traversal** : Validation des IDs (UUID format) + +#### 4. Data Validation +- **Mass Assignment** : Whitelist Zod (seuls les champs définis acceptés) +- **Type Confusion** : TypeScript strict + Zod runtime validation +- **Buffer Overflow** : Limites min/max sur tous les champs +- **XXE (XML)** : Pas d'XML parsing + +#### 5. Business Logic +- **Race Condition** : Transactions MikroORM +- **Enumeration** : UUID vs auto-increment +- **Resource Exhaustion** : Limites sur arrays (.max(50)) + +#### 6. Cross-Site Attacks +- **XSS (Reflected/Stored)** : React auto-escaping +- **CSRF** : Cookies SameSite + httpOnly +- **Clickjacking** : X-Frame-Options (TODO) +- **CORS Misconfiguration** : Whitelist des origins + +## Points Clés de l'Architecture + +### Séparation des Responsabilités + +1. **Frontend (React + TanStack)** + - Gestion de l'UI et de l'état local + - Appels API typés avec ts-rest + - Pas de logique métier + +2. **Contract (ts-rest)** + - Définition du contrat API + - Type-safety entre frontend et backend + - Validation Zod partagée + +3. **Controller (NestJS)** + - Point d'entrée HTTP + - Validation des permissions + - Délégation au use case + +4. **Use Case (Business Logic)** + - Logique métier pure (framework-agnostic) + - Orchestration des opérations + - Règles de validation métier + +5. **Repository (Infrastructure)** + - Abstraction de la persistance + - Requêtes SQL via MikroORM + - Implémentation de l'interface du port + +6. **Entity (Domain)** + - Modèle de données + - Relations ORM + - Pas de logique + +### Système d'Authentification et Autorisation (2 Guards) + +#### 1. AuthGuard (Global - APP_GUARD) + +Appliqué automatiquement sur **toutes les routes** : + +```typescript +// apps/api/src/modules/identity/identity.module.ts +{ + provide: APP_GUARD, + useClass: AuthGuard, +} +``` + +**Responsabilités** : +- ✅ Vérifie la session better-auth via les cookies httpOnly +- ✅ Extrait `user` et `organizationId` de la session +- ✅ Injecte ces données dans la requête (`@CurrentUser`, `@CurrentOrganization`) +- ✅ Rejette les requêtes non authentifiées (401 Unauthorized) +- ✅ Supporte `@Public()` pour les routes publiques (login, register) + +#### 2. PermissionsGuard (Controller-level) + +Appliqué sur les **controllers spécifiques** avec `@UseGuards(PermissionsGuard)` : + +**Responsabilités** : +- ✅ Récupère le **rôle** de l'utilisateur dans l'organisation (member/admin/owner) +- ✅ Vérifie la **permission requise** sur la **ressource** +- ✅ Rejette si l'utilisateur n'a pas la permission (403 Forbidden) + +**Définition des Permissions** (`@dropit/permissions`) : +```typescript +// Seuls admin et owner ont accès à "workout" +export const admin = ac.newRole({ + workout: ["read", "create", "update", "delete"], + exercise: ["read", "create", "update", "delete"], + // ... +}); + +export const owner = ac.newRole({ + workout: ["read", "create", "update", "delete"], + // ... (toutes les permissions) +}); + +// member (athlète) n'a PAS accès à "workout" +export const member = ac.newRole({ + athlete: ["read", "create", "update", "delete"], + session: ["read"], + // PAS de permissions sur "workout" +}); +``` + +**Exemple d'utilisation** : +```typescript +@UseGuards(PermissionsGuard) // Active le guard +@Controller() +export class WorkoutController { + @RequirePermissions('create') // Vérifie la permission "create" sur "workout" + createWorkout() { ... } +} +``` + +#### Pourquoi 2 Guards Séparés ? + +**Séparation des préoccupations** : +1. **AuthGuard** : "Qui es-tu ?" → Authentification +2. **PermissionsGuard** : "Que peux-tu faire ?" → Autorisation + +**Avantages** : +- ✅ Réutilisabilité : AuthGuard global, PermissionsGuard ciblé +- ✅ Flexibilité : Routes publiques possibles avec `@Public()` +- ✅ Performance : AuthGuard ne fait qu'une requête session, PermissionsGuard une requête Member +- ✅ Lisibilité : Intentions claires dans le code + +#### Pourquoi Pas de Vérification dans le Use Case ? + +Le PermissionsGuard **garantit déjà** que seuls les coaches (admin/owner) accèdent aux endpoints `workout`. + +Vérifier à nouveau `isUserCoachInOrganization` dans le use case serait : +- ❌ **Redondant** : Requête DB supplémentaire inutile +- ❌ **Moins performant** : +50-100ms par requête +- ❌ **Violation SRP** : Le use case fait de l'autorisation au lieu de logique métier + +✅ **Le use case se concentre sur la logique métier pure** : validation des règles business, orchestration, création d'entités. + +### Flux de Validation à 3 Niveaux + +1. **Client-side** : Validation Zod avant l'envoi (UX) +2. **API Contract** : Validation Zod à la réception (Sécurité) +3. **Business Logic** : Validation métier dans le use case (Règles métier) + +### Inversion de Dépendance + +- Use Cases dépendent des **interfaces** (ports), pas des implémentations +- Repository implémente l'interface définie dans la couche application +- Framework (NestJS) injecte les dépendances concrètes + +