╔══════════════════════════════════════════════════════════════════════════════════╗
║            GUIDE COMPLET SPRING BOOT — NIVEAU ENTREPRISE                         ║
║            PARTIE 1 : FONDATIONS JAVA & WEB                                      ║
║            Chapitres 1 à 5 : Java · POO · HTTP · JSON · REST                     ║
╚══════════════════════════════════════════════════════════════════════════════════╝

[OBJECTIF] PROJET FIL ROUGE : TaskFlow Backend
   Une plateforme SaaS de gestion de tâches construite pas à pas tout au long du guide.
   Stack : Spring Boot 3.x · PostgreSQL · JWT · Docker · Maven

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════╗
║   CHAPITRE 1 — JAVA : LES BASES INDISPENSABLES           ║
╚══════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Qu'est-ce que Java ?
─────────────────────
Java est un langage de programmation orienté objet, compilé et interprété, créé par
James Gosling chez Sun Microsystems en 1995. Son slogan historique : "Write Once, Run
Anywhere" (WORA). Java compile votre code source (.java) en bytecode (.class), qui est
ensuite exécuté par la JVM (Java Virtual Machine) sur n'importe quelle plateforme.

Pourquoi Java pour le backend ?
────────────────────────────────
• Typage statique -> détection d'erreurs à la compilation
• Performances excellentes (JIT Compiler)
• Écosystème mature : Maven, Gradle, Spring, Hibernate
• Largement utilisé en entreprise (banques, assurances, e-commerce)
• Spring Boot est LE framework Java backend de référence mondiale

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  INSTALLATION DE L'ENVIRONNEMENT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Prérequis :
• JDK 21 (LTS recommandé pour Spring Boot 3.x)
• IDE : IntelliJ IDEA Community (recommandé) ou VS Code + Extension Pack for Java
• Maven 3.9+
• Docker Desktop

Installation JDK 21 :
─────────────────────
# Sur Ubuntu/Debian
sudo apt update
sudo apt install openjdk-21-jdk

# Vérification
java --version
# -> java 21.0.x 2024-xx-xx

# Sur macOS (avec Homebrew)
brew install openjdk@21

# Sur Windows
# Télécharger depuis https://adoptium.net/ -> Eclipse Temurin 21

Configuration JAVA_HOME :
─────────────────────────
# Linux/macOS — ajouter dans ~/.bashrc ou ~/.zshrc
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
export PATH=$PATH:$JAVA_HOME/bin

# Vérification
echo $JAVA_HOME

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3⃣  SYNTAXE JAVA FONDAMENTALE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

3.1 Premier programme Java
──────────────────────────

// Fichier : HelloWorld.java
// Convention : nom du fichier = nom de la classe publique

public class HelloWorld {
    // main() est le point d'entrée de tout programme Java
    // public -> accessible partout
    // static -> appartient à la classe, pas à une instance
    // void -> ne retourne rien
    // String[] args -> arguments passés en ligne de commande
    public static void main(String[] args) {
        System.out.println("Bonjour, Spring Boot !");
        // System -> classe système standard
        // out -> flux de sortie standard (PrintStream)
        // println -> affiche + saut de ligne
    }
}

Compilation et exécution :
──────────────────────────
# Compiler
javac HelloWorld.java
# -> génère HelloWorld.class (bytecode)

# Exécuter
java HelloWorld
# Affiche : Bonjour, Spring Boot !

3.2 Types Primitifs
────────────────────

Java possède 8 types primitifs (stockés en mémoire pile, pas sur le tas) :

┌──────────┬──────────┬───────────────────────┬────────────────────┐
│  Type    │  Taille  │  Plage de valeurs     │  Exemple           │
├──────────┼──────────┼───────────────────────┼────────────────────┤
│  byte    │  8 bits  │  -128 à 127           │  byte b = 100;     │
│  short   │  16 bits │  -32768 à 32767       │  short s = 1000;   │
│  int     │  32 bits │  -2^31 à 2^31-1       │  int i = 42;       │
│  long    │  64 bits │  -2^63 à 2^63-1       │  long l = 100L;    │
│  float   │  32 bits │  ±3.4×10^38           │  float f = 3.14f;  │
│  double  │  64 bits │  ±1.7×10^308          │  double d = 3.14;  │
│  char    │  16 bits │  '\u0000' à '\uffff'  │  char c = 'A';     │
│  boolean │  1 bit   │  true / false         │  boolean ok=true;  │
└──────────┴──────────┴───────────────────────┴────────────────────┘

[ATTENTION] RÈGLE IMPORTANTE : En backend Spring Boot, vous utiliserez principalement :
   • int / long -> IDs, compteurs
   • double -> prix, calculs
   • boolean -> flags, états
   • String (pas primitif !) -> textes, noms

3.3 Types de Référence
───────────────────────

Contrairement aux primitifs, les types de référence stockent une adresse mémoire
vers un objet sur le tas (heap).

// String — chaîne de caractères
String nom = "TaskFlow";
String prenom = new String("Backend");  // rarement utilisé

// String est immuable (immutable) -> chaque modification crée un nouveau String
String s1 = "Hello";
String s2 = s1 + " World";  // s1 reste "Hello", s2 = "Hello World"

// Méthodes essentielles de String
String texte = "  Spring Boot  ";
texte.length()          // -> 15 (avec espaces)
texte.trim()            // -> "Spring Boot" (sans espaces)
texte.toUpperCase()     // -> "  SPRING BOOT  "
texte.toLowerCase()     // -> "  spring boot  "
texte.contains("Boot")  // -> true
texte.startsWith("  Sp")// -> true
texte.replace("Boot", "Framework")  // -> "  Spring Framework  "
texte.split(" ")        // -> tableau de String
texte.isEmpty()         // -> false
texte.isBlank()         // -> false (caractères non-blancs présents)
"".isEmpty()            // -> true
"   ".isBlank()         // -> true

// StringBuilder — pour construire des Strings efficacement
StringBuilder sb = new StringBuilder();
sb.append("Task");
sb.append("Flow");
sb.append(" v1.0");
String resultat = sb.toString();  // -> "TaskFlow v1.0"

// Comparaison de Strings — PIÈGE CLASSIQUE
String a = "hello";
String b = "hello";
String c = new String("hello");

System.out.println(a == b);          // -> true (même pool de littéraux)
System.out.println(a == c);          // -> false ! (objets différents)
System.out.println(a.equals(c));     // -> true [OK] TOUJOURS utiliser equals()
System.out.println(a.equalsIgnoreCase("HELLO")); // -> true

3.4 Structures de Contrôle
────────────────────────────

// IF - ELSE IF - ELSE
int score = 85;
if (score >= 90) {
    System.out.println("Excellent");
} else if (score >= 70) {
    System.out.println("Bien");     // <- exécuté
} else {
    System.out.println("Passable");
}

// Opérateur ternaire
String statut = (score >= 70) ? "Validé" : "Échoué";  // -> "Validé"

// SWITCH (Java 14+ avec expressions)
String role = "ADMIN";
String message = switch (role) {
    case "ADMIN"    -> "Accès total";
    case "USER"     -> "Accès limité";
    case "GUEST"    -> "Accès en lecture";
    default         -> "Rôle inconnu";
};
// -> "Accès total"

// WHILE
int compteur = 0;
while (compteur < 5) {
    System.out.println("Tour " + compteur);
    compteur++;
}

// DO-WHILE (exécuté au moins une fois)
int n = 0;
do {
    System.out.println("Valeur : " + n);
    n++;
} while (n < 3);

// FOR classique
for (int i = 0; i < 10; i++) {
    if (i == 5) continue;  // sauter l'itération 5
    if (i == 8) break;     // arrêter à 8
    System.out.println(i);
}

// FOR-EACH (pour parcourir collections/tableaux)
String[] taches = {"Concevoir", "Développer", "Tester", "Déployer"};
for (String tache : taches) {
    System.out.println("-> " + tache);
}

3.5 Tableaux (Arrays)
──────────────────────

// Déclaration et initialisation
int[] notes = new int[5];          // tableau de 5 entiers (initialisés à 0)
String[] jours = {"Lun", "Mar", "Mer", "Jeu", "Ven"};

// Accès
System.out.println(jours[0]);      // -> "Lun" (index commence à 0)
System.out.println(jours.length);  // -> 5

// Tableau 2D
int[][] matrice = {
    {1, 2, 3},
    {4, 5, 6},
    {7, 8, 9}
};
System.out.println(matrice[1][2]); // -> 6 (ligne 1, colonne 2)

// Trier un tableau
import java.util.Arrays;
int[] nombres = {5, 2, 8, 1, 9};
Arrays.sort(nombres);              // -> {1, 2, 5, 8, 9}
System.out.println(Arrays.toString(nombres)); // afficher tableau

3.6 Collections Java
─────────────────────

Les collections sont préférées aux tableaux pour leur flexibilité :

import java.util.*;

// LIST — collection ordonnée, doublons autorisés
List<String> taches = new ArrayList<>();
taches.add("Créer endpoint");
taches.add("Tester API");
taches.add("Documenter");
taches.add("Créer endpoint");  // doublon autorisé

taches.get(0);           // -> "Créer endpoint"
taches.size();           // -> 4
taches.remove(1);        // supprime index 1
taches.contains("Tester API");  // -> true

// Itération moderne
taches.forEach(t -> System.out.println("-> " + t));

// MAP — paires clé-valeur, clés uniques
Map<String, Integer> priorites = new HashMap<>();
priorites.put("bug critique", 1);
priorites.put("feature", 3);
priorites.put("refactor", 2);

priorites.get("bug critique");  // -> 1
priorites.containsKey("feature");  // -> true
priorites.getOrDefault("inexistant", 0);  // -> 0 (valeur par défaut)

// Itération sur Map
priorites.forEach((cle, valeur) -> 
    System.out.println(cle + " -> priorité " + valeur));

// SET — collection sans doublons, non ordonnée
Set<String> roles = new HashSet<>();
roles.add("ADMIN");
roles.add("USER");
roles.add("ADMIN");  // ignoré (doublon)
roles.size();        // -> 2

// LinkedHashMap — Map ordonnée par insertion
Map<String, String> config = new LinkedHashMap<>();
config.put("db.host", "localhost");
config.put("db.port", "5432");
config.put("db.name", "taskflow");

3.7 Méthodes Java
──────────────────

// Syntaxe d'une méthode
// [modificateurs] [type_retour] [nom]([paramètres]) { corps }

public static int additionner(int a, int b) {
    return a + b;
}

// Méthode avec varargs (nombre variable d'arguments)
public static double moyenne(double... valeurs) {
    double somme = 0;
    for (double v : valeurs) somme += v;
    return somme / valeurs.length;
}
// Appel : moyenne(10.0, 20.0, 30.0)  -> 20.0

// Surcharge de méthode (Overloading)
public static String formater(String texte) {
    return "[" + texte + "]";
}
public static String formater(String texte, String prefixe) {
    return prefixe + "[" + texte + "]";
}
// Java choisit la bonne version selon les arguments

3.8 Gestion des Exceptions
────────────────────────────

Les exceptions sont des événements anormaux qui interrompent le flux normal.

// Hiérarchie des exceptions Java
//
// Throwable
// ├── Error (erreurs JVM graves, ne pas attraper)
// │   └── OutOfMemoryError, StackOverflowError
// └── Exception
//     ├── RuntimeException (non vérifiées)
//     │   ├── NullPointerException
//     │   ├── IllegalArgumentException
//     │   ├── ArrayIndexOutOfBoundsException
//     │   └── NumberFormatException
//     └── IOException (vérifiées, obligatoire try-catch)
//         ├── FileNotFoundException
//         └── SQLException

// TRY-CATCH-FINALLY
try {
    String texte = null;
    int longueur = texte.length();  // lance NullPointerException
} catch (NullPointerException e) {
    System.err.println("Erreur null : " + e.getMessage());
} catch (Exception e) {
    System.err.println("Erreur générique : " + e.getMessage());
} finally {
    // Toujours exécuté (même si exception)
    System.out.println("Nettoyage effectué");
}

// THROW — lancer une exception
public static int diviser(int a, int b) {
    if (b == 0) {
        throw new IllegalArgumentException("Division par zéro impossible");
    }
    return a / b;
}

// Exception personnalisée (pattern très utilisé en Spring Boot)
public class TaskNotFoundException extends RuntimeException {
    private final Long taskId;
    
    public TaskNotFoundException(Long taskId) {
        super("Tâche introuvable avec l'ID : " + taskId);
        this.taskId = taskId;
    }
    
    public Long getTaskId() {
        return taskId;
    }
}

// Try-with-resources (fermeture automatique)
try (BufferedReader reader = new BufferedReader(new FileReader("config.txt"))) {
    String ligne = reader.readLine();
    System.out.println(ligne);
} catch (IOException e) {
    System.err.println("Erreur lecture fichier : " + e.getMessage());
}
// reader.close() est appelé automatiquement

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4⃣  JAVA 8+ FONCTIONNALITÉS MODERNES (ESSENTIELLES EN SPRING)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

4.1 Lambda Expressions
───────────────────────

Une lambda est une fonction anonyme (sans nom) passée comme argument.

// Syntaxe : (paramètres) -> { corps }

// Avant Java 8
Runnable r1 = new Runnable() {
    @Override
    public void run() {
        System.out.println("Tâche exécutée");
    }
};

// Avec lambda Java 8+
Runnable r2 = () -> System.out.println("Tâche exécutée");

// Lambda avec paramètres
Comparator<String> comp = (a, b) -> a.compareTo(b);

// Lambda multi-lignes
Comparator<Integer> comparateurComplexe = (x, y) -> {
    if (x > y) return 1;
    if (x < y) return -1;
    return 0;
};

4.2 Stream API
───────────────

Les streams permettent de traiter des collections de manière fonctionnelle.
C'est fondamental pour manipuler des données en Spring Boot.

import java.util.stream.*;

List<Integer> nombres = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// FILTER -> garder les éléments pairs
List<Integer> pairs = nombres.stream()
    .filter(n -> n % 2 == 0)
    .collect(Collectors.toList());
// -> [2, 4, 6, 8, 10]

// MAP -> transformer chaque élément
List<String> enTexte = nombres.stream()
    .map(n -> "Tâche #" + n)
    .collect(Collectors.toList());
// -> ["Tâche #1", "Tâche #2", ...]

// FILTER + MAP + COLLECT
List<String> tachesActives = Arrays.asList("Login", "Dashboard", "API", "Tests");
List<String> resultat = tachesActives.stream()
    .filter(t -> t.length() > 4)
    .map(String::toUpperCase)        // référence de méthode
    .sorted()
    .collect(Collectors.toList());
// -> ["DASHBOARD", "LOGIN", "TESTS"]

// REDUCE -> agréger en une valeur
int somme = nombres.stream()
    .reduce(0, Integer::sum);
// -> 55

// COUNT
long nb = tachesActives.stream()
    .filter(t -> t.contains("a"))
    .count();

// findFirst, findAny
Optional<String> premier = tachesActives.stream()
    .filter(t -> t.startsWith("A"))
    .findFirst();
premier.ifPresent(System.out::println);  // -> "API"

// anyMatch, allMatch, noneMatch
boolean toutesLongues = tachesActives.stream().allMatch(t -> t.length() > 2);
boolean aucuneVide = tachesActives.stream().noneMatch(String::isEmpty);

// Groupement
Map<Integer, List<String>> parLongueur = tachesActives.stream()
    .collect(Collectors.groupingBy(String::length));

4.3 Optional
─────────────

Optional évite les NullPointerException. Très utilisé dans Spring Boot avec JPA.

Optional<String> optTexte = Optional.of("Spring Boot");
Optional<String> optVide = Optional.empty();
Optional<String> optNullable = Optional.ofNullable(null);

// Vérification
optTexte.isPresent();    // -> true
optVide.isPresent();     // -> false
optTexte.isEmpty();      // -> false (Java 11+)

// Récupérer la valeur
optTexte.get();          // -> "Spring Boot" (lance exception si vide !)
optVide.orElse("défaut"); // -> "défaut"
optVide.orElseGet(() -> calculerValeur()); // lazy
optVide.orElseThrow(() -> new RuntimeException("Pas de valeur"));

// Transformation
optTexte.map(String::toUpperCase)       // -> Optional["SPRING BOOT"]
        .filter(s -> s.length() > 5)   // -> Optional["SPRING BOOT"]
        .ifPresent(System.out::println);

4.4 Records (Java 16+)
───────────────────────

Les records sont des classes immuables pour les données. Parfaits pour les DTOs.

// Avant Java 16
public class TacheDTO {
    private final Long id;
    private final String titre;
    private final String statut;
    
    public TacheDTO(Long id, String titre, String statut) {
        this.id = id;
        this.titre = titre;
        this.statut = statut;
    }
    
    // getters, equals, hashCode, toString... 50 lignes de boilerplate !
}

// Avec record Java 16+
public record TacheDTO(Long id, String titre, String statut) {}

// -> Le compilateur génère automatiquement :
//   - Constructeur avec tous les champs
//   - Getters (id(), titre(), statut())
//   - equals(), hashCode(), toString()

TacheDTO tache = new TacheDTO(1L, "Créer API", "EN_COURS");
System.out.println(tache.titre());  // -> "Créer API"
System.out.println(tache);          // -> TacheDTO[id=1, titre=Créer API, statut=EN_COURS]

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  BONNES PRATIQUES JAVA
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[OK] DO (À faire) :
• Utiliser equals() pour comparer les objets, jamais ==
• Préférer les interfaces aux classes concrètes (List<> pas ArrayList<>)
• Nommer les variables et méthodes de manière explicite
• Gérer les null avec Optional
• Fermer les ressources avec try-with-resources
• Utiliser final pour les variables qui ne changent pas

[X] DON'T (À éviter) :
• Ne jamais attraper Exception génériquement sans la logger
• Éviter les champs public dans les classes (toujours private)
• Ne pas ignorer les exceptions silencieusement (catch vide)
• Éviter String + dans des boucles -> utiliser StringBuilder

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[EFFORT]  EXERCICES CHAPITRE 1
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FACILE :
Ex 1.1 : Créez une méthode calculerMoyenne(List<Double> notes) qui retourne la moyenne.
Ex 1.2 : Créez un programme qui vérifie si un mot est un palindrome.
Ex 1.3 : Utilisez Stream pour filtrer les nombres pairs d'une liste et les multiplier par 2.

INTERMÉDIAIRE :
Ex 1.4 : Créez une exception personnalisée UserNotFoundException avec un champ userId.
Ex 1.5 : Utilisez un Map pour compter les occurrences de chaque mot dans une phrase.
Ex 1.6 : Créez un record UserDTO avec id, nom, email et validez l'email avec une méthode.

AVANCÉ :
Ex 1.7 : Implémentez un système de cache simple avec Map<String, Object> et TTL.
Ex 1.8 : Créez une méthode générique <T> List<T> paginate(List<T> liste, int page, int size).
Ex 1.9 : Utilisez Stream pour grouper des Tache par statut et compter par groupe.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[CLE]  CORRIGÉS CHAPITRE 1
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// CORRIGÉ Ex 1.1
public static double calculerMoyenne(List<Double> notes) {
    // Cas limite : liste vide
    if (notes == null || notes.isEmpty()) {
        throw new IllegalArgumentException("La liste de notes ne peut pas être vide");
    }
    // Utilisation de Stream pour sommer puis diviser
    return notes.stream()
                .mapToDouble(Double::doubleValue)  // convertit en DoubleStream
                .average()                          // calcule la moyenne
                .orElse(0.0);                       // valeur par défaut si vide
}

// CORRIGÉ Ex 1.2
public static boolean estPalindrome(String mot) {
    // Normaliser : minuscules, sans espaces
    String normalise = mot.toLowerCase().replaceAll("\\s", "");
    // Comparer avec l'inverse
    String inverse = new StringBuilder(normalise).reverse().toString();
    return normalise.equals(inverse);
}
// Test : estPalindrome("Kayak") -> true, estPalindrome("Spring") -> false

// CORRIGÉ Ex 1.3
public static List<Integer> pairsDoubles(List<Integer> nombres) {
    return nombres.stream()
                  .filter(n -> n % 2 == 0)      // garder pairs
                  .map(n -> n * 2)               // doubler
                  .collect(Collectors.toList()); // collecter
}
// Test : [1,2,3,4,5,6] -> [4, 8, 12]

// CORRIGÉ Ex 1.4
public class UserNotFoundException extends RuntimeException {
    private final Long userId;
    
    public UserNotFoundException(Long userId) {
        super("Utilisateur introuvable avec l'ID : " + userId);
        this.userId = userId;
    }
    
    public UserNotFoundException(String email) {
        super("Utilisateur introuvable avec l'email : " + email);
        this.userId = null;
    }
    
    public Long getUserId() { return userId; }
}

// CORRIGÉ Ex 1.5
public static Map<String, Long> compterMots(String phrase) {
    // Diviser par espaces, compter avec groupingBy + counting
    return Arrays.stream(phrase.toLowerCase().split("\\s+"))
                 .collect(Collectors.groupingBy(
                     mot -> mot,
                     Collectors.counting()
                 ));
}

// CORRIGÉ Ex 1.8
public static <T> List<T> paginate(List<T> liste, int page, int size) {
    // Validation
    if (page < 0) throw new IllegalArgumentException("Page doit être >= 0");
    if (size <= 0) throw new IllegalArgumentException("Size doit être > 0");
    
    int debut = page * size;
    int fin = Math.min(debut + size, liste.size());
    
    // Si le début dépasse la liste, retourner vide
    if (debut >= liste.size()) return Collections.emptyList();
    
    return liste.subList(debut, fin);
}

═══════════════════════════════════════════════════════════════════

╔══════════════════════════════════════════════════════════╗
║   CHAPITRE 2 — PROGRAMMATION ORIENTÉE OBJET (POO)        ║
╚══════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  INTRODUCTION PÉDAGOGIQUE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Qu'est-ce que la POO ?
───────────────────────
La Programmation Orientée Objet (POO) est un paradigme qui organise le code en
"objets" qui combinent données (attributs) et comportements (méthodes).
Spring Boot est entièrement basé sur la POO. Comprendre la POO = comprendre Spring.

Les 4 piliers de la POO :
──────────────────────────
1. Encapsulation -> protéger les données internes (private + getters/setters)
2. Héritage -> réutiliser le code d'une classe parente
3. Polymorphisme -> même interface, comportements différents
4. Abstraction -> cacher la complexité, exposer l'essentiel

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  CLASSES ET OBJETS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

2.1 Structure d'une Classe
───────────────────────────

// Modèle de classe Java professionnel
// Contexte : entité Utilisateur pour TaskFlow

public class User {
    
    // ── ATTRIBUTS (private = encapsulation) ──────────────────────
    private Long id;
    private String nom;
    private String email;
    private String motDePasse;
    private boolean actif;
    private LocalDateTime dateCreation;
    
    // ── CONSTRUCTEUR PAR DÉFAUT ───────────────────────────────────
    public User() {
        this.actif = true;
        this.dateCreation = LocalDateTime.now();
    }
    
    // ── CONSTRUCTEUR AVEC PARAMÈTRES ─────────────────────────────
    public User(String nom, String email, String motDePasse) {
        this();  // appeler le constructeur par défaut
        this.nom = nom;
        this.email = email;
        this.motDePasse = motDePasse;
    }
    
    // ── GETTERS ET SETTERS ────────────────────────────────────────
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    
    public String getNom() { return nom; }
    public void setNom(String nom) {
        if (nom == null || nom.isBlank()) {
            throw new IllegalArgumentException("Le nom ne peut pas être vide");
        }
        this.nom = nom.trim();
    }
    
    public String getEmail() { return email; }
    public void setEmail(String email) {
        if (!email.contains("@")) {
            throw new IllegalArgumentException("Email invalide : " + email);
        }
        this.email = email.toLowerCase();
    }
    
    public boolean isActif() { return actif; }
    public void setActif(boolean actif) { this.actif = actif; }
    
    public LocalDateTime getDateCreation() { return dateCreation; }
    
    // ── MÉTHODES MÉTIER ──────────────────────────────────────────
    public void desactiver() {
        this.actif = false;
    }
    
    public boolean peutSupprimerTache(Tache tache) {
        return this.id.equals(tache.getProprietaireId()) || this.estAdmin();
    }
    
    public boolean estAdmin() {
        // logique simplifiée
        return false;
    }
    
    // ── MÉTHODES STANDARD ────────────────────────────────────────
    @Override
    public String toString() {
        return "User{id=" + id + ", nom='" + nom + "', email='" + email + 
               "', actif=" + actif + "}";
    }
    
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User)) return false;
        User user = (User) o;
        return Objects.equals(id, user.id) && 
               Objects.equals(email, user.email);
    }
    
    @Override
    public int hashCode() {
        return Objects.hash(id, email);
    }
}

// UTILISATION
User user1 = new User("Alice Martin", "alice@taskflow.com", "secret123");
user1.setId(1L);

System.out.println(user1.getNom());    // -> "Alice Martin"
System.out.println(user1.isActif());   // -> true

User user2 = new User("Bob", "alice@taskflow.com", "autre");
user2.setId(1L);
System.out.println(user1.equals(user2)); // -> true (même id et email)

2.2 Héritage
─────────────

// Classe parente (parent)
public abstract class BaseEntity {
    private Long id;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    
    // Constructeur
    protected BaseEntity() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }
    
    // Getters/Setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    
    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
    
    // Méthode appelée avant mise à jour
    public void preUpdate() {
        this.updatedAt = LocalDateTime.now();
    }
    
    // Méthode abstraite -> doit être implémentée par les sous-classes
    public abstract String getEntityType();
}

// Classe enfant héritant de BaseEntity
public class Tache extends BaseEntity {
    private String titre;
    private String description;
    private StatutTache statut;
    private Long proprietaireId;
    private LocalDate dateEcheance;
    
    // Constructeur
    public Tache(String titre, String description, Long proprietaireId) {
        super();  // appelle BaseEntity()
        this.titre = titre;
        this.description = description;
        this.proprietaireId = proprietaireId;
        this.statut = StatutTache.A_FAIRE;
    }
    
    // Implémentation obligatoire de la méthode abstraite
    @Override
    public String getEntityType() {
        return "TACHE";
    }
    
    // Méthode spécifique à Tache
    public void marquerTerminee() {
        this.statut = StatutTache.TERMINEE;
        this.preUpdate();  // appelle méthode de BaseEntity
    }
    
    // Getters
    public String getTitre() { return titre; }
    public StatutTache getStatut() { return statut; }
    public Long getProprietaireId() { return proprietaireId; }
}

// Enum pour les statuts
public enum StatutTache {
    A_FAIRE,
    EN_COURS,
    EN_REVISION,
    TERMINEE,
    ANNULEE
}

2.3 Interfaces
───────────────

Les interfaces définissent des contrats. Spring Boot les utilise massivement
(Repository, Service, etc.)

// Interface : contrat de comportement
public interface Exportable {
    String exporterJson();
    String exporterCsv();
    byte[] exporterPdf();
}

// Interface avec méthode par défaut (Java 8+)
public interface Auditable {
    LocalDateTime getCreatedAt();
    LocalDateTime getUpdatedAt();
    
    // Méthode par défaut (pas obligé de surcharger)
    default boolean estRecent() {
        return getCreatedAt().isAfter(LocalDateTime.now().minusDays(7));
    }
}

// Interface fonctionnelle (une seule méthode abstraite -> utilisable avec lambda)
@FunctionalInterface
public interface TacheProcessor {
    void process(Tache tache);
}

// Utilisation
TacheProcessor processeur = tache -> {
    System.out.println("Traitement de : " + tache.getTitre());
    tache.marquerTerminee();
};

// Implémentation multiple d'interfaces (Java ne supporte pas l'héritage multiple
// de classes, mais supporte les interfaces multiples)
public class Tache extends BaseEntity implements Exportable, Auditable {
    // ... doit implémenter toutes les méthodes des interfaces
    
    @Override
    public String exporterJson() {
        return "{\"id\":" + getId() + ",\"titre\":\"" + titre + "\"}";
    }
    
    @Override
    public String exporterCsv() {
        return getId() + "," + titre + "," + statut;
    }
    
    @Override
    public byte[] exporterPdf() {
        // génération PDF simplifiée
        return exporterJson().getBytes();
    }
}

2.4 Polymorphisme
──────────────────

// Le polymorphisme permet d'utiliser une référence parente pour des objets enfants

public abstract class Notification {
    protected String destinataire;
    protected String message;
    
    public Notification(String destinataire, String message) {
        this.destinataire = destinataire;
        this.message = message;
    }
    
    // Méthode polymorphique
    public abstract void envoyer();
    
    // Méthode commune
    public String getResume() {
        return "Notification pour " + destinataire + " : " + message;
    }
}

public class EmailNotification extends Notification {
    private String sujet;
    
    public EmailNotification(String email, String sujet, String message) {
        super(email, message);
        this.sujet = sujet;
    }
    
    @Override
    public void envoyer() {
        System.out.println("[EMAIL] Email envoyé à " + destinataire);
        System.out.println("   Sujet : " + sujet);
        System.out.println("   Corps : " + message);
    }
}

public class SmsNotification extends Notification {
    public SmsNotification(String telephone, String message) {
        super(telephone, message);
    }
    
    @Override
    public void envoyer() {
        System.out.println("[MOBILE] SMS envoyé au " + destinataire + " : " + message);
    }
}

public class PushNotification extends Notification {
    public PushNotification(String deviceId, String message) {
        super(deviceId, message);
    }
    
    @Override
    public void envoyer() {
        System.out.println("[NOTIF] Push envoyé au device " + destinataire);
    }
}

// UTILISATION POLYMORPHIQUE
List<Notification> notifications = new ArrayList<>();
notifications.add(new EmailNotification("alice@test.com", "Tâche terminée", "Votre tâche est terminée"));
notifications.add(new SmsNotification("+221771234567", "Nouvelle tâche assignée"));
notifications.add(new PushNotification("device-abc123", "Rappel : tâche due demain"));

// Appel uniforme -> chaque objet exécute SA version de envoyer()
for (Notification n : notifications) {
    n.envoyer();  // polymorphisme en action !
}

2.5 Classes Utilitaires Communes
──────────────────────────────────

// Classe utilitaire (méthodes statiques seulement, constructeur privé)
public final class ValidationUtils {
    
    // Constructeur privé -> empêche l'instanciation
    private ValidationUtils() {
        throw new UnsupportedOperationException("Classe utilitaire non instanciable");
    }
    
    public static boolean emailValide(String email) {
        return email != null && 
               email.contains("@") && 
               email.contains(".") &&
               email.length() >= 5;
    }
    
    public static boolean motDePasseValide(String mdp) {
        return mdp != null &&
               mdp.length() >= 8 &&
               mdp.matches(".*[A-Z].*") &&    // au moins une majuscule
               mdp.matches(".*[0-9].*");       // au moins un chiffre
    }
    
    public static String sanitiser(String texte) {
        if (texte == null) return null;
        return texte.trim()
                    .replaceAll("<[^>]*>", "")   // supprimer HTML
                    .replaceAll("[<>\"']", "");   // supprimer caractères dangereux
    }
}

// Builder Pattern (Pattern de création — très utilisé en Spring et tests)
public class TacheBuilder {
    private String titre;
    private String description;
    private Long proprietaireId;
    private LocalDate dateEcheance;
    private int priorite = 3;
    
    public TacheBuilder titre(String titre) {
        this.titre = titre;
        return this;  // retourne this -> chaînable
    }
    
    public TacheBuilder description(String description) {
        this.description = description;
        return this;
    }
    
    public TacheBuilder proprietaire(Long id) {
        this.proprietaireId = id;
        return this;
    }
    
    public TacheBuilder echeance(LocalDate date) {
        this.dateEcheance = date;
        return this;
    }
    
    public TacheBuilder priorite(int priorite) {
        this.priorite = priorite;
        return this;
    }
    
    public Tache build() {
        // Validation avant construction
        Objects.requireNonNull(titre, "Le titre est obligatoire");
        Objects.requireNonNull(proprietaireId, "Le propriétaire est obligatoire");
        
        Tache tache = new Tache(titre, description, proprietaireId);
        tache.setDateEcheance(dateEcheance);
        return tache;
    }
}

// UTILISATION du Builder Pattern
Tache tache = new TacheBuilder()
    .titre("Implémenter endpoint login")
    .description("Créer endpoint POST /api/auth/login avec JWT")
    .proprietaire(1L)
    .echeance(LocalDate.of(2025, 1, 31))
    .priorite(1)
    .build();

// En Spring Boot : Lombok @Builder fait ça automatiquement !

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[EFFORT]  EXERCICES CHAPITRE 2
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FACILE :
Ex 2.1 : Créez une classe Projet avec titre, description, dateDebut, propriétaire (User).
Ex 2.2 : Ajoutez une interface Describable avec méthode getDescription() à Tache.
Ex 2.3 : Créez une enum PrioriteTache avec BASSE, NORMALE, HAUTE, CRITIQUE.

INTERMÉDIAIRE :
Ex 2.4 : Implémentez le pattern Builder pour la classe User.
Ex 2.5 : Créez une hiérarchie : BaseEntity -> Document -> (Rapport, Facture).
Ex 2.6 : Implémentez Comparable<Tache> pour trier par dateEcheance.

AVANCÉ :
Ex 2.7 : Créez un pattern Strategy pour différents algorithmes de calcul de priorité.
Ex 2.8 : Implémentez un pattern Observer pour notifier lors du changement de statut.
Ex 2.9 : Créez un pattern Factory pour créer différents types de Notification.

═══════════════════════════════════════════════════════════════════

╔══════════════════════════════════════════════════════════╗
║   CHAPITRE 3 — HTTP : LE PROTOCOLE DU WEB               ║
╚══════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  QU'EST-CE QUE HTTP ?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

HTTP (HyperText Transfer Protocol) est le protocole de communication fondamental
du Web. Toute API REST que vous créerez avec Spring Boot utilisera HTTP.

Modèle Request-Response :
──────────────────────────

  CLIENT (navigateur, mobile, Postman)
       │
       │  HTTP REQUEST
       │  ──────────────────────────────────
       │  POST /api/auth/login HTTP/1.1
       │  Host: api.taskflow.com
       │  Content-Type: application/json
       │  Authorization: Bearer eyJhbGc...
       │
       │  {"email": "alice@test.com", "password": "secret"}
       │
       [BLACK_DOWN-POINTING_TRIANGLE]
  SERVEUR (Spring Boot)
       │
       │  HTTP RESPONSE
       │  ──────────────────────────────────
       │  HTTP/1.1 200 OK
       │  Content-Type: application/json
       │  X-Request-Id: abc-123
       │
       │  {"token": "eyJhbGciOiJIUzI1NiJ9...", "userId": 1}
       │
       [BLACK_DOWN-POINTING_TRIANGLE]
  CLIENT reçoit la réponse

2⃣  MÉTHODES HTTP
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

┌──────────┬──────────────────────────────┬──────────────────────┬────────────┐
│ Méthode  │ Usage                        │ Corps (Body) ?       │ Idempotent │
├──────────┼──────────────────────────────┼──────────────────────┼────────────┤
│ GET      │ Récupérer des données        │ [X] Non               │ [OK] Oui     │
│ POST     │ Créer une ressource          │ [OK] Oui               │ [X] Non     │
│ PUT      │ Remplacer complètement       │ [OK] Oui               │ [OK] Oui     │
│ PATCH    │ Modifier partiellement       │ [OK] Oui               │ [X] Variable │
│ DELETE   │ Supprimer une ressource      │ [X] Généralement non  │ [OK] Oui     │
│ HEAD     │ Comme GET, sans corps        │ [X] Non               │ [OK] Oui     │
│ OPTIONS  │ Découvrir les méthodes dispo │ [X] Non               │ [OK] Oui     │
└──────────┴──────────────────────────────┴──────────────────────┴────────────┘

Idempotent = même résultat, qu'on l'appelle 1 fois ou 100 fois.

Exemples TaskFlow :
───────────────────
GET    /api/tasks           -> Récupérer toutes les tâches
GET    /api/tasks/42        -> Récupérer la tâche #42
POST   /api/tasks           -> Créer une nouvelle tâche
PUT    /api/tasks/42        -> Remplacer complètement la tâche #42
PATCH  /api/tasks/42/status -> Changer uniquement le statut de la tâche #42
DELETE /api/tasks/42        -> Supprimer la tâche #42

3⃣  CODES DE STATUT HTTP
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les codes de statut informent le client sur le résultat de la requête.

2xx — SUCCÈS
────────────
200 OK              -> Requête réussie (GET, PUT, PATCH)
201 Created         -> Ressource créée avec succès (POST)
204 No Content      -> Succès sans corps de réponse (DELETE)
206 Partial Content -> Contenu partiel (pagination, streaming)

3xx — REDIRECTION
──────────────────
301 Moved Permanently  -> URL déplacée définitivement
302 Found              -> Redirection temporaire
304 Not Modified       -> Cache valide, pas de nouveau contenu

4xx — ERREURS CLIENT
─────────────────────
400 Bad Request        -> Requête malformée / données invalides
401 Unauthorized       -> Non authentifié (pas de token ou token invalide)
403 Forbidden          -> Authentifié mais pas autorisé (mauvais rôle)
404 Not Found          -> Ressource introuvable
405 Method Not Allowed -> Méthode HTTP non supportée sur cet endpoint
409 Conflict           -> Conflit (email déjà utilisé, etc.)
422 Unprocessable      -> Données syntaxiquement correctes mais sémantiquement invalides
429 Too Many Requests  -> Rate limiting dépassé

5xx — ERREURS SERVEUR
──────────────────────
500 Internal Server Error -> Erreur côté serveur (bug, crash)
502 Bad Gateway           -> Gateway/Proxy a reçu une réponse invalide
503 Service Unavailable   -> Serveur temporairement indisponible
504 Gateway Timeout       -> Timeout du gateway

4⃣  HEADERS HTTP
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les headers sont des métadonnées de la requête/réponse.

Headers de Requête communs :
─────────────────────────────
Content-Type: application/json         -> format du corps de la requête
Accept: application/json               -> format attendu pour la réponse
Authorization: Bearer eyJhbGc...       -> token JWT d'authentification
Accept-Language: fr-FR,fr;q=0.9        -> langue préférée
X-Request-Id: uuid-123-abc              -> ID unique pour traçabilité

Headers de Réponse communs :
──────────────────────────────
Content-Type: application/json         -> format du corps de la réponse
X-Total-Count: 150                     -> nombre total d'éléments (pagination)
X-Page-Number: 0                       -> page actuelle
Location: /api/tasks/43                -> URL de la ressource créée (201)
Cache-Control: max-age=3600            -> directives de mise en cache
Access-Control-Allow-Origin: *         -> CORS

5⃣  HTTPS ET SÉCURITÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

HTTPS = HTTP + TLS (Transport Layer Security)
Toutes les APIs en production doivent utiliser HTTPS.

Flux HTTPS :
────────────
1. Client -> Serveur : "Je veux HTTPS" (Client Hello)
2. Serveur -> Client : Certificat SSL
3. Client vérifie le certificat (via CA - Certificate Authority)
4. Échange de clé de session symétrique
5. Communication chiffrée

En Spring Boot : HTTPS configuré via application.properties
───────────────────────────────────────────────────────────
server.ssl.enabled=true
server.ssl.key-store=classpath:keystore.p12
server.ssl.key-store-password=taskflow-secret
server.ssl.key-store-type=PKCS12

═══════════════════════════════════════════════════════════════════

╔══════════════════════════════════════════════════════════╗
║   CHAPITRE 4 — JSON : LE FORMAT DES APIs MODERNES       ║
╚══════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  QU'EST-CE QUE JSON ?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

JSON (JavaScript Object Notation) est le format d'échange de données standard
des APIs REST modernes. Léger, lisible par les humains et facilement parsable
par les machines.

2⃣  SYNTAXE JSON
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Types JSON :
────────────
• String     : "Alice Martin"
• Number     : 42, 3.14, -10
• Boolean    : true, false
• null       : null
• Object     : { "clé": "valeur" }
• Array      : [1, 2, 3], ["a", "b"]

Exemple complexe — Réponse API TaskFlow :
─────────────────────────────────────────
{
  "success": true,
  "timestamp": "2025-01-15T10:30:00Z",
  "data": {
    "task": {
      "id": 42,
      "title": "Implémenter authentification JWT",
      "description": "Créer le système de login avec tokens JWT",
      "status": "IN_PROGRESS",
      "priority": "HIGH",
      "createdAt": "2025-01-10T08:00:00Z",
      "updatedAt": "2025-01-15T09:45:00Z",
      "dueDate": "2025-01-31",
      "owner": {
        "id": 1,
        "name": "Alice Martin",
        "email": "alice@taskflow.com",
        "avatarUrl": "https://cdn.taskflow.com/avatars/alice.jpg"
      },
      "assignees": [
        {
          "id": 2,
          "name": "Bob Dupont",
          "role": "DEVELOPER"
        },
        {
          "id": 3,
          "name": "Carol Smith",
          "role": "REVIEWER"
        }
      ],
      "tags": ["backend", "security", "jwt"],
      "attachments": [],
      "comments": {
        "count": 3,
        "lastComment": {
          "author": "Bob Dupont",
          "text": "J'ai implémenté la génération du token.",
          "at": "2025-01-15T10:00:00Z"
        }
      },
      "metadata": {
        "estimatedHours": 8,
        "actualHours": 5.5,
        "completionPercentage": 75
      }
    }
  },
  "pagination": null,
  "errors": []
}

3⃣  JACKSON EN SPRING BOOT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Spring Boot utilise Jackson pour la sérialisation/désérialisation JSON automatique.

Annotations Jackson importantes :
───────────────────────────────────

import com.fasterxml.jackson.annotation.*;

public class TaskResponse {
    
    // Renommer le champ en JSON
    @JsonProperty("task_id")
    private Long id;
    
    // Inclure toujours même si null
    @JsonInclude(JsonInclude.Include.ALWAYS)
    private String description;
    
    // Exclure du JSON (ex: mot de passe)
    @JsonIgnore
    private String motDePasse;
    
    // Format de date
    @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'", timezone = "UTC")
    private LocalDateTime createdAt;
    
    // Alias de lecture
    @JsonAlias({"task_title", "taskTitle", "titre"})
    private String title;
    
    // Ordre des propriétés dans le JSON
    @JsonPropertyOrder({"id", "title", "status", "createdAt"})
    // (annotation de classe)
}

// Configuration globale de Jackson dans Spring Boot
@Configuration
public class JacksonConfig {
    
    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        
        // Ne pas échouer sur des propriétés inconnues
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        
        // Ne pas sérialiser les nulls
        mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        
        // Support des dates Java 8
        mapper.registerModule(new JavaTimeModule());
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        
        return mapper;
    }
}

4⃣  SÉRIALISATION / DÉSÉRIALISATION MANUELLE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// ObjectMapper : convertit Java <-> JSON
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());

// Sérialisation : Java -> JSON String
User user = new User("Alice", "alice@test.com", "password123");
String json = mapper.writeValueAsString(user);
// -> {"nom":"Alice","email":"alice@test.com"}

// Désérialisation : JSON String -> Java
String jsonInput = "{\"nom\":\"Bob\",\"email\":\"bob@test.com\"}";
User userFromJson = mapper.readValue(jsonInput, User.class);

// Désérialiser vers un type générique (List, Map...)
String jsonArray = "[{\"id\":1,\"titre\":\"Tâche 1\"},{\"id\":2,\"titre\":\"Tâche 2\"}]";
List<TacheDTO> taches = mapper.readValue(jsonArray, 
    new TypeReference<List<TacheDTO>>() {});

// Sérialiser en fichier
mapper.writeValue(new File("export.json"), taches);

// Lire depuis fichier
List<TacheDTO> fromFile = mapper.readValue(
    new File("export.json"), 
    new TypeReference<List<TacheDTO>>() {}
);

═══════════════════════════════════════════════════════════════════

╔══════════════════════════════════════════════════════════╗
║    CHAPITRE 5 — REST : L'ARCHITECTURE DES APIs MODERNES  ║
╚══════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  QU'EST-CE QUE REST ?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

REST (Representational State Transfer) est un style d'architecture pour créer
des APIs web. Défini par Roy Fielding dans sa thèse de doctorat (2000).
Une API qui suit ces principes est dite "RESTful".

2⃣  LES 6 CONTRAINTES REST
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. CLIENT-SERVEUR
   -> Séparation des préoccupations. Le client ne connaît pas la base de données.
   -> Le serveur ne connaît pas l'UI.

2. STATELESS (Sans état)
   -> Chaque requête contient TOUTES les informations nécessaires.
   -> Le serveur ne stocke PAS l'état de la session.
   -> C'est pourquoi on utilise JWT : le token porte l'identité de l'utilisateur.

3. CACHEABLE
   -> Les réponses peuvent être mises en cache pour améliorer les performances.
   -> Spring Boot supporte le cache via @Cacheable.

4. INTERFACE UNIFORME
   -> URLs cohérentes, utilisation correcte des méthodes HTTP.

5. LAYERED SYSTEM
   -> Le client ne sait pas s'il communique directement avec le serveur ou
     via un proxy/load balancer.

6. CODE ON DEMAND (optionnel)
   -> Le serveur peut envoyer du code exécutable (JavaScript, etc.)

3⃣  CONCEPTION D'UNE API REST
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Règles de nommage des URLs :
────────────────────────────
[OK] Utiliser des noms (pas des verbes) dans les URLs
[OK] Utiliser le pluriel pour les collections
[OK] Utiliser la casse kebab-case ou snake_case
[OK] Hiérarchie logique des ressources

MAUVAIS :                          BON :
/getTasks                   ->      GET /tasks
/createNewTask              ->      POST /tasks
/deleteTask?id=42           ->      DELETE /tasks/42
/getTasksByUser?userId=1    ->      GET /users/1/tasks
/task_status_update         ->      PATCH /tasks/42/status

API TaskFlow — Endpoints complets :
─────────────────────────────────────
# AUTHENTIFICATION
POST   /api/v1/auth/register          -> Inscription
POST   /api/v1/auth/login             -> Connexion -> retourne token JWT
POST   /api/v1/auth/refresh           -> Renouveler le token
POST   /api/v1/auth/logout            -> Déconnexion

# UTILISATEURS
GET    /api/v1/users                  -> Liste paginée des utilisateurs (ADMIN)
GET    /api/v1/users/{id}             -> Profil d'un utilisateur
PUT    /api/v1/users/{id}             -> Modifier un utilisateur
DELETE /api/v1/users/{id}             -> Désactiver un compte
GET    /api/v1/users/me               -> Profil de l'utilisateur connecté

# PROJETS
GET    /api/v1/projects               -> Liste des projets
POST   /api/v1/projects               -> Créer un projet
GET    /api/v1/projects/{id}          -> Détail d'un projet
PUT    /api/v1/projects/{id}          -> Modifier un projet
DELETE /api/v1/projects/{id}          -> Supprimer un projet
GET    /api/v1/projects/{id}/tasks    -> Tâches d'un projet

# TÂCHES
GET    /api/v1/tasks                  -> Toutes mes tâches
POST   /api/v1/tasks                  -> Créer une tâche
GET    /api/v1/tasks/{id}             -> Détail d'une tâche
PUT    /api/v1/tasks/{id}             -> Modifier une tâche
PATCH  /api/v1/tasks/{id}/status      -> Changer le statut
PATCH  /api/v1/tasks/{id}/assign      -> Assigner à quelqu'un
DELETE /api/v1/tasks/{id}             -> Supprimer une tâche
GET    /api/v1/tasks/{id}/comments    -> Commentaires d'une tâche
POST   /api/v1/tasks/{id}/comments    -> Ajouter un commentaire

4⃣  VERSIONING D'API
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le versioning permet de faire évoluer l'API sans casser les clients existants.

Stratégies :
─────────────
1. URL versioning (recommandé) :
   GET /api/v1/tasks
   GET /api/v2/tasks   (nouvelle version)

2. Header versioning :
   GET /api/tasks
   Accept: application/vnd.taskflow.v2+json

3. Query param :
   GET /api/tasks?version=2

Recommandation : URL versioning pour sa clarté.

5⃣  FORMAT DE RÉPONSE STANDARD (TASKFLOW)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

En entreprise, on standardise le format de toutes les réponses.

// Format de réponse succès
{
  "success": true,
  "message": "Opération réussie",
  "data": { ... },           // les données retournées
  "pagination": {            // si liste paginée
    "page": 0,
    "size": 20,
    "totalElements": 150,
    "totalPages": 8,
    "hasNext": true,
    "hasPrevious": false
  },
  "timestamp": "2025-01-15T10:30:00Z"
}

// Format de réponse erreur
{
  "success": false,
  "message": "Validation échouée",
  "errors": [
    {
      "field": "email",
      "message": "Format d'email invalide",
      "rejectedValue": "alice@"
    },
    {
      "field": "password",
      "message": "Le mot de passe doit contenir au moins 8 caractères",
      "rejectedValue": "abc"
    }
  ],
  "errorCode": "VALIDATION_ERROR",
  "timestamp": "2025-01-15T10:30:00Z",
  "path": "/api/v1/auth/register"
}

// Classe Java pour ce format
public class ApiResponse<T> {
    private boolean success;
    private String message;
    private T data;
    private List<FieldError> errors;
    private PaginationInfo pagination;
    private String timestamp;
    
    // Factory methods
    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(true, "Succès", data, null, null);
    }
    
    public static <T> ApiResponse<T> success(String message, T data) {
        return new ApiResponse<>(true, message, data, null, null);
    }
    
    public static <T> ApiResponse<T> error(String message) {
        return new ApiResponse<>(false, message, null, null, null);
    }
    
    public static <T> ApiResponse<T> validationError(List<FieldError> errors) {
        ApiResponse<T> response = new ApiResponse<>(false, "Validation échouée", null, errors, null);
        return response;
    }
    
    private ApiResponse(boolean success, String message, T data, 
                        List<FieldError> errors, PaginationInfo pagination) {
        this.success = success;
        this.message = message;
        this.data = data;
        this.errors = errors;
        this.pagination = pagination;
        this.timestamp = LocalDateTime.now().toString();
    }
}

6⃣  OUTILS DE TEST D'API REST
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

1. Postman -> interface graphique, collections, tests automatisés
2. Insomnia -> alternative légère à Postman
3. cURL -> ligne de commande (rapide et universel)
4. HTTPie -> cURL plus lisible
5. Swagger/OpenAPI -> documentation interactive intégrée à Spring Boot

Exemples cURL :
───────────────
# GET avec token
curl -X GET https://localhost:8080/api/v1/tasks \
     -H "Authorization: Bearer eyJhbGc..." \
     -H "Accept: application/json"

# POST avec body JSON
curl -X POST https://localhost:8080/api/v1/tasks \
     -H "Content-Type: application/json" \
     -H "Authorization: Bearer eyJhbGc..." \
     -d '{
       "title": "Nouvelle tâche",
       "description": "Description de la tâche",
       "priority": "HIGH"
     }'

# PATCH
curl -X PATCH https://localhost:8080/api/v1/tasks/42/status \
     -H "Content-Type: application/json" \
     -H "Authorization: Bearer eyJhbGc..." \
     -d '{"status": "COMPLETED"}'

# DELETE
curl -X DELETE https://localhost:8080/api/v1/tasks/42 \
     -H "Authorization: Bearer eyJhbGc..."

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[EFFORT]  EXERCICES CHAPITRE 5
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FACILE :
Ex 5.1 : Dessinez la structure d'URL pour une API de gestion d'une bibliothèque
         (livres, auteurs, emprunts).
Ex 5.2 : Quel code HTTP retourner pour chaque cas :
         - Connexion réussie avec token
         - Livre non trouvé par ID
         - Email déjà utilisé à l'inscription
         - Tentative d'accès sans token
Ex 5.3 : Concevez le format JSON pour une réponse paginée de livres.

INTERMÉDIAIRE :
Ex 5.4 : Définissez les endpoints REST complets pour un système de commentaires
         imbriqués (tâche -> commentaires -> réponses).
Ex 5.5 : Créez la classe ApiResponse<T> complète avec builder pattern.
Ex 5.6 : Quel code HTTP pour PATCH /tasks/42/status si l'utilisateur n'a pas
         le droit de modifier cette tâche ? Expliquez.

AVANCÉ :
Ex 5.7 : Concevez une stratégie de versioning pour migrer /api/v1/tasks vers
         /api/v2/tasks en gardant la compatibilité.
Ex 5.8 : Créez la documentation OpenAPI (Swagger YAML) pour les endpoints auth.
Ex 5.9 : Implémentez un rate limiter conceptuellement pour l'endpoint /auth/login.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[LISTE] RÉSUMÉ PARTIE 1 — CE QUE VOUS AVEZ APPRIS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[OK] Java fondamental : types, collections, lambdas, streams, Optional, records
[OK] POO : encapsulation, héritage, polymorphisme, interfaces, patterns (Builder, Strategy)
[OK] HTTP : méthodes, codes de statut, headers, HTTPS
[OK] JSON : syntaxe, Jackson, sérialisation/désérialisation
[OK] REST : contraintes, conventions d'URL, versioning, format de réponse standard

[SOON_WITH_RIGHTWARDS_ARROW_ABOVE] PARTIE 2 : Installation Spring Boot, structure de projet, Maven, premiers endpoints

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[DOCS] RÉFÉRENCES ET RESSOURCES COMPLÉMENTAIRES
• Documentation officielle Java 21 : https://docs.oracle.com/en/java/javase/21/
• Baeldung Java tutorials : https://www.baeldung.com/java-tutorial
• JSON specification : https://www.json.org/
• HTTP specification RFC 7231 : https://tools.ietf.org/html/rfc7231
• REST Dissertation (Roy Fielding) : https://www.ics.uci.edu/~fielding/pubs/dissertation/top.htm

═══════════════════════════════════════════════════════════════════
FIN DE LA PARTIE 1 — spring_boot_part_1.txt
═══════════════════════════════════════════════════════════════════

╔══════════════════════════════════════════════════════════════════════════════════╗
║           GUIDE COMPLET SPRING BOOT — NIVEAU ENTREPRISE                          ║
║           PARTIE 2 : INTRODUCTION À SPRING BOOT                                  ║
║           Chapitres 6 à 9 : Installation · Structure · Config · Starters         ║
╚══════════════════════════════════════════════════════════════════════════════════╝

[OBJECTIF] PROJET FIL ROUGE : TaskFlow Backend
   Cette partie : mise en place complète du projet Spring Boot

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

╔══════════════════════════════════════════════════════════╗
║    CHAPITRE 6 — SPRING BOOT : INSTALLATION & DÉMARRAGE   ║
╚══════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  QU'EST-CE QUE SPRING BOOT ?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Spring Boot est un framework qui simplifie radicalement le développement
d'applications Java d'entreprise basées sur Spring Framework.

Histoire :
──────────
• 2002 : Spring Framework créé par Rod Johnson
• 2014 : Spring Boot 1.0 lancé (révolution : convention over configuration)
• 2017 : Spring Boot 2.0 (Spring 5, Reactive, Java 9+)
• 2022 : Spring Boot 3.0 (Java 17+, Jakarta EE 9, AOT)
• 2024 : Spring Boot 3.3+ (Java 21, Virtual Threads, GraalVM)

Spring vs Spring Boot :
────────────────────────
┌──────────────────────┬─────────────────────────────────────────────────────────┐
│ Spring Framework     │ Spring Boot                                             │
├──────────────────────┼─────────────────────────────────────────────────────────┤
│ Configuration XML    │ Configuration automatique (Auto-configuration)          │
│ Serveur externe      │ Serveur embarqué (Tomcat/Jetty/Undertow)                │
│ Dépendances manuelles│ Starters (groupes de dépendances)                       │
│ Démarrage complexe   │ Démarrage en quelques secondes                          │
│ Beaucoup de code     │ Conventions par défaut + @SpringBootApplication         │
└──────────────────────┴─────────────────────────────────────────────────────────┘

Avantages Spring Boot :
────────────────────────
• Auto-configuration -> zéro XML
• Serveur Tomcat intégré -> jar exécutable
• Spring Initializr -> génération de projet en 1 clic
• Spring Actuator -> monitoring out-of-the-box
• Profils -> configs dev/test/prod séparées
• Énorme écosystème Spring

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  CRÉER LE PROJET TASKFLOW AVEC SPRING INITIALIZR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Option A : Spring Initializr Web
──────────────────────────────────
1. Aller sur https://start.spring.io/
2. Configurer :

   Project     : Maven
   Language    : Java
   Spring Boot : 3.3.x (dernière stable)
   
   Project Metadata :
   ┌─────────────────────────────────────┐
   │ Group    : com.taskflow             │
   │ Artifact : taskflow-backend         │
   │ Name     : TaskFlow Backend         │
   │ Desc     : SaaS Task Management API │
   │ Package  : com.taskflow.backend     │
   │ Packaging: Jar                      │
   │ Java     : 21                       │
   └─────────────────────────────────────┘
   
   Dependencies à sélectionner :
   [OK] Spring Web
   [OK] Spring Data JPA
   [OK] Spring Security
   [OK] Spring Validation
   [OK] PostgreSQL Driver
   [OK] Spring Boot DevTools
   [OK] Lombok
   [OK] Spring Boot Actuator
   [OK] Spring Cache Abstraction

3. Cliquer "GENERATE" -> télécharge taskflow-backend.zip
4. Décompresser et ouvrir dans IntelliJ IDEA

Option B : IntelliJ IDEA (recommandé)
───────────────────────────────────────
File -> New -> Project -> Spring Initializr
-> Même configuration que ci-dessus
-> IntelliJ télécharge et configure automatiquement

Option C : Spring CLI
──────────────────────
# Installer Spring CLI
sdk install springboot   # avec SDKMAN

# Créer le projet
spring init \
  --build=maven \
  --java-version=21 \
  --boot-version=3.3.0 \
  --dependencies=web,data-jpa,security,validation,postgresql,devtools,lombok,actuator \
  --group-id=com.taskflow \
  --artifact-id=taskflow-backend \
  --name="TaskFlow Backend" \
  taskflow-backend.zip

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3⃣  DÉMARRER L'APPLICATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Avec Maven :
────────────
# Depuis le répertoire du projet
mvn spring-boot:run

# Ou compiler et exécuter le jar
mvn clean package -DskipTests
java -jar target/taskflow-backend-0.0.1-SNAPSHOT.jar

Avec IntelliJ :
───────────────
• Clic droit sur TaskflowBackendApplication -> Run
• Ou cliquer le bouton [BLACK_RIGHT-POINTING_TRIANGLE] vert

Vérifier le démarrage :
─────────────────────────
  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/

 :: Spring Boot ::                (v3.3.0)

2025-01-15 10:00:00.123  INFO --- [main] o.s.b.w.embedded.tomcat.TomcatWebServer  : 
  Tomcat started on port(s): 8080 (http)
2025-01-15 10:00:00.456  INFO --- [main] com.taskflow.backend.TaskflowApplication : 
  Started TaskflowApplication in 3.421 seconds

-> Serveur démarré sur http://localhost:8080

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4⃣  LA CLASSE PRINCIPALE SPRINGBOOTAPPLICATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// src/main/java/com/taskflow/backend/TaskflowBackendApplication.java

package com.taskflow.backend;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

// @SpringBootApplication est une méta-annotation qui combine :
// @Configuration      -> cette classe est une source de beans Spring
// @EnableAutoConfiguration -> active l'auto-configuration de Spring Boot
// @ComponentScan      -> scanne les composants dans le package et sous-packages

@SpringBootApplication
@EnableCaching       // active le cache Spring
@EnableAsync         // active l'exécution asynchrone
@EnableScheduling    // active les tâches planifiées
public class TaskflowBackendApplication {

    public static void main(String[] args) {
        // SpringApplication.run() :
        // 1. Crée le contexte Spring (ApplicationContext)
        // 2. Enregistre tous les beans
        // 3. Lance le serveur Tomcat embarqué
        // 4. Démarre l'application
        
        SpringApplication app = new SpringApplication(TaskflowBackendApplication.class);
        
        // Configuration optionnelle avant démarrage
        app.setBannerMode(Banner.Mode.CONSOLE);  // afficher le banner ASCII
        
        app.run(args);
    }
}

═══════════════════════════════════════════════════════════════════

╔══════════════════════════════════════════════════════════╗
║   CHAPITRE 7 — STRUCTURE DU PROJET SPRING BOOT           ║
╚══════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  ARBORESCENCE COMPLÈTE TASKFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

taskflow-backend/
│
├── pom.xml                              <- gestionnaire de dépendances Maven
├── Dockerfile                           <- containerisation Docker
├── docker-compose.yml                   <- orchestration locale
├── .env                                 <- variables d'environnement (NE PAS committer)
├── .gitignore                           <- fichiers à ignorer par Git
├── README.md                            <- documentation du projet
│
├── src/
│   ├── main/
│   │   ├── java/
│   │   │   └── com/taskflow/backend/
│   │   │       │
│   │   │       ├── TaskflowBackendApplication.java   <- point d'entrée
│   │   │       │
│   │   │       ├── config/                           <- configurations Spring
│   │   │       │   ├── SecurityConfig.java
│   │   │       │   ├── JwtConfig.java
│   │   │       │   ├── JacksonConfig.java
│   │   │       │   ├── CacheConfig.java
│   │   │       │   ├── AsyncConfig.java
│   │   │       │   └── OpenApiConfig.java
│   │   │       │
│   │   │       ├── controller/                       <- couche HTTP
│   │   │       │   ├── AuthController.java
│   │   │       │   ├── UserController.java
│   │   │       │   ├── TaskController.java
│   │   │       │   ├── ProjectController.java
│   │   │       │   └── CommentController.java
│   │   │       │
│   │   │       ├── service/                          <- logique métier
│   │   │       │   ├── AuthService.java
│   │   │       │   ├── UserService.java
│   │   │       │   ├── TaskService.java
│   │   │       │   ├── ProjectService.java
│   │   │       │   ├── CommentService.java
│   │   │       │   ├── EmailService.java
│   │   │       │   └── JwtService.java
│   │   │       │
│   │   │       ├── repository/                       <- accès base de données
│   │   │       │   ├── UserRepository.java
│   │   │       │   ├── TaskRepository.java
│   │   │       │   ├── ProjectRepository.java
│   │   │       │   └── CommentRepository.java
│   │   │       │
│   │   │       ├── entity/                           <- modèles JPA
│   │   │       │   ├── User.java
│   │   │       │   ├── Task.java
│   │   │       │   ├── Project.java
│   │   │       │   ├── Comment.java
│   │   │       │   └── BaseEntity.java
│   │   │       │
│   │   │       ├── dto/                              <- Data Transfer Objects
│   │   │       │   ├── request/
│   │   │       │   │   ├── LoginRequest.java
│   │   │       │   │   ├── RegisterRequest.java
│   │   │       │   │   ├── CreateTaskRequest.java
│   │   │       │   │   └── UpdateTaskRequest.java
│   │   │       │   └── response/
│   │   │       │       ├── AuthResponse.java
│   │   │       │       ├── UserResponse.java
│   │   │       │       ├── TaskResponse.java
│   │   │       │       └── ApiResponse.java
│   │   │       │
│   │   │       ├── exception/                        <- exceptions personnalisées
│   │   │       │   ├── GlobalExceptionHandler.java
│   │   │       │   ├── ResourceNotFoundException.java
│   │   │       │   ├── UnauthorizedException.java
│   │   │       │   ├── ConflictException.java
│   │   │       │   └── BusinessException.java
│   │   │       │
│   │   │       ├── security/                         <- sécurité JWT
│   │   │       │   ├── JwtAuthenticationFilter.java
│   │   │       │   ├── JwtTokenProvider.java
│   │   │       │   ├── UserDetailsServiceImpl.java
│   │   │       │   └── SecurityUtils.java
│   │   │       │
│   │   │       ├── mapper/                           <- conversions Entity <-> DTO
│   │   │       │   ├── UserMapper.java
│   │   │       │   ├── TaskMapper.java
│   │   │       │   └── ProjectMapper.java
│   │   │       │
│   │   │       └── util/                             <- utilitaires
│   │   │           ├── ValidationUtils.java
│   │   │           ├── DateUtils.java
│   │   │           └── PageUtils.java
│   │   │
│   │   └── resources/
│   │       ├── application.properties                <- config principale
│   │       ├── application-dev.properties            <- config développement
│   │       ├── application-test.properties           <- config tests
│   │       ├── application-prod.properties           <- config production
│   │       ├── db/
│   │       │   └── migration/                        <- scripts Flyway
│   │       │       ├── V1__Create_tables.sql
│   │       │       ├── V2__Add_indexes.sql
│   │       │       └── V3__Seed_data.sql
│   │       ├── static/                               <- fichiers statiques (si besoin)
│   │       └── templates/                            <- templates Thymeleaf (si besoin)
│   │
│   └── test/
│       └── java/
│           └── com/taskflow/backend/
│               ├── controller/
│               │   ├── AuthControllerTest.java
│               │   └── TaskControllerTest.java
│               ├── service/
│               │   ├── AuthServiceTest.java
│               │   └── TaskServiceTest.java
│               └── repository/
│                   └── TaskRepositoryTest.java
│
└── target/                                          <- généré par Maven (ne pas committer)
    └── taskflow-backend-0.0.1-SNAPSHOT.jar

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  ARCHITECTURE EN COUCHES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

L'architecture en couches sépare les responsabilités :

  HTTP Request
       │
  ┌────[BLACK_DOWN-POINTING_TRIANGLE]──────────────────────────────────────────────────────┐
  │                  CONTROLLER LAYER                         │
  │  - Reçoit les requêtes HTTP                               │
  │  - Valide les données d'entrée                            │
  │  - Délègue au service                                     │
  │  - Retourne la réponse HTTP                               │
  └────┬──────────────────────────────────────────────────────┘
       │ appelle
  ┌────[BLACK_DOWN-POINTING_TRIANGLE]──────────────────────────────────────────────────────┐
  │                  SERVICE LAYER                            │
  │  - Contient TOUTE la logique métier                       │
  │  - Coordonne les repositories                             │
  │  - Gère les transactions (@Transactional)                 │
  │  - Lève des exceptions métier                             │
  └────┬──────────────────────────────────────────────────────┘
       │ appelle
  ┌────[BLACK_DOWN-POINTING_TRIANGLE]──────────────────────────────────────────────────────┐
  │                  REPOSITORY LAYER                          │
  │  - Accès à la base de données                             │
  │  - Requêtes JPA/SQL                                       │
  │  - Pagination, tri, filtres                               │
  └────┬──────────────────────────────────────────────────────┘
       │ communique avec
  ┌────[BLACK_DOWN-POINTING_TRIANGLE]──────────────────────────────────────────────────────┐
  │                  DATABASE                                  │
  │  PostgreSQL (production)                                   │
  │  H2 (tests)                                               │
  └───────────────────────────────────────────────────────────┘

RÈGLE D'OR : Chaque couche ne communique QU'AVEC la couche adjacent.
             Controller -> Service -> Repository
             JAMAIS Controller -> Repository directement !

═══════════════════════════════════════════════════════════════════

╔══════════════════════════════════════════════════════════╗
║   CHAPITRE 8 — APPLICATION.PROPERTIES : CONFIGURATION    ║
╚══════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  CONFIGURATION PRINCIPALE (application.properties)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ═══════════════════════════════════════════════════════════
# APPLICATION
# ═══════════════════════════════════════════════════════════
spring.application.name=taskflow-backend
server.port=8080
server.servlet.context-path=/

# Profil actif (dev par défaut)
spring.profiles.active=dev

# ═══════════════════════════════════════════════════════════
# BASE DE DONNÉES
# ═══════════════════════════════════════════════════════════
spring.datasource.url=jdbc:postgresql://localhost:5432/taskflow_db
spring.datasource.username=taskflow_user
spring.datasource.password=taskflow_password
spring.datasource.driver-class-name=org.postgresql.Driver

# Pool de connexions HikariCP (connexions simultanées)
spring.datasource.hikari.pool-name=TaskFlowPool
spring.datasource.hikari.maximum-pool-size=10
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=30000
spring.datasource.hikari.idle-timeout=600000
spring.datasource.hikari.max-lifetime=1800000

# ═══════════════════════════════════════════════════════════
# JPA / HIBERNATE
# ═══════════════════════════════════════════════════════════
# none -> ne rien faire, validate -> vérifier, update -> mettre à jour, 
# create -> (RE)créer, create-drop -> créer et supprimer à l'arrêt
spring.jpa.hibernate.ddl-auto=validate

# Activer les migrations Flyway (script SQL gérés manuellement)
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration

# Afficher le SQL Hibernate (dev uniquement)
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.format_sql=true

# Dialecte PostgreSQL
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect

# Désactiver Open Session in View (bonne pratique)
spring.jpa.open-in-view=false

# Batch processing pour les insertions en masse
spring.jpa.properties.hibernate.jdbc.batch_size=20
spring.jpa.properties.hibernate.order_inserts=true

# ═══════════════════════════════════════════════════════════
# JWT CUSTOM PROPERTIES
# ═══════════════════════════════════════════════════════════
# Clé secrète (en prod: variable d'environnement !)
jwt.secret=myVerySecretKeyForTaskFlowBackendThatMustBe256BitsMinimum
jwt.expiration=86400000         # 24 heures en millisecondes
jwt.refresh-expiration=604800000 # 7 jours

# ═══════════════════════════════════════════════════════════
# SPRING SECURITY
# ═══════════════════════════════════════════════════════════
spring.security.user.name=admin
spring.security.user.password=admin123

# ═══════════════════════════════════════════════════════════
# ACTUATOR (monitoring)
# ═══════════════════════════════════════════════════════════
management.endpoints.web.exposure.include=health,info,metrics,env
management.endpoint.health.show-details=always
management.info.app.name=TaskFlow Backend
management.info.app.version=1.0.0
management.info.app.description=Task Management SaaS API

# ═══════════════════════════════════════════════════════════
# LOGGING
# ═══════════════════════════════════════════════════════════
logging.level.root=WARN
logging.level.com.taskflow=INFO
logging.level.org.springframework.web=INFO
logging.level.org.springframework.security=INFO
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n
logging.file.name=logs/taskflow.log

# ═══════════════════════════════════════════════════════════
# EMAIL (SMTP)
# ═══════════════════════════════════════════════════════════
spring.mail.host=smtp.gmail.com
spring.mail.port=587
spring.mail.username=${MAIL_USERNAME}
spring.mail.password=${MAIL_PASSWORD}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true

# ═══════════════════════════════════════════════════════════
# CACHE
# ═══════════════════════════════════════════════════════════
spring.cache.type=caffeine
spring.cache.caffeine.spec=maximumSize=1000,expireAfterWrite=30m

# ═══════════════════════════════════════════════════════════
# UPLOAD DE FICHIERS
# ═══════════════════════════════════════════════════════════
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=50MB

# ═══════════════════════════════════════════════════════════
# CUSTOM APPLICATION PROPERTIES
# ═══════════════════════════════════════════════════════════
app.name=TaskFlow
app.version=1.0.0
app.frontend-url=http://localhost:3000
app.max-tasks-per-user=100
app.file-upload-dir=/uploads

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  PROFILS SPRING BOOT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les profils permettent d'avoir des configurations différentes par environnement.

# application-dev.properties (développement)
spring.datasource.url=jdbc:postgresql://localhost:5432/taskflow_dev
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
logging.level.com.taskflow=DEBUG
logging.level.org.springframework.web=DEBUG
# Désactiver la sécurité pour les tests locaux (optionnel)
jwt.secret=dev-secret-key-not-secure-use-only-for-development

# application-test.properties (tests automatisés)
spring.datasource.url=jdbc:h2:mem:taskflow_test;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.hibernate.ddl-auto=create-drop
spring.flyway.enabled=false
logging.level.com.taskflow=DEBUG

# application-prod.properties (production)
# Utiliser des variables d'environnement pour les secrets !
spring.datasource.url=${DB_URL}
spring.datasource.username=${DB_USERNAME}
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=false
logging.level.root=WARN
logging.level.com.taskflow=INFO
jwt.secret=${JWT_SECRET}
server.ssl.enabled=true

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3⃣  PROPERTIES TYPÉES AVEC @CONFIGURATIONPROPERTIES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Plutôt que d'utiliser @Value partout, centraliser les properties en classes typées :

// Classe de configuration typée
@ConfigurationProperties(prefix = "app")
@Component
@Validated
public class AppProperties {
    
    @NotBlank
    private String name;
    
    @NotBlank
    private String version;
    
    @NotBlank
    private String frontendUrl;
    
    @Min(1) @Max(1000)
    private int maxTasksPerUser;
    
    private String fileUploadDir;
    
    // Sous-groupe imbriqué
    private Email email = new Email();
    
    // Getters et setters...
    
    @Component
    public static class Email {
        private String fromAddress = "noreply@taskflow.com";
        private String fromName = "TaskFlow";
        // getters, setters...
    }
}

// Dans application.properties
// app.name=TaskFlow
// app.version=1.0.0
// app.frontend-url=http://localhost:3000
// app.max-tasks-per-user=100
// app.email.from-address=noreply@taskflow.com

// Injection dans un service
@Service
public class TaskService {
    
    private final AppProperties appProperties;
    
    public TaskService(AppProperties appProperties) {
        this.appProperties = appProperties;
    }
    
    public void creerTache(User user, CreateTaskRequest request) {
        // Utiliser la propriété typée
        long nbTaches = taskRepository.countByOwner(user);
        if (nbTaches >= appProperties.getMaxTasksPerUser()) {
            throw new BusinessException("Limite de " + 
                appProperties.getMaxTasksPerUser() + " tâches atteinte");
        }
        // ...
    }
}

// Configuration JWT typée
@ConfigurationProperties(prefix = "jwt")
@Component
@Validated
public class JwtProperties {
    
    @NotBlank
    private String secret;
    
    @Min(3600000)    // minimum 1 heure
    private long expiration;
    
    @Min(86400000)   // minimum 24 heures
    private long refreshExpiration;
    
    // getters, setters...
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4⃣  ACTIVER UN PROFIL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

En ligne de commande :
──────────────────────
java -jar taskflow-backend.jar --spring.profiles.active=prod

Variable d'environnement :
───────────────────────────
export SPRING_PROFILES_ACTIVE=prod
java -jar taskflow-backend.jar

Dans application.properties :
───────────────────────────────
spring.profiles.active=dev  # défaut

Avec Maven :
────────────
mvn spring-boot:run -Dspring-boot.run.profiles=dev

Dans IntelliJ :
───────────────
Run Configuration -> Active profiles : prod

═══════════════════════════════════════════════════════════════════

╔══════════════════════════════════════════════════════════╗
║   CHAPITRE 9 — MAVEN & STARTERS SPRING BOOT              ║
╚══════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  QU'EST-CE QUE MAVEN ?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Maven est un outil de gestion de projet Java qui automatise :
• La gestion des dépendances (téléchargement automatique)
• La compilation du code source
• L'exécution des tests
• Le packaging (jar, war)
• Le déploiement

Cycle de vie Maven :
────────────────────
validate -> compile -> test -> package -> verify -> install -> deploy

Commandes essentielles :
─────────────────────────
mvn clean          -> supprimer /target
mvn compile        -> compiler les sources
mvn test           -> exécuter les tests
mvn package        -> créer le JAR
mvn clean package  -> nettoyer puis packager (le plus utilisé)
mvn clean package -DskipTests  -> sans exécuter les tests
mvn spring-boot:run  -> lancer l'application
mvn dependency:tree  -> voir l'arbre des dépendances

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  LE FICHIER POM.XML COMPLET TASKFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    
    <modelVersion>4.0.0</modelVersion>
    
    <!-- ───── PARENT SPRING BOOT ───────────────────────────── -->
    <!-- Le parent gère les versions de toutes les dépendances Spring -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.0</version>
        <relativePath/>
    </parent>
    
    <!-- ───── IDENTITÉ DU PROJET ──────────────────────────── -->
    <groupId>com.taskflow</groupId>
    <artifactId>taskflow-backend</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <packaging>jar</packaging>
    <name>TaskFlow Backend</name>
    <description>SaaS Task Management API built with Spring Boot</description>
    
    <!-- ───── PROPRIÉTÉS ─────────────────────────────────── -->
    <properties>
        <java.version>21</java.version>
        <mapstruct.version>1.5.5.Final</mapstruct.version>
        <jjwt.version>0.12.3</jjwt.version>
    </properties>
    
    <!-- ───── DÉPENDANCES ────────────────────────────────── -->
    <dependencies>
        
        <!-- SPRING WEB : Controllers REST, Tomcat embarqué -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <!-- SPRING DATA JPA : ORM Hibernate, accès base de données -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        
        <!-- SPRING SECURITY : authentification et autorisation -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        
        <!-- VALIDATION : @Valid, @NotBlank, @Email, etc. -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        
        <!-- ACTUATOR : endpoints de monitoring /actuator/health -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        
        <!-- CACHE : @Cacheable, @CacheEvict -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>
        
        <!-- MAIL : envoi d'emails -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-mail</artifactId>
        </dependency>
        
        <!-- POSTGRESQL : driver base de données -->
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        
        <!-- FLYWAY : migrations de base de données -->
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-core</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-database-postgresql</artifactId>
        </dependency>
        
        <!-- LOMBOK : génération automatique du boilerplate Java -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        
        <!-- JWT : génération et validation de tokens -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>
        
        <!-- MAPSTRUCT : mapping automatique Entity <-> DTO -->
        <dependency>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct</artifactId>
            <version>${mapstruct.version}</version>
        </dependency>
        
        <!-- CAFFEINE : implémentation de cache haute performance -->
        <dependency>
            <groupId>com.github.ben-manes.caffeine</groupId>
            <artifactId>caffeine</artifactId>
        </dependency>
        
        <!-- SPRINGDOC OPENAPI : documentation Swagger automatique -->
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>2.5.0</version>
        </dependency>
        
        <!-- DEVTOOLS : rechargement à chaud en développement -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        
        <!-- ─── DÉPENDANCES DE TEST ─────────────────────── -->
        
        <!-- SPRING TEST : intégration tests + MockMvc -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        
        <!-- SPRING SECURITY TEST -->
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        
        <!-- H2 : base de données en mémoire pour les tests -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>test</scope>
        </dependency>
        
        <!-- TESTCONTAINERS : PostgreSQL dans Docker pour les tests d'intégration -->
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>postgresql</artifactId>
            <scope>test</scope>
        </dependency>
        
    </dependencies>
    
    <!-- ───── BUILD ───────────────────────────────────────── -->
    <build>
        <plugins>
            
            <!-- Plugin Spring Boot : créer le fat JAR exécutable -->
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
            
            <!-- Compilateur Maven : configuration Java 21 + Lombok + MapStruct -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>21</source>
                    <target>21</target>
                    <annotationProcessorPaths>
                        <!-- Lombok DOIT être avant MapStruct -->
                        <path>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </path>
                        <path>
                            <groupId>org.mapstruct</groupId>
                            <artifactId>mapstruct-processor</artifactId>
                            <version>${mapstruct.version}</version>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
            
        </plugins>
    </build>
    
</project>

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3⃣  LES STARTERS SPRING BOOT EXPLIQUÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Un starter est un ensemble préconfiguré de dépendances. C'est la magie de Spring Boot.

spring-boot-starter-web
════════════════════════
Contient :
• Spring MVC (controllers, routing, JSON)
• Tomcat embarqué (serveur HTTP)
• Jackson (sérialisation JSON)
• Validation

Ce qu'il configure automatiquement :
• DispatcherServlet (point d'entrée des requêtes)
• Convertisseurs de messages (JSON, XML)
• Gestion des erreurs par défaut

spring-boot-starter-data-jpa
══════════════════════════════
Contient :
• Spring Data JPA (repository pattern)
• Hibernate (ORM)
• Spring JDBC
• HikariCP (pool de connexions)

Ce qu'il configure automatiquement :
• EntityManagerFactory
• TransactionManager
• Repositories JPA

spring-boot-starter-security
══════════════════════════════
Contient :
• Spring Security Core
• Spring Security Web
• Spring Security Config

Ce qu'il configure automatiquement :
• Formulaire de login par défaut (/login)
• Protection CSRF
• HTTP Basic auth

[ATTENTION] Attention : dès l'ajout de spring-security, TOUTES les routes sont sécurisées !
   Il faut configurer SecurityConfig pour personnaliser.

spring-boot-starter-test
═════════════════════════
Contient :
• JUnit 5 (framework de test)
• Mockito (mocking)
• Spring Test / Spring Boot Test
• AssertJ (assertions fluentes)
• Hamcrest (matchers)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4⃣  LOMBOK : ÉLIMINER LE BOILERPLATE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Lombok est une bibliothèque Java qui génère du code à la compilation via des annotations.
Indispensable en Spring Boot pour réduire de 60% le code répétitif.

Principales annotations Lombok :
──────────────────────────────────

import lombok.*;

// @Getter -> génère tous les getters
// @Setter -> génère tous les setters
// @ToString -> génère toString()
// @EqualsAndHashCode -> génère equals() et hashCode()
// @NoArgsConstructor -> constructeur sans arguments
// @AllArgsConstructor -> constructeur avec tous les arguments
// @RequiredArgsConstructor -> constructeur pour les champs final et @NonNull

// @Data = @Getter + @Setter + @ToString + @EqualsAndHashCode + @RequiredArgsConstructor
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder                   // -> génère TacheDTO.builder()...build()
public class TacheDTO {
    private Long id;
    private String titre;
    private String statut;
    private String proprietaire;
    private LocalDateTime createdAt;
}

// AVANT Lombok (50+ lignes)
public class TacheDTO {
    private Long id;
    private String titre;
    // ...
    
    public TacheDTO() {}
    public TacheDTO(Long id, String titre, ...) { this.id = id; this.titre = titre; ... }
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    // ... 50 lignes de boilerplate
    @Override public String toString() { ... }
    @Override public boolean equals(Object o) { ... }
    @Override public int hashCode() { ... }
}

// APRÈS Lombok (5 lignes !)
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class TacheDTO {
    private Long id;
    private String titre;
    private String statut;
    private String proprietaire;
    private LocalDateTime createdAt;
}

// Utilisation du Builder
TacheDTO dto = TacheDTO.builder()
    .id(1L)
    .titre("Implémenter JWT")
    .statut("EN_COURS")
    .proprietaire("Alice Martin")
    .createdAt(LocalDateTime.now())
    .build();

// @Slf4j -> logger automatique
@Slf4j
@Service
public class TaskService {
    
    public void creerTache(CreateTaskRequest request) {
        log.info("Création d'une nouvelle tâche : {}", request.getTitre());
        // 'log' est automatiquement disponible !
        // Équivaut à :
        // private static final Logger log = LoggerFactory.getLogger(TaskService.class);
    }
}

// @Value (Lombok, pas Spring !) -> classe immuable
@Value
public class TaskId {
    Long value;
    // Génère : constructeur all-args, getters, equals, hashCode, toString
    // Tous les champs sont private final
    // Pas de setters !
}

// @NonNull -> null check automatique
public void assignerTache(@NonNull Long taskId, @NonNull Long userId) {
    // Lombok génère : if (taskId == null) throw new NullPointerException("taskId is marked non-null but is null")
}

// @SneakyThrows -> relance les checked exceptions sans déclarer throws
@SneakyThrows
public String lireConfig(String path) {
    return Files.readString(Path.of(path));
    // Pas besoin de try-catch ou de déclarer throws IOException
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  PREMIER ENDPOINT SPRING BOOT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Créons le premier endpoint de TaskFlow pour vérifier que tout fonctionne :

// src/main/java/com/taskflow/backend/controller/HealthController.java

package com.taskflow.backend.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.extern.slf4j.Slf4j;
import java.time.LocalDateTime;
import java.util.Map;

// @RestController = @Controller + @ResponseBody
// -> Tous les retours de méthodes sont automatiquement convertis en JSON
@RestController
@RequestMapping("/api/v1")   // préfixe commun à tous les endpoints de ce controller
@Slf4j
public class HealthController {
    
    // GET /api/v1/health
    @GetMapping("/health")
    public ResponseEntity<Map<String, Object>> health() {
        log.info("Health check endpoint appelé");
        
        Map<String, Object> response = Map.of(
            "status", "UP",
            "service", "TaskFlow Backend",
            "version", "1.0.0",
            "timestamp", LocalDateTime.now().toString(),
            "environment", "development"
        );
        
        return ResponseEntity.ok(response);
    }
    
    // GET /api/v1/info
    @GetMapping("/info")
    public ResponseEntity<Map<String, String>> info() {
        return ResponseEntity.ok(Map.of(
            "name", "TaskFlow Backend",
            "description", "Task Management SaaS API",
            "version", "1.0.0",
            "author", "TaskFlow Team",
            "documentation", "/swagger-ui.html"
        ));
    }
}

Test avec cURL :
────────────────
curl http://localhost:8080/api/v1/health

Réponse attendue :
──────────────────
{
  "status": "UP",
  "service": "TaskFlow Backend",
  "version": "1.0.0",
  "timestamp": "2025-01-15T10:30:00.123",
  "environment": "development"
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6⃣  SPRING BOOT DEVTOOLS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

DevTools améliore l'expérience de développement :

• Rechargement automatique (restart) lors des changements de code
• LiveReload : rechargement du navigateur automatique
• Cache désactivé en développement
• Console H2 activée

Configuration dans application-dev.properties :
──────────────────────────────────────────────
spring.devtools.restart.enabled=true
spring.devtools.livereload.enabled=true
spring.devtools.restart.additional-exclude=static/**,public/**

[ATTENTION] DevTools est automatiquement désactivé en production (scope runtime, optional).

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7⃣  DOCKER ET DOCKER-COMPOSE POUR TASKFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Pour le développement local, on utilise Docker pour PostgreSQL :

# docker-compose.yml
version: '3.8'

services:
  
  # Base de données PostgreSQL
  postgres:
    image: postgres:16-alpine
    container_name: taskflow-postgres
    environment:
      POSTGRES_DB: taskflow_db
      POSTGRES_USER: taskflow_user
      POSTGRES_PASSWORD: taskflow_password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./src/main/resources/db/init.sql:/docker-entrypoint-initdb.d/init.sql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U taskflow_user -d taskflow_db"]
      interval: 10s
      timeout: 5s
      retries: 5
  
  # PgAdmin - interface web pour PostgreSQL (optionnel)
  pgadmin:
    image: dpage/pgadmin4
    container_name: taskflow-pgadmin
    environment:
      PGADMIN_DEFAULT_EMAIL: admin@taskflow.com
      PGADMIN_DEFAULT_PASSWORD: admin
    ports:
      - "5050:80"
    depends_on:
      - postgres
  
  # Redis (pour le cache en production)
  redis:
    image: redis:7-alpine
    container_name: taskflow-redis
    ports:
      - "6379:6379"
    command: redis-server --requirepass taskflow_redis_password

volumes:
  postgres_data:

Commandes Docker-Compose :
───────────────────────────
# Démarrer tous les services
docker-compose up -d

# Vérifier les services actifs
docker-compose ps

# Voir les logs PostgreSQL
docker-compose logs postgres

# Arrêter et supprimer les containers
docker-compose down

# Arrêter et supprimer containers + volumes (reset base de données)
docker-compose down -v

Avec Docker-Compose démarré, lancer Spring Boot :
──────────────────────────────────────────────────
# 1. Démarrer PostgreSQL
docker-compose up -d postgres

# 2. Lancer Spring Boot
mvn spring-boot:run

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[EFFORT]  EXERCICES PARTIE 2
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FACILE :
Ex 9.1 : Créez le projet Spring Boot TaskFlow avec Spring Initializr en local.
Ex 9.2 : Ajoutez un endpoint GET /api/v1/ping qui retourne {"pong": true, "timestamp": ...}
Ex 9.3 : Configurez deux profils (dev, prod) avec des ports différents (8080 et 8443).

INTERMÉDIAIRE :
Ex 9.4 : Créez une classe AppProperties avec @ConfigurationProperties pour les propriétés
         app.name, app.version, app.max-users.
Ex 9.5 : Modifiez le Dockerfile pour créer une image Docker multi-stage du projet.
Ex 9.6 : Ajoutez Spring Boot Actuator et configurez les endpoints health et info.

AVANCÉ :
Ex 9.7 : Configurez le logging avec Logback (logback-spring.xml) pour écrire dans
         des fichiers rotatifs.
Ex 9.8 : Créez un docker-compose.yml complet avec l'application Spring Boot, PostgreSQL
         et Redis.
Ex 9.9 : Ajoutez OpenAPI/Swagger avec springdoc et configurez la documentation
         avec des informations sur TaskFlow.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[CLE]  CORRIGÉ EX 9.5 — DOCKERFILE MULTI-STAGE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

# ─── ÉTAPE 1 : BUILD ─────────────────────────────────────────
FROM maven:3.9-eclipse-temurin-21 AS builder

# Dossier de travail dans le container
WORKDIR /app

# Copier d'abord le pom.xml (optimisation du cache Docker)
COPY pom.xml .

# Télécharger les dépendances (mis en cache si pom.xml n'a pas changé)
RUN mvn dependency:go-offline -B

# Copier le code source
COPY src ./src

# Compiler et packager (sans les tests)
RUN mvn clean package -DskipTests

# ─── ÉTAPE 2 : RUNTIME ──────────────────────────────────────
# Image légère JRE uniquement (pas JDK)
FROM eclipse-temurin:21-jre-alpine

WORKDIR /app

# Créer un utilisateur non-root (sécurité)
RUN addgroup -S taskflow && adduser -S taskflow -G taskflow

# Copier uniquement le JAR depuis l'étape builder
COPY --from=builder /app/target/taskflow-backend-0.0.1-SNAPSHOT.jar app.jar

# Créer le dossier pour les uploads
RUN mkdir -p /uploads && chown taskflow:taskflow /uploads

# Utiliser l'utilisateur non-root
USER taskflow

# Port exposé
EXPOSE 8080

# Variables d'environnement par défaut
ENV SPRING_PROFILES_ACTIVE=prod
ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC"

# Point d'entrée
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

# Vérification de santé
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
  CMD wget -qO- http://localhost:8080/api/v1/health || exit 1

# ─── COMMANDES ──────────────────────────────────────────────
# Construire l'image
# docker build -t taskflow-backend:latest .

# Exécuter
# docker run -p 8080:8080 \
#   -e SPRING_PROFILES_ACTIVE=prod \
#   -e DB_URL=jdbc:postgresql://db:5432/taskflow \
#   -e DB_USERNAME=taskflow_user \
#   -e DB_PASSWORD=secret \
#   -e JWT_SECRET=myVerySecretKey \
#   taskflow-backend:latest

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[LISTE] RÉSUMÉ PARTIE 2 — CE QUE VOUS AVEZ APPRIS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[OK] Créer un projet Spring Boot avec Spring Initializr
[OK] Comprendre la structure de projet professionnelle (architecture en couches)
[OK] Configurer application.properties pour dev/test/prod
[OK] Utiliser les profils Spring Boot
[OK] Comprendre Maven et le pom.xml complet
[OK] Connaître les starters Spring Boot et ce qu'ils apportent
[OK] Utiliser Lombok pour éliminer le boilerplate
[OK] Créer un premier endpoint REST
[OK] Dockeriser l'application avec multi-stage build

[SOON_WITH_RIGHTWARDS_ARROW_ABOVE] PARTIE 3 : IoC, Dependency Injection, et le cycle de vie des Beans

═══════════════════════════════════════════════════════════════════
FIN DE LA PARTIE 2 — spring_boot_part_2.txt
═══════════════════════════════════════════════════════════════════

╔══════════════════════════════════════════════════════════════════════════════════╗
║            GUIDE COMPLET SPRING BOOT — NIVEAU ENTREPRISE                        ║
║            PARTIE 3 : IoC, INJECTION DE DÉPENDANCES & BEANS                      ║
║            Chapitres 10 à 12 : Inversion de contrôle · DI · Cycle de vie         ║
╚══════════════════════════════════════════════════════════════════════════════════╝

╔══════════════════════════════════════════════════════════╗
║   CHAPITRE 10 — INVERSION DE CONTRÔLE (IoC)              ║
╚══════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  QU'EST-CE QUE L'IoC ?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

L'Inversion of Control (IoC) est un principe de conception où le contrôle
du flux d'un programme est inversé : au lieu que votre code crée ses propres
dépendances, un framework externe (Spring) les crée et les gère.

Analogie :
──────────
Sans IoC :
  Vous allez au restaurant, vous allez en cuisine, vous cuisinez vous-même.

Avec IoC :
  Vous allez au restaurant, vous commandez, le chef (Spring) cuisine et vous apporte.

Problème sans IoC :
────────────────────

// MAUVAIS : couplage fort
public class TaskController {
    
    // Le controller crée lui-même ses dépendances -> problèmes :
    // 1. Couplage fort -> impossible de changer l'implémentation
    // 2. Impossible de tester (pas de mock possible)
    // 3. Duplication -> chaque classe crée ses propres instances
    
    private TaskService taskService = new TaskServiceImpl();  // couplage !
    private EmailService emailService = new EmailServiceImpl(
        new SmtpConfig("smtp.gmail.com", 587)  // dépendances en cascade !
    );
    private TaskRepository taskRepository = new TaskRepositoryImpl(
        new DatabaseConnection("jdbc:postgresql://localhost:5432/taskflow")
    );
    
    public Task creerTache(CreateTaskRequest request) {
        // ...
    }
}

Solution avec IoC :
────────────────────

// BON : Spring gère les dépendances
@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {
    
    // Spring INJECTE les dépendances -> couplage faible
    private final TaskService taskService;
    private final EmailService emailService;
    
    // Spring appelle ce constructeur avec les bonnes implémentations
    public TaskController(TaskService taskService, EmailService emailService) {
        this.taskService = taskService;
        this.emailService = emailService;
    }
    
    // Le controller ne sait pas COMMENT les services sont implémentés
    // Il sait juste QUOI ils peuvent faire (via les interfaces)
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  LE CONTENEUR SPRING (APPLICATION CONTEXT)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Spring IoC Container (aussi appelé ApplicationContext) est le cœur de Spring.
Il est responsable de :
  1. Lire la configuration (@SpringBootApplication, annotations)
  2. Créer les objets (beans) selon la configuration
  3. Câbler les dépendances (injection)
  4. Gérer le cycle de vie des beans

Fonctionnement interne :
─────────────────────────

  Démarrage Spring Boot
         │
         [BLACK_DOWN-POINTING_TRIANGLE]
  @SpringBootApplication
  -> @ComponentScan scanne com.taskflow.**
         │
         [BLACK_DOWN-POINTING_TRIANGLE]
  Détection des beans :
  • @Component, @Service, @Repository, @Controller
  • @Configuration + @Bean
  • @RestController
         │
         [BLACK_DOWN-POINTING_TRIANGLE]
  Création du BeanDefinition pour chaque bean :
  • nom, classe, scope, dépendances, méthodes lifecycle
         │
         [BLACK_DOWN-POINTING_TRIANGLE]
  Résolution des dépendances :
  • Trouver les dépendances de chaque bean
  • Ordre de création selon le graphe de dépendances
         │
         [BLACK_DOWN-POINTING_TRIANGLE]
  Instanciation des beans (new ClassName())
         │
         [BLACK_DOWN-POINTING_TRIANGLE]
  Injection des dépendances
         │
         [BLACK_DOWN-POINTING_TRIANGLE]
  Appel des méthodes @PostConstruct
         │
         [BLACK_DOWN-POINTING_TRIANGLE]
  ApplicationContext prêt -> Tomcat démarre -> Application prête

═══════════════════════════════════════════════════════════════════

╔══════════════════════════════════════════════════════════╗
║   CHAPITRE 11 — DEPENDENCY INJECTION (DI)                ║
╚══════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  LES 3 MODES D'INJECTION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Spring supporte 3 façons d'injecter des dépendances :

MODE 1 : INJECTION PAR CONSTRUCTEUR [OK] (RECOMMANDÉ)
────────────────────────────────────────────────────

@Service
public class TaskService {
    
    // Champs final -> immutables après construction
    private final TaskRepository taskRepository;
    private final UserRepository userRepository;
    private final EmailService emailService;
    private final JwtService jwtService;
    
    // Constructeur : Spring injecte automatiquement les dépendances
    // Depuis Spring 4.3 : @Autowired optionnel si un seul constructeur
    public TaskService(
            TaskRepository taskRepository,
            UserRepository userRepository,
            EmailService emailService,
            JwtService jwtService) {
        this.taskRepository = taskRepository;
        this.userRepository = userRepository;
        this.emailService = emailService;
        this.jwtService = jwtService;
    }
}

// Avec Lombok @RequiredArgsConstructor (génère le constructeur automatiquement)
@Service
@RequiredArgsConstructor  // génère constructeur pour tous les champs final
@Slf4j
public class TaskService {
    
    private final TaskRepository taskRepository;
    private final UserRepository userRepository;
    private final EmailService emailService;
    private final JwtService jwtService;
    
    // Spring voit le constructeur généré par Lombok et injecte !
}

Pourquoi préférer l'injection par constructeur ?
• Dépendances clairement visibles
• Champs final -> pas de modification accidentelle
• Facilite les tests (on peut passer des mocks)
• Détection des dépendances circulaires à la compilation

MODE 2 : INJECTION PAR SETTER [ATTENTION] (UTILISER AVEC PRÉCAUTION)
─────────────────────────────────────────────────────────────

@Service
public class NotificationService {
    
    private EmailService emailService;
    private SmsService smsService;
    
    // @Autowired sur setter : Spring appelle le setter après construction
    @Autowired
    public void setEmailService(EmailService emailService) {
        this.emailService = emailService;
    }
    
    @Autowired(required = false)  // dépendance optionnelle
    public void setSmsService(SmsService smsService) {
        this.smsService = smsService;
    }
}

Quand utiliser ? Pour les dépendances optionnelles seulement.

MODE 3 : INJECTION PAR CHAMP [X] (DÉCONSEILLÉ)
──────────────────────────────────────────────

@Service
public class TaskService {
    
    @Autowired  // Spring injecte directement dans le champ (via réflexion)
    private TaskRepository taskRepository;
    
    @Autowired
    private EmailService emailService;
}

Pourquoi éviter ?
• Champs non-final -> peuvent être null si mal initialisés
• Impossible de tester sans Spring
• Cache les dépendances (pas visibles dans le constructeur)
• IntelliJ affiche un warning "Field injection is not recommended"

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  RÉSOLUTION DES AMBIGUÏTÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Si plusieurs beans du même type existent, Spring ne sait pas lequel injecter.
Solutions :

// Interface
public interface NotificationSender {
    void send(String destinataire, String message);
}

// Implémentation 1
@Service
@Primary  // -> utilisé par défaut
public class EmailSender implements NotificationSender {
    @Override
    public void send(String destinataire, String message) {
        System.out.println("[EMAIL] Email -> " + destinataire + " : " + message);
    }
}

// Implémentation 2
@Service
public class SmsSender implements NotificationSender {
    @Override
    public void send(String destinataire, String message) {
        System.out.println("[MOBILE] SMS -> " + destinataire + " : " + message);
    }
}

// Implémentation 3
@Service
public class PushSender implements NotificationSender {
    @Override
    public void send(String destinataire, String message) {
        System.out.println("[NOTIF] Push -> " + destinataire + " : " + message);
    }
}

// Utilisation avec @Qualifier
@Service
@RequiredArgsConstructor
public class AlertService {
    
    // @Primary : injecte EmailSender par défaut
    private final NotificationSender defaultSender;
    
    // @Qualifier : injecte SmsSender spécifiquement
    @Qualifier("smsSender")
    private final NotificationSender smsSender;
    
    // Injecter TOUTES les implémentations
    private final List<NotificationSender> allSenders;
    
    public void envoyerAlerte(String message) {
        // Utiliser tous les senders
        allSenders.forEach(sender -> sender.send("admin@taskflow.com", message));
    }
}

// Injection de Map<String, NotificationSender> -> clé = nom du bean
@Service
public class NotificationRouter {
    
    private final Map<String, NotificationSender> senders;
    
    public NotificationRouter(Map<String, NotificationSender> senders) {
        this.senders = senders;
    }
    
    public void envoyer(String type, String destinataire, String message) {
        // type = "emailSender", "smsSender", "pushSender"
        NotificationSender sender = senders.get(type + "Sender");
        if (sender == null) throw new IllegalArgumentException("Sender inconnu: " + type);
        sender.send(destinataire, message);
    }
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3⃣  INJECTION DE VALEURS DEPUIS LES PROPERTIES
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// @Value : injecter une propriété dans un champ

@Service
@Slf4j
public class JwtService {
    
    // Injection de propriété simple
    @Value("${jwt.secret}")
    private String jwtSecret;
    
    // Avec valeur par défaut
    @Value("${jwt.expiration:86400000}")  // 24h par défaut
    private long jwtExpiration;
    
    // Injection de propriétés système
    @Value("${user.home}")
    private String userHome;
    
    // Injection de listes
    @Value("${app.allowed-origins:http://localhost:3000,http://localhost:4200}")
    private List<String> allowedOrigins;
    
    // Injection avec expression SpEL (Spring Expression Language)
    @Value("#{T(java.time.LocalDate).now().toString()}")
    private String today;
    
    @Value("#{systemProperties['user.name']}")
    private String currentUser;
    
    public String generateToken(String username) {
        log.debug("Génération du token JWT pour : {}", username);
        // ... logique JWT
        return "eyJhbGc...";
    }
}

// application.properties
// jwt.secret=myVerySecretKey256BitsMinimum
// jwt.expiration=86400000
// app.allowed-origins=http://localhost:3000,http://localhost:4200

═══════════════════════════════════════════════════════════════════

╔══════════════════════════════════════════════════════════╗
║    CHAPITRE 12 — BEANS SPRING : CYCLE DE VIE & SCOPES    ║
╚══════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  ANNOTATIONS DE BEAN
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Spring reconnaît plusieurs annotations pour déclarer des beans :

@Component    -> composant générique
@Service      -> logique métier (sémantique)
@Repository   -> accès base de données + gestion exceptions
@Controller   -> Spring MVC
@RestController -> @Controller + @ResponseBody (REST API)
@Configuration -> classe de configuration (définit des @Bean)

// @Component générique
@Component
public class DateUtils {
    public String formaterDate(LocalDate date) {
        return date.format(DateTimeFormatter.ofPattern("dd/MM/yyyy"));
    }
}

// @Service : logique métier
@Service
@Slf4j
public class TaskService {
    // ...
}

// @Repository : couche accès données
// + Spring traduit automatiquement les exceptions JPA en DataAccessException
@Repository
public interface TaskRepository extends JpaRepository<Task, Long> {
    // ...
}

// @Configuration + @Bean : définition manuelle de beans
@Configuration
public class AppConfig {
    
    // @Bean -> méthode qui crée et configure un bean
    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
        mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
        return mapper;
    }
    
    @Bean
    public PasswordEncoder passwordEncoder() {
        // BCrypt : algorithme de hachage de mots de passe
        return new BCryptPasswordEncoder(12);  // 12 = rounds
    }
    
    @Bean
    @ConditionalOnProperty(name = "app.email.enabled", havingValue = "true")
    public JavaMailSender mailSender() {
        JavaMailSenderImpl sender = new JavaMailSenderImpl();
        sender.setHost("smtp.gmail.com");
        sender.setPort(587);
        // ...
        return sender;
    }
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  SCOPES DES BEANS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le scope définit le cycle de vie et le nombre d'instances d'un bean.

SINGLETON (par défaut) :
─────────────────────────
Une seule instance partagée par TOUTE l'application.

@Service
// @Scope("singleton") -> optionnel, c'est le défaut
public class TaskService {
    // UNE seule instance créée au démarrage
    // Partagée par tous les controllers -> doit être THREAD-SAFE !
}

PROTOTYPE :
────────────
Une nouvelle instance à chaque injection/demande.

@Component
@Scope("prototype")
public class TaskBuilder {
    // NOUVELLE instance à chaque fois qu'on l'injecte
    // Utile pour les objets avec état interne qui ne doivent pas être partagés
}

// Utiliser ApplicationContext pour obtenir des beans prototype
@Service
@RequiredArgsConstructor
public class TaskService {
    private final ApplicationContext context;
    
    public Task creerTache(CreateTaskRequest request) {
        // Nouvelle instance à chaque appel
        TaskBuilder builder = context.getBean(TaskBuilder.class);
        return builder.titre(request.getTitre())
                      .description(request.getDescription())
                      .build();
    }
}

REQUEST (Web uniquement) :
───────────────────────────
Une instance par requête HTTP.

@Component
@Scope(value = WebApplicationContext.SCOPE_REQUEST, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class RequestContext {
    private String requestId;
    private LocalDateTime requestTime;
    // Données spécifiques à la requête courante
}

SESSION (Web uniquement) :
───────────────────────────
Une instance par session HTTP.

@Component
@Scope(value = WebApplicationContext.SCOPE_SESSION, proxyMode = ScopedProxyMode.TARGET_CLASS)
public class UserSession {
    private Long userId;
    private Set<String> recentTaskIds = new HashSet<>();
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3⃣  CYCLE DE VIE D'UN BEAN
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Instanciation (new)
       │
Injection des dépendances
       │
@PostConstruct (initialisation)
       │
Bean prêt -> utilisé par l'application
       │
@PreDestroy (nettoyage à l'arrêt)
       │
Destruction du bean

@Service
@Slf4j
public class DatabaseConnectionPool {
    
    private List<Connection> pool = new ArrayList<>();
    
    @Value("${db.pool.size:10}")
    private int poolSize;
    
    // Appelé APRÈS l'injection de toutes les dépendances
    // Parfait pour l'initialisation qui nécessite des dépendances
    @PostConstruct
    public void initialiser() {
        log.info("Initialisation du pool de connexions (size={})", poolSize);
        for (int i = 0; i < poolSize; i++) {
            pool.add(creerConnexion());
        }
        log.info("Pool initialisé avec {} connexions", pool.size());
    }
    
    // Appelé AVANT la destruction du bean (arrêt de l'application)
    @PreDestroy
    public void nettoyer() {
        log.info("Fermeture de {} connexions", pool.size());
        pool.forEach(this::fermerConnexion);
        pool.clear();
        log.info("Pool nettoyé");
    }
    
    private Connection creerConnexion() { return null; /* ... */ }
    private void fermerConnexion(Connection c) { /* ... */ }
}

// Utiliser InitializingBean et DisposableBean (alternative)
@Service
public class CacheService implements InitializingBean, DisposableBean {
    
    private Map<String, Object> cache;
    
    @Override
    public void afterPropertiesSet() throws Exception {
        // Équivalent à @PostConstruct
        this.cache = new ConcurrentHashMap<>();
        log.info("Cache initialisé");
    }
    
    @Override
    public void destroy() throws Exception {
        // Équivalent à @PreDestroy
        this.cache.clear();
        log.info("Cache nettoyé");
    }
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4⃣  AUTO-CONFIGURATION SPRING BOOT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Spring Boot configure automatiquement des beans selon les dépendances présentes.

Comment ça marche :
────────────────────
1. Spring Boot lit les fichiers META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
2. Pour chaque auto-configuration, vérifie des conditions (@ConditionalOn...)
3. Si conditions remplies -> crée les beans

Exemples de conditions :
─────────────────────────

@Configuration
@ConditionalOnClass(DataSource.class)  // si DataSource est dans le classpath
@ConditionalOnProperty(name = "spring.datasource.url")  // si la propriété existe
public class DataSourceAutoConfiguration {
    
    @Bean
    @ConditionalOnMissingBean  // uniquement si l'utilisateur n'a pas créé le sien
    public DataSource dataSource() {
        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl(url);
        // ...
        return ds;
    }
}

// Voir toutes les auto-configurations actives :
// mvn spring-boot:run --debug 2>&1 | grep "CONDITIONS EVALUATION REPORT"

// Ou dans application.properties :
// logging.level.org.springframework.boot.autoconfigure=DEBUG

Créer sa propre auto-configuration (avancé) :
──────────────────────────────────────────────

@Configuration
@ConditionalOnClass(TaskflowMetrics.class)
@ConditionalOnProperty(
    name = "taskflow.metrics.enabled",
    havingValue = "true",
    matchIfMissing = true
)
public class TaskflowMetricsAutoConfiguration {
    
    @Bean
    @ConditionalOnMissingBean
    public TaskflowMetrics taskflowMetrics() {
        return new TaskflowMetrics();
    }
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  BEANS CONDITIONNELS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// Créer un bean uniquement sous certaines conditions

@Configuration
public class NotificationConfig {
    
    // Bean uniquement si email.enabled=true
    @Bean
    @ConditionalOnProperty(name = "app.email.enabled", havingValue = "true", matchIfMissing = false)
    public EmailService emailService() {
        return new SmtpEmailService();
    }
    
    // Bean de remplacement si email désactivé
    @Bean
    @ConditionalOnMissingBean(EmailService.class)
    public EmailService noOpEmailService() {
        return (to, subject, body) -> {
            log.info("Email simulé -> To:{} Subject:{}", to, subject);
        };
    }
    
    // Bean selon le profil actif
    @Bean
    @Profile("dev")
    public NotificationSender devNotificationSender() {
        return (to, msg) -> log.debug("DEV - Notification -> {} : {}", to, msg);
    }
    
    @Bean
    @Profile("prod")
    public NotificationSender prodNotificationSender() {
        return new RealNotificationSender();
    }
    
    // Bean selon l'OS
    @Bean
    @ConditionalOnProperty(name = "os.name", havingValue = "Linux")
    public SystemService linuxSystemService() {
        return new LinuxSystemService();
    }
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
6⃣  TASKFLOW : VUE D'ENSEMBLE DES BEANS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Voici comment les beans TaskFlow sont interconnectés :

  ApplicationContext
       │
  ┌────┴────────────────────────────────────────────────────────────────┐
  │                                                                      │
  │  @RestController                                                     │
  │  TaskController ─────────────────────────[BLACK_RIGHT-POINTING_POINTER] TaskService               │
  │                                                  │                   │
  │  @RestController                                 ├──[BLACK_RIGHT-POINTING_POINTER] TaskRepository │
  │  AuthController ─────────────────────────[BLACK_RIGHT-POINTING_POINTER]       │    (JPA)          │
  │                     AuthService                  │                   │
  │                          │                       ├──[BLACK_RIGHT-POINTING_POINTER] UserRepository │
  │  @RestController         ├──[BLACK_RIGHT-POINTING_POINTER] UserRepository      │    (JPA)         │
  │  UserController ─────────[BLACK_RIGHT-POINTING_POINTER]                       │                   │
  │                     UserService                  └──[BLACK_RIGHT-POINTING_POINTER] EmailService   │
  │                          │                                           │
  │                          └──[BLACK_RIGHT-POINTING_POINTER] JwtService                             │
  │                               (singleton)                            │
  │                                                                      │
  │  @Configuration beans :                                              │
  │  • PasswordEncoder (BCrypt)                                          │
  │  • ObjectMapper (Jackson)                                            │
  │  • SecurityFilterChain                                               │
  │  • AuthenticationManager                                             │
  └──────────────────────────────────────────────────────────────────────┘

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
7⃣  IMPLÉMENTATION COMPLÈTE : SERVICES TASKFLOW
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// Voici comment structurer les services selon les bonnes pratiques Spring Boot

// Interface de service (bonne pratique pour découplage)
public interface TaskService {
    TaskResponse creerTache(Long userId, CreateTaskRequest request);
    TaskResponse getTache(Long taskId, Long userId);
    Page<TaskResponse> listerTaches(Long userId, TaskFilters filters, Pageable pageable);
    TaskResponse mettreAJour(Long taskId, Long userId, UpdateTaskRequest request);
    void supprimerTache(Long taskId, Long userId);
    TaskResponse changerStatut(Long taskId, Long userId, StatutTache nouveauStatut);
}

// Implémentation
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional  // toutes les méthodes sont transactionnelles par défaut
public class TaskServiceImpl implements TaskService {
    
    private final TaskRepository taskRepository;
    private final UserRepository userRepository;
    private final TaskMapper taskMapper;
    private final EmailService emailService;
    private final AppProperties appProperties;
    
    @Override
    public TaskResponse creerTache(Long userId, CreateTaskRequest request) {
        log.info("Création de tâche pour userId={}, titre='{}'", userId, request.getTitre());
        
        // Récupérer l'utilisateur
        User user = userRepository.findById(userId)
            .orElseThrow(() -> new ResourceNotFoundException("User", userId));
        
        // Vérifier la limite de tâches
        long nbTaches = taskRepository.countByOwner(user);
        if (nbTaches >= appProperties.getMaxTasksPerUser()) {
            throw new BusinessException(
                "Limite de " + appProperties.getMaxTasksPerUser() + " tâches atteinte. " +
                "Passez à un plan supérieur."
            );
        }
        
        // Créer la tâche
        Task task = taskMapper.toEntity(request);
        task.setOwner(user);
        task.setStatut(StatutTache.A_FAIRE);
        
        Task savedTask = taskRepository.save(task);
        log.info("Tâche créée avec succès : id={}", savedTask.getId());
        
        // Notification email asynchrone
        emailService.envoyerConfirmationCreation(user.getEmail(), savedTask.getTitre());
        
        return taskMapper.toResponse(savedTask);
    }
    
    @Override
    @Transactional(readOnly = true)  // optimisation : transaction en lecture seule
    public TaskResponse getTache(Long taskId, Long userId) {
        Task task = taskRepository.findById(taskId)
            .orElseThrow(() -> new ResourceNotFoundException("Task", taskId));
        
        // Vérifier que l'utilisateur a accès à cette tâche
        verifierAcces(task, userId);
        
        return taskMapper.toResponse(task);
    }
    
    @Override
    @Transactional(readOnly = true)
    public Page<TaskResponse> listerTaches(Long userId, TaskFilters filters, Pageable pageable) {
        User user = userRepository.getReferenceById(userId);
        
        Page<Task> taches = taskRepository.findByOwnerWithFilters(user, filters, pageable);
        return taches.map(taskMapper::toResponse);
    }
    
    @Override
    public TaskResponse mettreAJour(Long taskId, Long userId, UpdateTaskRequest request) {
        Task task = taskRepository.findById(taskId)
            .orElseThrow(() -> new ResourceNotFoundException("Task", taskId));
        
        verifierProprietaire(task, userId);
        
        // Mise à jour sélective (PATCH-like)
        if (request.getTitre() != null) task.setTitre(request.getTitre());
        if (request.getDescription() != null) task.setDescription(request.getDescription());
        if (request.getPriorite() != null) task.setPriorite(request.getPriorite());
        if (request.getDateEcheance() != null) task.setDateEcheance(request.getDateEcheance());
        
        Task updated = taskRepository.save(task);
        return taskMapper.toResponse(updated);
    }
    
    @Override
    public void supprimerTache(Long taskId, Long userId) {
        Task task = taskRepository.findById(taskId)
            .orElseThrow(() -> new ResourceNotFoundException("Task", taskId));
        
        verifierProprietaire(task, userId);
        
        taskRepository.delete(task);
        log.info("Tâche supprimée : id={} par userId={}", taskId, userId);
    }
    
    @Override
    public TaskResponse changerStatut(Long taskId, Long userId, StatutTache nouveauStatut) {
        Task task = taskRepository.findById(taskId)
            .orElseThrow(() -> new ResourceNotFoundException("Task", taskId));
        
        verifierAcces(task, userId);
        
        StatutTache ancienStatut = task.getStatut();
        
        // Valider la transition de statut
        validerTransitionStatut(ancienStatut, nouveauStatut);
        
        task.setStatut(nouveauStatut);
        if (nouveauStatut == StatutTache.TERMINEE) {
            task.setDateTerminaison(LocalDateTime.now());
        }
        
        Task updated = taskRepository.save(task);
        
        log.info("Statut tâche {} changé : {} -> {}", taskId, ancienStatut, nouveauStatut);
        
        return taskMapper.toResponse(updated);
    }
    
    // Méthodes privées utilitaires
    
    private void verifierAcces(Task task, Long userId) {
        if (!task.getOwner().getId().equals(userId) &&
            !task.getAssignees().stream().anyMatch(u -> u.getId().equals(userId))) {
            throw new UnauthorizedException("Accès refusé à la tâche " + task.getId());
        }
    }
    
    private void verifierProprietaire(Task task, Long userId) {
        if (!task.getOwner().getId().equals(userId)) {
            throw new UnauthorizedException("Seul le propriétaire peut modifier cette tâche");
        }
    }
    
    private void validerTransitionStatut(StatutTache actuel, StatutTache nouveau) {
        // Règles métier sur les transitions autorisées
        Map<StatutTache, Set<StatutTache>> transitions = Map.of(
            StatutTache.A_FAIRE,    Set.of(StatutTache.EN_COURS, StatutTache.ANNULEE),
            StatutTache.EN_COURS,   Set.of(StatutTache.EN_REVISION, StatutTache.A_FAIRE, StatutTache.ANNULEE),
            StatutTache.EN_REVISION, Set.of(StatutTache.TERMINEE, StatutTache.EN_COURS),
            StatutTache.TERMINEE,   Set.of(),  // état final
            StatutTache.ANNULEE,    Set.of()   // état final
        );
        
        Set<StatutTache> autorisees = transitions.getOrDefault(actuel, Set.of());
        if (!autorisees.contains(nouveau)) {
            throw new BusinessException(
                "Transition de statut invalide : " + actuel + " -> " + nouveau +
                ". Transitions autorisées : " + autorisees
            );
        }
    }
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
8⃣  ÉVÉNEMENTS SPRING
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Spring supporte le pattern Observateur via ses événements :

// Créer un événement personnalisé
public class TacheCreeeEvent extends ApplicationEvent {
    private final Task task;
    private final User creator;
    
    public TacheCreeeEvent(Object source, Task task, User creator) {
        super(source);
        this.task = task;
        this.creator = creator;
    }
    
    public Task getTask() { return task; }
    public User getCreator() { return creator; }
}

// Publier l'événement depuis le service
@Service
@RequiredArgsConstructor
public class TaskServiceImpl implements TaskService {
    private final ApplicationEventPublisher eventPublisher;
    
    public TaskResponse creerTache(Long userId, CreateTaskRequest request) {
        // ... logique création ...
        Task savedTask = taskRepository.save(task);
        
        // Publier l'événement (asynchrone avec @Async sur le listener)
        eventPublisher.publishEvent(new TacheCreeeEvent(this, savedTask, user));
        
        return taskMapper.toResponse(savedTask);
    }
}

// Écouter l'événement
@Component
@Slf4j
public class TacheEventListener {
    
    private final EmailService emailService;
    private final NotificationService notificationService;
    
    public TacheEventListener(EmailService emailService, NotificationService notificationService) {
        this.emailService = emailService;
        this.notificationService = notificationService;
    }
    
    @EventListener
    @Async  // exécuté dans un thread séparé
    public void surTacheCreee(TacheCreeeEvent event) {
        log.info("Événement : tâche créée id={}", event.getTask().getId());
        
        // Email de confirmation
        emailService.envoyerConfirmation(
            event.getCreator().getEmail(),
            event.getTask().getTitre()
        );
        
        // Push notification
        notificationService.envoyerPush(
            event.getCreator().getId(),
            "Tâche créée : " + event.getTask().getTitre()
        );
    }
    
    @EventListener(condition = "#event.task.priorite == T(com.taskflow.entity.PrioriteTache).CRITIQUE")
    public void surTacheCreeCritique(TacheCreeeEvent event) {
        log.warn("[ALERTE] Tâche CRITIQUE créée : {}", event.getTask().getTitre());
        // Alerter tous les admins
    }
}

// Événements Spring Boot prédéfinis utiles :
@EventListener(ApplicationReadyEvent.class)
public void surDemarrage() {
    log.info("[RAPIDE] TaskFlow Backend démarré et prêt !");
    // Initialiser des données si nécessaire
}

@EventListener(ApplicationStartedEvent.class)
public void surDemarrageDebut() {
    log.info("Application Spring Boot démarrée");
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[EFFORT]  EXERCICES PARTIE 3
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FACILE :
Ex 3.1 : Créez un service UserService avec injection par constructeur de UserRepository.
Ex 3.2 : Configurez un @Bean PasswordEncoder dans une classe @Configuration.
Ex 3.3 : Utilisez @PostConstruct pour logger les propriétés de l'application au démarrage.

INTERMÉDIAIRE :
Ex 3.4 : Créez une interface NotificationService avec 3 implémentations
         (Email, SMS, Push) et utilisez @Primary + @Qualifier.
Ex 3.5 : Créez un événement ProjectCreeeEvent et un listener asynchrone.
Ex 3.6 : Implémentez un bean @Scope("prototype") TacheBuilder.

AVANCÉ :
Ex 3.7 : Créez une auto-configuration personnalisée pour un composant de métriques.
Ex 3.8 : Implémentez la machine d'états des statuts de tâche avec le pattern State.
Ex 3.9 : Créez un ApplicationContextAware pour accéder au contexte Spring dans
         des classes non-gérées par Spring.

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[CLE]  CORRIGÉ EX 3.4
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// Interface
public interface NotificationService {
    void envoyer(String destinataire, String sujet, String message);
    String getType();
}

// Implémentation Email (Primary)
@Service("emailNotificationService")
@Primary
@Slf4j
public class EmailNotificationService implements NotificationService {
    
    @Value("${app.email.from:noreply@taskflow.com}")
    private String fromAddress;
    
    @Override
    public void envoyer(String destinataire, String sujet, String message) {
        log.info("[EMAIL] Email -> {} | Sujet : {} | Corps : {}", destinataire, sujet, message);
        // Intégration SMTP réelle ici
    }
    
    @Override
    public String getType() { return "EMAIL"; }
}

// Implémentation SMS
@Service("smsNotificationService")
@Slf4j
public class SmsNotificationService implements NotificationService {
    
    @Override
    public void envoyer(String destinataire, String sujet, String message) {
        log.info("[MOBILE] SMS -> {} : {}", destinataire, message);
        // Intégration Twilio ici
    }
    
    @Override
    public String getType() { return "SMS"; }
}

// Implémentation Push
@Service("pushNotificationService")
@Slf4j
public class PushNotificationService implements NotificationService {
    
    @Override
    public void envoyer(String destinataire, String sujet, String message) {
        log.info("[NOTIF] Push -> device:{} : {}", destinataire, message);
        // Intégration Firebase ici
    }
    
    @Override
    public String getType() { return "PUSH"; }
}

// Utilisation dans AlertService
@Service
@RequiredArgsConstructor
@Slf4j
public class AlertService {
    
    // Injection du Primary (Email)
    private final NotificationService defaultNotificationService;
    
    // Injection de SMS spécifiquement
    @Qualifier("smsNotificationService")
    private final NotificationService smsService;
    
    // Toutes les implémentations disponibles
    private final Map<String, NotificationService> allServices;
    
    public void alerterUtilisateur(User user, String type, String message) {
        String serviceKey = type.toLowerCase() + "NotificationService";
        NotificationService service = allServices.getOrDefault(
            serviceKey, defaultNotificationService
        );
        
        service.envoyer(user.getEmail(), "Alerte TaskFlow", message);
    }
    
    public void alerterTousCanaux(User user, String message) {
        allServices.values().forEach(service ->
            service.envoyer(user.getEmail(), "Alerte critique", message)
        );
    }
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[LISTE] RÉSUMÉ PARTIE 3 — CE QUE VOUS AVEZ APPRIS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[OK] IoC : inversion de contrôle, conteneur Spring, ApplicationContext
[OK] DI : injection par constructeur (recommandé), setter, champ
[OK] Résolution des ambiguïtés : @Primary, @Qualifier, List<T>, Map<String, T>
[OK] @Value et @ConfigurationProperties pour les propriétés
[OK] Scopes : singleton (défaut), prototype, request, session
[OK] Cycle de vie : @PostConstruct, @PreDestroy
[OK] Auto-configuration et @ConditionalOn...
[OK] Événements Spring : ApplicationEventPublisher, @EventListener, @Async
[OK] Implémentation complète de TaskService avec injection et logique métier

[SOON_WITH_RIGHTWARDS_ARROW_ABOVE] PARTIE 4 : Controllers REST — mapping, validation, gestion des requêtes HTTP

═══════════════════════════════════════════════════════════════════
FIN DE LA PARTIE 3 — spring_boot_part_3.txt
═══════════════════════════════════════════════════════════════════

╔══════════════════════════════════════════════════════════════════════════════════╗
║         GUIDE COMPLET SPRING BOOT — NIVEAU ENTREPRISE                            ║
║         PARTIE 4 : CONTROLLERS REST                                              ║
║         PARTIE 5 : SERVICE LAYER                                                 ║
╚══════════════════════════════════════════════════════════════════════════════════╝

╔══════════════════════════════════════════════════════════╗
║  CHAPITRE 13 — REST CONTROLLERS                         ║
╚══════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  ANATOMIE D'UN REST CONTROLLER
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Le Controller est la porte d'entrée de votre API. Il :
• Reçoit les requêtes HTTP
• Extrait les données (path, query params, body, headers)
• Délègue au Service
• Retourne la réponse HTTP appropriée

Flux complet :
──────────────
  HTTP Request
       │
  DispatcherServlet (unique point d'entrée de Spring MVC)
       │
  HandlerMapping (trouve le bon @RequestMapping)
       │
  Filtres de sécurité (JWT validation)
       │
  @RestController method
       │ appelle
  @Service method
       │ appelle
  @Repository method
       │
  Database
       │ retour
  Service -> Controller -> HttpMessageConverter -> JSON Response

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  CONTROLLER COMPLET TASKFLOW — TASK CONTROLLER
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

package com.taskflow.backend.controller;

import com.taskflow.backend.dto.request.CreateTaskRequest;
import com.taskflow.backend.dto.request.UpdateTaskRequest;
import com.taskflow.backend.dto.response.ApiResponse;
import com.taskflow.backend.dto.response.TaskResponse;
import com.taskflow.backend.entity.StatutTache;
import com.taskflow.backend.security.SecurityUtils;
import com.taskflow.backend.service.TaskService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

// ────────────────────────────────────────────────────────────────
// @RestController : indique que toutes les méthodes retournent
//                   directement le body de la réponse (pas de vue)
// @RequestMapping : préfixe pour toutes les routes de ce controller
// @Tag : documentation Swagger
// @SecurityRequirement : indique que ce controller nécessite un JWT
// ────────────────────────────────────────────────────────────────
@RestController
@RequestMapping("/api/v1/tasks")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Tasks", description = "API de gestion des tâches")
@SecurityRequirement(name = "bearerAuth")
public class TaskController {
    
    private final TaskService taskService;
    
    // ────────────────────────────────────────────────────────
    // GET /api/v1/tasks
    // Lister toutes les tâches de l'utilisateur connecté
    // ────────────────────────────────────────────────────────
    @GetMapping
    @Operation(summary = "Lister mes tâches", description = "Retourne la liste paginée des tâches")
    public ResponseEntity<ApiResponse<Page<TaskResponse>>> listerTaches(
        // @PageableDefault : valeurs par défaut si non fournis en query params
        // ?page=0&size=20&sort=createdAt,desc
        @PageableDefault(size = 20, sort = "createdAt") Pageable pageable,
        
        // Filtres optionnels via query params
        @RequestParam(required = false) StatutTache statut,
        @RequestParam(required = false) String recherche,
        @RequestParam(required = false) String priorite
    ) {
        // SecurityUtils extrait l'ID de l'utilisateur depuis le JWT
        Long userId = SecurityUtils.getCurrentUserId();
        log.debug("Listage des tâches pour userId={}, statut={}", userId, statut);
        
        TaskFilters filters = new TaskFilters(statut, recherche, priorite);
        Page<TaskResponse> taches = taskService.listerTaches(userId, filters, pageable);
        
        return ResponseEntity.ok(ApiResponse.success(taches));
    }
    
    // ────────────────────────────────────────────────────────
    // GET /api/v1/tasks/{id}
    // Récupérer une tâche par son ID
    // ────────────────────────────────────────────────────────
    @GetMapping("/{id}")
    @Operation(summary = "Récupérer une tâche par ID")
    public ResponseEntity<ApiResponse<TaskResponse>> getTache(
        // @PathVariable : extrait la valeur depuis l'URL
        @PathVariable Long id
    ) {
        Long userId = SecurityUtils.getCurrentUserId();
        
        TaskResponse tache = taskService.getTache(id, userId);
        return ResponseEntity.ok(ApiResponse.success(tache));
    }
    
    // ────────────────────────────────────────────────────────
    // POST /api/v1/tasks
    // Créer une nouvelle tâche
    // ────────────────────────────────────────────────────────
    @PostMapping
    @Operation(summary = "Créer une tâche")
    public ResponseEntity<ApiResponse<TaskResponse>> creerTache(
        // @RequestBody : désérialise le JSON du body en objet Java
        // @Valid : déclenche la validation des contraintes
        @Valid @RequestBody CreateTaskRequest request
    ) {
        Long userId = SecurityUtils.getCurrentUserId();
        log.info("Création tâche par userId={} : titre='{}'", userId, request.getTitre());
        
        TaskResponse tache = taskService.creerTache(userId, request);
        
        // 201 Created avec l'en-tête Location
        return ResponseEntity
            .status(HttpStatus.CREATED)   // -> 201
            .header("Location", "/api/v1/tasks/" + tache.getId())
            .body(ApiResponse.success("Tâche créée avec succès", tache));
    }
    
    // ────────────────────────────────────────────────────────
    // PUT /api/v1/tasks/{id}
    // Remplacer complètement une tâche
    // ────────────────────────────────────────────────────────
    @PutMapping("/{id}")
    @Operation(summary = "Modifier une tâche")
    public ResponseEntity<ApiResponse<TaskResponse>> mettreAJour(
        @PathVariable Long id,
        @Valid @RequestBody UpdateTaskRequest request
    ) {
        Long userId = SecurityUtils.getCurrentUserId();
        
        TaskResponse tache = taskService.mettreAJour(id, userId, request);
        return ResponseEntity.ok(ApiResponse.success("Tâche mise à jour", tache));
    }
    
    // ────────────────────────────────────────────────────────
    // PATCH /api/v1/tasks/{id}/status
    // Changer uniquement le statut
    // ────────────────────────────────────────────────────────
    @PatchMapping("/{id}/status")
    @Operation(summary = "Changer le statut d'une tâche")
    public ResponseEntity<ApiResponse<TaskResponse>> changerStatut(
        @PathVariable Long id,
        @RequestBody @Valid ChangeStatusRequest request
    ) {
        Long userId = SecurityUtils.getCurrentUserId();
        
        TaskResponse tache = taskService.changerStatut(id, userId, request.getStatut());
        return ResponseEntity.ok(ApiResponse.success(tache));
    }
    
    // ────────────────────────────────────────────────────────
    // PATCH /api/v1/tasks/{id}/assign
    // Assigner la tâche à un utilisateur
    // ────────────────────────────────────────────────────────
    @PatchMapping("/{id}/assign")
    public ResponseEntity<ApiResponse<TaskResponse>> assigner(
        @PathVariable Long id,
        @RequestBody @Valid AssignTaskRequest request
    ) {
        Long userId = SecurityUtils.getCurrentUserId();
        TaskResponse tache = taskService.assigner(id, userId, request.getAssigneeId());
        return ResponseEntity.ok(ApiResponse.success(tache));
    }
    
    // ────────────────────────────────────────────────────────
    // DELETE /api/v1/tasks/{id}
    // Supprimer une tâche
    // ────────────────────────────────────────────────────────
    @DeleteMapping("/{id}")
    @Operation(summary = "Supprimer une tâche")
    @ResponseStatus(HttpStatus.NO_CONTENT)  // toujours 204
    public void supprimerTache(@PathVariable Long id) {
        Long userId = SecurityUtils.getCurrentUserId();
        log.info("Suppression tâche id={} par userId={}", id, userId);
        taskService.supprimerTache(id, userId);
        // Pas de corps de réponse -> 204 No Content
    }
    
    // ────────────────────────────────────────────────────────
    // ADMIN ONLY
    // GET /api/v1/tasks/all -> Toutes les tâches (Admin)
    // ────────────────────────────────────────────────────────
    @GetMapping("/all")
    @PreAuthorize("hasRole('ADMIN')")  // sécurité niveau méthode
    @Operation(summary = "Toutes les tâches (Admin uniquement)")
    public ResponseEntity<ApiResponse<Page<TaskResponse>>> toutesLesTaches(
        @PageableDefault(size = 50) Pageable pageable
    ) {
        Page<TaskResponse> taches = taskService.listerToutesLesTaches(pageable);
        return ResponseEntity.ok(ApiResponse.success(taches));
    }
    
    // ────────────────────────────────────────────────────────
    // GET /api/v1/tasks/stats
    // Statistiques des tâches de l'utilisateur
    // ────────────────────────────────────────────────────────
    @GetMapping("/stats")
    public ResponseEntity<ApiResponse<TaskStats>> obtenirStatistiques() {
        Long userId = SecurityUtils.getCurrentUserId();
        TaskStats stats = taskService.obtenirStatistiques(userId);
        return ResponseEntity.ok(ApiResponse.success(stats));
    }
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3⃣  AUTH CONTROLLER COMPLET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Authentication", description = "API d'authentification")
public class AuthController {
    
    private final AuthService authService;
    
    // POST /api/v1/auth/register
    @PostMapping("/register")
    @Operation(summary = "Inscription d'un nouvel utilisateur")
    public ResponseEntity<ApiResponse<AuthResponse>> inscrire(
        @Valid @RequestBody RegisterRequest request
    ) {
        log.info("Demande d'inscription pour email : {}", request.getEmail());
        AuthResponse response = authService.inscrire(request);
        
        return ResponseEntity
            .status(HttpStatus.CREATED)
            .body(ApiResponse.success("Inscription réussie", response));
    }
    
    // POST /api/v1/auth/login
    @PostMapping("/login")
    @Operation(summary = "Connexion")
    public ResponseEntity<ApiResponse<AuthResponse>> connecter(
        @Valid @RequestBody LoginRequest request
    ) {
        log.info("Tentative de connexion pour : {}", request.getEmail());
        AuthResponse response = authService.connecter(request);
        
        return ResponseEntity.ok(ApiResponse.success("Connexion réussie", response));
    }
    
    // POST /api/v1/auth/refresh
    @PostMapping("/refresh")
    public ResponseEntity<ApiResponse<AuthResponse>> rafraichirToken(
        @RequestBody @Valid RefreshTokenRequest request
    ) {
        AuthResponse response = authService.rafraichirToken(request.getRefreshToken());
        return ResponseEntity.ok(ApiResponse.success(response));
    }
    
    // POST /api/v1/auth/logout
    @PostMapping("/logout")
    public ResponseEntity<ApiResponse<Void>> deconnecter(
        @RequestHeader("Authorization") String authHeader
    ) {
        String token = authHeader.substring(7);  // supprimer "Bearer "
        authService.deconnecter(token);
        return ResponseEntity.ok(ApiResponse.success("Déconnexion réussie", null));
    }
    
    // POST /api/v1/auth/forgot-password
    @PostMapping("/forgot-password")
    public ResponseEntity<ApiResponse<Void>> demanderReinitialisationMdp(
        @RequestBody @Valid ForgotPasswordRequest request
    ) {
        authService.demanderReinitialisationMdp(request.getEmail());
        // Toujours retourner 200 même si l'email n'existe pas (sécurité)
        return ResponseEntity.ok(
            ApiResponse.success("Si cet email existe, un lien vous a été envoyé", null)
        );
    }
    
    // POST /api/v1/auth/reset-password
    @PostMapping("/reset-password")
    public ResponseEntity<ApiResponse<Void>> reinitialiserMdp(
        @RequestBody @Valid ResetPasswordRequest request
    ) {
        authService.reinitialiserMdp(request.getToken(), request.getNouveauMotDePasse());
        return ResponseEntity.ok(ApiResponse.success("Mot de passe réinitialisé", null));
    }
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4⃣  EXTRACTION DES DONNÉES DE REQUÊTE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// Toutes les annotations d'extraction dans Spring MVC

@RestController
@RequestMapping("/api/v1/demo")
public class DemoController {
    
    // PATH VARIABLE : /demo/42/comments/7
    @GetMapping("/{taskId}/comments/{commentId}")
    public String demo1(
        @PathVariable Long taskId,        // extrait de l'URL
        @PathVariable("commentId") Long cId  // nom personnalisé
    ) {
        return "Task " + taskId + " Comment " + cId;
    }
    
    // QUERY PARAMS : /demo?page=0&size=20&sort=name&active=true
    @GetMapping("/search")
    public String demo2(
        @RequestParam int page,                        // obligatoire
        @RequestParam(defaultValue = "20") int size,   // avec valeur par défaut
        @RequestParam(required = false) String sort,   // optionnel
        @RequestParam(required = false) Boolean active
    ) {
        return "page=" + page + " size=" + size;
    }
    
    // REQUEST BODY : Corps de la requête HTTP
    @PostMapping
    public String demo3(@RequestBody Map<String, Object> body) {
        return "Reçu : " + body;
    }
    
    // HEADERS : en-têtes HTTP
    @GetMapping("/headers")
    public String demo4(
        @RequestHeader("Authorization") String authHeader,
        @RequestHeader(value = "X-Request-Id", required = false) String requestId,
        @RequestHeader HttpHeaders headers  // tous les headers
    ) {
        return "Token : " + authHeader;
    }
    
    // COOKIE
    @GetMapping("/cookie")
    public String demo5(
        @CookieValue(value = "session", required = false) String sessionId
    ) {
        return "Session : " + sessionId;
    }
    
    // HttpServletRequest complet
    @GetMapping("/request")
    public String demo6(HttpServletRequest request) {
        String ip = request.getRemoteAddr();
        String userAgent = request.getHeader("User-Agent");
        String method = request.getMethod();
        String url = request.getRequestURI();
        return "IP:" + ip + " Method:" + method + " URL:" + url;
    }
    
    // Principal (utilisateur authentifié)
    @GetMapping("/me")
    public String demo7(
        @AuthenticationPrincipal UserDetails userDetails,
        Principal principal  // alternative plus simple
    ) {
        return "Utilisateur : " + userDetails.getUsername();
    }
    
    // MatrixVariable : /demo;color=red;size=L
    @GetMapping("/matrix")
    public String demo8(
        @MatrixVariable String color,
        @MatrixVariable(required = false, defaultValue = "M") String size
    ) {
        return "Color:" + color + " Size:" + size;
    }
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  RESPONSEENTITY EN PROFONDEUR
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ResponseEntity<T> permet de contrôler précisément la réponse HTTP :

@GetMapping("/exemple")
public ResponseEntity<TaskResponse> exemple() {
    
    // Forme 1 : simple
    return ResponseEntity.ok(taskResponse);  // 200 OK
    
    // Forme 2 : avec status
    return ResponseEntity
        .status(HttpStatus.CREATED)
        .body(taskResponse);
    
    // Forme 3 : avec headers
    return ResponseEntity
        .status(HttpStatus.CREATED)
        .header("Location", "/api/v1/tasks/42")
        .header("X-Task-Id", "42")
        .contentType(MediaType.APPLICATION_JSON)
        .body(taskResponse);
    
    // Forme 4 : no content
    return ResponseEntity.noContent().build();  // 204
    
    // Forme 5 : not found
    return ResponseEntity.notFound().build();   // 404
    
    // Forme 6 : bad request
    return ResponseEntity.badRequest().body(null);
    
    // Forme 7 : avec ETag pour cache
    String etag = "\"" + taskResponse.getUpdatedAt().hashCode() + "\"";
    return ResponseEntity.ok()
        .eTag(etag)
        .cacheControl(CacheControl.maxAge(30, TimeUnit.MINUTES))
        .body(taskResponse);
}

// ResponseEntity<Void> -> quand pas de corps
@DeleteMapping("/{id}")
public ResponseEntity<Void> supprimer(@PathVariable Long id) {
    taskService.supprimer(id);
    return ResponseEntity.noContent().build();
}

═══════════════════════════════════════════════════════════════════

╔══════════════════════════════════════════════════════════╗
║  CHAPITRE 14 — MAPPING DES ROUTES                       ║
╚══════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  ANNOTATIONS DE MAPPING
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// Annotations raccourcis (équivalents de @RequestMapping(method=...))

@GetMapping("/tasks")       // = @RequestMapping(value="/tasks", method=GET)
@PostMapping("/tasks")      // = @RequestMapping(value="/tasks", method=POST)
@PutMapping("/tasks/{id}")  // = @RequestMapping(value="/tasks/{id}", method=PUT)
@PatchMapping("/tasks/{id}/status")
@DeleteMapping("/tasks/{id}")

// @RequestMapping complet (rarement utilisé)
@RequestMapping(
    value = "/tasks",
    method = RequestMethod.GET,
    produces = MediaType.APPLICATION_JSON_VALUE,  // Content-Type de la réponse
    consumes = MediaType.APPLICATION_JSON_VALUE,  // Content-Type attendu en entrée
    headers = "X-Api-Version=1"                   // filtrage par header
)
public List<Task> lister() { ... }

// Routes multiples sur une méthode
@GetMapping({"/tasks", "/todos", "/items"})  // même méthode pour plusieurs routes
public List<Task> listerAll() { ... }

// Variables de chemin avec regex
@GetMapping("/tasks/{id:[0-9]+}")  // seulement si id est numérique
public Task getTask(@PathVariable Long id) { ... }

@GetMapping("/files/{filename:.+}")  // captures le . dans le nom de fichier
public Resource getFile(@PathVariable String filename) { ... }

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  NEGOCIATION DE CONTENU
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// Retourner JSON ou XML selon le header Accept

@GetMapping(
    value = "/tasks/{id}",
    produces = {
        MediaType.APPLICATION_JSON_VALUE,      // Accept: application/json
        MediaType.APPLICATION_XML_VALUE        // Accept: application/xml
    }
)
public TaskResponse getTask(@PathVariable Long id) {
    return taskService.getTache(id);
    // Jackson choisit le bon convertisseur selon le header Accept
}

// Retourner différents types
@GetMapping("/report")
public ResponseEntity<?> rapport(
    @RequestHeader(value = "Accept", defaultValue = "application/json") String accept
) {
    if (accept.contains("text/csv")) {
        return ResponseEntity.ok()
            .contentType(MediaType.parseMediaType("text/csv"))
            .header("Content-Disposition", "attachment; filename=tasks.csv")
            .body(taskService.exportCsv());
    }
    return ResponseEntity.ok(taskService.getReport());
}

═══════════════════════════════════════════════════════════════════

╔══════════════════════════════════════════════════════════╗
║  CHAPITRE 15 — VALIDATION DES DONNÉES                   ║
╚══════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  DTO DE REQUÊTE AVEC VALIDATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Les DTOs (Data Transfer Objects) définissent la structure des données entrantes.
Les annotations de validation définissent les contraintes.

// CreateTaskRequest.java
package com.taskflow.backend.dto.request;

import jakarta.validation.constraints.*;
import lombok.Data;
import java.time.LocalDate;

@Data
public class CreateTaskRequest {
    
    // Titre obligatoire, 3 à 200 caractères
    @NotBlank(message = "Le titre est obligatoire")
    @Size(min = 3, max = 200, message = "Le titre doit contenir entre 3 et 200 caractères")
    private String titre;
    
    // Description optionnelle, max 5000 caractères
    @Size(max = 5000, message = "La description ne peut pas dépasser 5000 caractères")
    private String description;
    
    // Priorité : valeur entre 1 et 5
    @Min(value = 1, message = "La priorité minimale est 1")
    @Max(value = 5, message = "La priorité maximale est 5")
    private int priorite = 3;  // valeur par défaut
    
    // Date d'échéance : doit être dans le futur
    @Future(message = "La date d'échéance doit être dans le futur")
    private LocalDate dateEcheance;
    
    // ID du projet (optionnel)
    @Positive(message = "L'ID du projet doit être positif")
    private Long projetId;
    
    // Liste d'assignés (max 10)
    @Size(max = 10, message = "Maximum 10 assignés par tâche")
    private List<Long> assigneeIds = new ArrayList<>();
    
    // Tags
    @Size(max = 5, message = "Maximum 5 tags par tâche")
    private List<@NotBlank @Size(max = 30) String> tags = new ArrayList<>();
}

// RegisterRequest.java
@Data
public class RegisterRequest {
    
    @NotBlank(message = "Le prénom est obligatoire")
    @Size(min = 2, max = 50)
    private String prenom;
    
    @NotBlank(message = "Le nom est obligatoire")
    @Size(min = 2, max = 50)
    private String nom;
    
    @NotBlank(message = "L'email est obligatoire")
    @Email(message = "Format d'email invalide")
    @Size(max = 255)
    private String email;
    
    @NotBlank(message = "Le mot de passe est obligatoire")
    @Size(min = 8, max = 128, message = "Le mot de passe doit contenir entre 8 et 128 caractères")
    @Pattern(
        regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$",
        message = "Le mot de passe doit contenir au moins une majuscule, une minuscule, un chiffre et un caractère spécial"
    )
    private String motDePasse;
    
    @NotBlank(message = "La confirmation du mot de passe est obligatoire")
    private String confirmationMotDePasse;
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  TOUTES LES ANNOTATIONS DE VALIDATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// jakarta.validation.constraints.*

// NULL
@NotNull     -> pas null (accepte "" et " ")
@Null        -> doit être null

// CHAÎNES
@NotBlank    -> pas null, pas vide, pas seulement des espaces
@NotEmpty    -> pas null, pas vide (accepte " ")
@Size(min=, max=) -> longueur min/max
@Pattern(regexp=) -> doit correspondre à l'expression régulière
@Email       -> format email valide
@URL         -> URL valide (via Hibernate Validator)

// NOMBRES
@Min(value=) -> valeur minimale
@Max(value=) -> valeur maximale
@DecimalMin(value=) -> valeur décimale minimale
@DecimalMax(value=) -> valeur décimale maximale
@Range(min=, max=) -> plage (Hibernate)
@Positive    -> > 0
@PositiveOrZero -> >= 0
@Negative    -> < 0
@NegativeOrZero -> <= 0
@Digits(integer=, fraction=) -> nombre de chiffres

// DATES
@Past        -> doit être dans le passé
@PastOrPresent -> passé ou présent
@Future      -> doit être dans le futur
@FutureOrPresent -> futur ou présent

// BOOLÉENS
@AssertTrue  -> doit être true
@AssertFalse -> doit être false

// COLLECTIONS
@Size(min=, max=) -> taille min/max
@NotEmpty    -> pas vide

// VALIDATION EN CASCADE
@Valid       -> valider les objets imbriqués

// Exemple avec imbrication :
public class CreateProjectRequest {
    @NotBlank
    private String nom;
    
    @Valid  // -> valide chaque membre de la liste
    @NotEmpty(message = "Au moins un membre requis")
    @Size(max = 50, message = "Maximum 50 membres")
    private List<@Valid MemberRequest> membres;
}

@Data
public class MemberRequest {
    @NotNull
    @Positive
    private Long userId;
    
    @NotNull
    private RoleProjet role;
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3⃣  VALIDATION PERSONNALISÉE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// Créer une annotation de validation personnalisée

// 1. Définir l'annotation
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = MotDePasseForteValidator.class)
@Documented
public @interface MotDePasseForte {
    String message() default "Le mot de passe est trop faible";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    
    int minLongueur() default 8;
    boolean requiertChiffre() default true;
    boolean requiertMajuscule() default true;
    boolean requiertSpecial() default true;
}

// 2. Implémenter le Validator
public class MotDePasseForteValidator implements ConstraintValidator<MotDePasseForte, String> {
    
    private int minLongueur;
    private boolean requiertChiffre;
    private boolean requiertMajuscule;
    private boolean requiertSpecial;
    
    @Override
    public void initialize(MotDePasseForte annotation) {
        this.minLongueur = annotation.minLongueur();
        this.requiertChiffre = annotation.requiertChiffre();
        this.requiertMajuscule = annotation.requiertMajuscule();
        this.requiertSpecial = annotation.requiertSpecial();
    }
    
    @Override
    public boolean isValid(String motDePasse, ConstraintValidatorContext context) {
        if (motDePasse == null) return false;
        
        if (motDePasse.length() < minLongueur) {
            creerMessage(context, "Minimum " + minLongueur + " caractères requis");
            return false;
        }
        
        if (requiertChiffre && !motDePasse.matches(".*\\d.*")) {
            creerMessage(context, "Au moins un chiffre requis");
            return false;
        }
        
        if (requiertMajuscule && !motDePasse.matches(".*[A-Z].*")) {
            creerMessage(context, "Au moins une majuscule requise");
            return false;
        }
        
        if (requiertSpecial && !motDePasse.matches(".*[@$!%*?&].*")) {
            creerMessage(context, "Au moins un caractère spécial (@$!%*?&) requis");
            return false;
        }
        
        return true;
    }
    
    private void creerMessage(ConstraintValidatorContext ctx, String message) {
        ctx.disableDefaultConstraintViolation();
        ctx.buildConstraintViolationWithTemplate(message).addConstraintViolation();
    }
}

// 3. Utiliser l'annotation
@Data
public class RegisterRequest {
    @MotDePasseForte(minLongueur = 10, requiertSpecial = true)
    private String motDePasse;
}

// Validation croisée (comparaison de deux champs)
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = MotsDePasseIdentiquesValidator.class)
public @interface MotsDePasseIdentiques {
    String message() default "Les mots de passe ne correspondent pas";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

public class MotsDePasseIdentiquesValidator 
        implements ConstraintValidator<MotsDePasseIdentiques, RegisterRequest> {
    
    @Override
    public boolean isValid(RegisterRequest request, ConstraintValidatorContext context) {
        if (request.getMotDePasse() == null || request.getConfirmationMotDePasse() == null) {
            return false;
        }
        return request.getMotDePasse().equals(request.getConfirmationMotDePasse());
    }
}

@Data
@MotsDePasseIdentiques  // validation au niveau de la classe
public class RegisterRequest {
    private String motDePasse;
    private String confirmationMotDePasse;
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
4⃣  VALIDATION MANUELLE (PROGRAMMATIQUE)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// Valider manuellement dans le service
@Service
@RequiredArgsConstructor
public class TaskService {
    
    private final Validator validator;  // jakarta.validation.Validator
    
    public TaskResponse creerTache(CreateTaskRequest request) {
        // Validation programmatique
        Set<ConstraintViolation<CreateTaskRequest>> violations = validator.validate(request);
        
        if (!violations.isEmpty()) {
            List<String> messages = violations.stream()
                .map(v -> v.getPropertyPath() + " : " + v.getMessage())
                .collect(Collectors.toList());
            throw new ValidationException("Données invalides : " + messages);
        }
        
        // ... suite de la logique
    }
}

// Validation avec groupes
@GroupSequence({Default.class, GroupPriorite.class, GroupMetier.class})
public interface ValidationSequence {}

public interface GroupPriorite {}
public interface GroupMetier {}

@Data
public class CreateTaskRequest {
    @NotBlank  // validé en premier (Default)
    private String titre;
    
    @Min(value = 1, groups = GroupPriorite.class)  // validé en 2ème
    private int priorite;
    
    @FutureDateMetier(groups = GroupMetier.class)  // validé en 3ème (custom)
    private LocalDate dateEcheance;
}

// Controller avec séquence de validation
@PostMapping
public ResponseEntity<?> creer(
    @Validated(ValidationSequence.class) @RequestBody CreateTaskRequest request
) { ... }

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
5⃣  GESTION DES ERREURS DE VALIDATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

// GlobalExceptionHandler.java — Gestionnaire d'exceptions global

@RestControllerAdvice  // intercepte les exceptions de tous les controllers
@Slf4j
public class GlobalExceptionHandler {
    
    // Erreurs de validation @Valid
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiResponse<Void> handleValidationErrors(MethodArgumentNotValidException ex) {
        log.warn("Erreur de validation : {}", ex.getMessage());
        
        List<FieldErrorDetail> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> new FieldErrorDetail(
                error.getField(),
                error.getDefaultMessage(),
                String.valueOf(error.getRejectedValue())
            ))
            .collect(Collectors.toList());
        
        return ApiResponse.validationError("Données invalides", errors);
    }
    
    // Erreurs de path variable (ex: id non-numérique)
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiResponse<Void> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
        String message = String.format(
            "Le paramètre '%s' avec la valeur '%s' n'est pas valide. Type attendu : %s",
            ex.getName(), ex.getValue(), ex.getRequiredType().getSimpleName()
        );
        return ApiResponse.error(message);
    }
    
    // Ressource non trouvée
    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ApiResponse<Void> handleNotFound(ResourceNotFoundException ex) {
        log.info("Ressource non trouvée : {}", ex.getMessage());
        return ApiResponse.error(ex.getMessage());
    }
    
    // Non autorisé
    @ExceptionHandler(UnauthorizedException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public ApiResponse<Void> handleUnauthorized(UnauthorizedException ex) {
        log.warn("Accès refusé : {}", ex.getMessage());
        return ApiResponse.error(ex.getMessage());
    }
    
    // Conflit (email déjà existant)
    @ExceptionHandler(ConflictException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public ApiResponse<Void> handleConflict(ConflictException ex) {
        return ApiResponse.error(ex.getMessage());
    }
    
    // Erreur métier
    @ExceptionHandler(BusinessException.class)
    @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
    public ApiResponse<Void> handleBusinessError(BusinessException ex) {
        log.warn("Erreur métier : {}", ex.getMessage());
        return ApiResponse.error(ex.getMessage());
    }
    
    // Erreur générique (500)
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ApiResponse<Void> handleGenericError(Exception ex, HttpServletRequest request) {
        log.error("Erreur inattendue sur {} {} : {}",
            request.getMethod(), request.getRequestURI(), ex.getMessage(), ex);
        
        // Ne pas exposer les détails en production
        String message = "Une erreur interne s'est produite. Contactez le support si le problème persiste.";
        return ApiResponse.error(message);
    }
}

═══════════════════════════════════════════════════════════════════

╔══════════════════════════════════════════════════════════╗
║  PARTIE 5 — SERVICE LAYER : LOGIQUE MÉTIER              ║
╚══════════════════════════════════════════════════════════╝

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1⃣  QU'EST-CE QUE LA COUCHE SERVICE ?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

La couche Service est le cœur de votre application.
Elle contient TOUTE la logique métier.

Règles absolues :
─────────────────
[OK] Le service ne connaît pas HTTP (pas de HttpServletRequest, HttpStatus)
[OK] Le service communique uniquement avec les Repositories
[OK] Toute la logique métier est dans le service (pas dans le controller !)
[OK] Le service définit les transactions (@Transactional)
[OK] Le service lance des exceptions métier (pas des exceptions HTTP)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2⃣  AUTH SERVICE COMPLET
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class AuthServiceImpl implements AuthService {
    
    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtService jwtService;
    private final EmailService emailService;
    private final TokenBlacklistService tokenBlacklistService;
    
    @Override
    public AuthResponse inscrire(RegisterRequest request) {
        log.info("Inscription d'un nouvel utilisateur : {}", request.getEmail());
        
        // 1. Vérifier que l'email n'est pas déjà utilisé
        if (userRepository.existsByEmail(request.getEmail().toLowerCase())) {
            throw new ConflictException("Un compte avec cet email existe déjà");
        }
        
        // 2. Créer l'utilisateur
        User user = User.builder()
            .prenom(request.getPrenom().trim())
            .nom(request.getNom().trim())
            .email(request.getEmail().toLowerCase().trim())
            .motDePasse(passwordEncoder.encode(request.getMotDePasse()))  // hachage BCrypt
            .role(RoleUtilisateur.USER)
            .actif(true)
            .emailVerifie(false)
            .build();
        
        User savedUser = userRepository.save(user);
        log.info("Utilisateur créé avec succès : id={}", savedUser.getId());
        
        // 3. Envoyer email de vérification (asynchrone)
        String verificationToken = jwtService.genererTokenVerification(savedUser.getEmail());
        emailService.envoyerVerificationEmail(savedUser.getEmail(), verificationToken);
        
        // 4. Générer les tokens JWT
        String accessToken = jwtService.genererToken(savedUser);
        String refreshToken = jwtService.genererRefreshToken(savedUser);
        
        return AuthResponse.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .userId(savedUser.getId())
            .email(savedUser.getEmail())
            .prenom(savedUser.getPrenom())
            .role(savedUser.getRole().name())
            .build();
    }
    
    @Override
    public AuthResponse connecter(LoginRequest request) {
        log.info("Tentative de connexion : {}", request.getEmail());
        
        // 1. Trouver l'utilisateur
        User user = userRepository.findByEmail(request.getEmail().toLowerCase())
            .orElseThrow(() -> new UnauthorizedException("Email ou mot de passe incorrect"));
        
        // 2. Vérifier le compte actif
        if (!user.isActif()) {
            throw new UnauthorizedException("Compte désactivé. Contactez le support.");
        }
        
        // 3. Vérifier le mot de passe (BCrypt)
        if (!passwordEncoder.matches(request.getMotDePasse(), user.getMotDePasse())) {
            // Incrémenter les tentatives échouées
            user.incrementerTentativesEchouees();
            
            if (user.getTentativesEchouees() >= 5) {
                user.setActif(false);
                userRepository.save(user);
                throw new UnauthorizedException(
                    "Compte verrouillé après 5 tentatives. Réinitialisez votre mot de passe."
                );
            }
            
            userRepository.save(user);
            throw new UnauthorizedException("Email ou mot de passe incorrect");
        }
        
        // 4. Réinitialiser les tentatives échouées
        user.reinitialiserTentatives();
        user.setDerniereConnexion(LocalDateTime.now());
        userRepository.save(user);
        
        // 5. Générer les tokens
        String accessToken = jwtService.genererToken(user);
        String refreshToken = jwtService.genererRefreshToken(user);
        
        log.info("Connexion réussie : userId={}", user.getId());
        
        return AuthResponse.builder()
            .accessToken(accessToken)
            .refreshToken(refreshToken)
            .userId(user.getId())
            .email(user.getEmail())
            .prenom(user.getPrenom())
            .role(user.getRole().name())
            .build();
    }
    
    @Override
    public AuthResponse rafraichirToken(String refreshToken) {
        // Valider le refresh token
        if (!jwtService.validerToken(refreshToken)) {
            throw new UnauthorizedException("Refresh token invalide ou expiré");
        }
        
        String email = jwtService.extraireEmail(refreshToken);
        User user = userRepository.findByEmail(email)
            .orElseThrow(() -> new UnauthorizedException("Utilisateur introuvable"));
        
        if (!user.isActif()) {
            throw new UnauthorizedException("Compte désactivé");
        }
        
        // Générer un nouveau access token
        String newAccessToken = jwtService.genererToken(user);
        
        return AuthResponse.builder()
            .accessToken(newAccessToken)
            .refreshToken(refreshToken)  // réutiliser le refresh token
            .userId(user.getId())
            .email(user.getEmail())
            .prenom(user.getPrenom())
            .role(user.getRole().name())
            .build();
    }
    
    @Override
    public void deconnecter(String token) {
        // Mettre le token en blacklist
        Date expiration = jwtService.extraireExpiration(token);
        tokenBlacklistService.blacklister(token, expiration);
        log.info("Token blacklisté avec succès");
    }
    
    @Override
    public void demanderReinitialisationMdp(String email) {
        // Ne pas révéler si l'email existe ou non (sécurité)
        userRepository.findByEmail(email.toLowerCase()).ifPresent(user -> {
            String token = jwtService.genererTokenReinitialisation(email);
            user.setTokenReinitialisation(token);
            user.setExpirationTokenReinitialisation(LocalDateTime.now().plusHours(2));
            userRepository.save(user);
            
            emailService.envoyerReinitialisationMdp(email, token);
            log.info("Email de réinitialisation envoyé à : {}", email);
        });
    }
    
    @Override
    public void reinitialiserMdp(String token, String nouveauMotDePasse) {
        User user = userRepository.findByTokenReinitialisation(token)
            .orElseThrow(() -> new UnauthorizedException("Token invalide ou expiré"));
        
        if (user.getExpirationTokenReinitialisation().isBefore(LocalDateTime.now())) {
            throw new UnauthorizedException("Le lien de réinitialisation a expiré");
        }
        
        user.setMotDePasse(passwordEncoder.encode(nouveauMotDePasse));
        user.setTokenReinitialisation(null);
        user.setExpirationTokenReinitialisation(null);
        user.reinitialiserTentatives();
        userRepository.save(user);
        
        log.info("Mot de passe réinitialisé pour userId={}", user.getId());
        emailService.envoyerConfirmationChangementMdp(user.getEmail());
    }
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
3⃣  @TRANSACTIONAL EN DÉTAIL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

@Transactional garantit l'intégrité des données :
  - Si tout réussit -> COMMIT (changements sauvegardés)
  - Si une exception -> ROLLBACK (changements annulés)

// Niveaux d'isolation
@Transactional(isolation = Isolation.READ_COMMITTED)  // défaut PostgreSQL
@Transactional(isolation = Isolation.SERIALIZABLE)    // le plus strict

// Propagation : que faire quand appelé depuis une autre transaction ?
@Transactional(propagation = Propagation.REQUIRED)         // défaut : rejoindre ou créer
@Transactional(propagation = Propagation.REQUIRES_NEW)     // toujours nouvelle transaction
@Transactional(propagation = Propagation.SUPPORTS)         // optionnelle
@Transactional(propagation = Propagation.NOT_SUPPORTED)    // suspendre si existante
@Transactional(propagation = Propagation.MANDATORY)        // doit être dans une transaction
@Transactional(propagation = Propagation.NEVER)            // ne doit PAS être dans une transaction

// Timeout
@Transactional(timeout = 30)  // 30 secondes maximum

// Lecture seule (optimisation)
@Transactional(readOnly = true)  // Hibernate ne vérifie pas les changements

// Rollback sur exception spécifique
@Transactional(rollbackFor = {BusinessException.class, DataIntegrityException.class})
@Transactional(noRollbackFor = {ValidationWarning.class})  // pas de rollback pour celle-ci

// Exemple concret
@Service
public class TransfertService {
    
    @Transactional  // englobante
    public void transfererTaches(Long sourceUserId, Long targetUserId, List<Long> taskIds) {
        User source = userRepository.findById(sourceUserId).orElseThrow();
        User target = userRepository.findById(targetUserId).orElseThrow();
        
        // Si l'une de ces opérations échoue -> tout est annulé
        List<Task> taches = taskRepository.findAllById(taskIds);
        taches.forEach(t -> t.setOwner(target));
        taskRepository.saveAll(taches);
        
        // Log interne avec sa propre transaction (REQUIRES_NEW)
        logTransfert(sourceUserId, targetUserId, taskIds.size());
    }
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logTransfert(Long from, Long to, int count) {
        // Cette méthode a sa propre transaction
        // -> commite indépendamment, même si la transaction parente roll back
        auditRepository.save(new AuditLog("TRANSFER", from, to, count));
    }
}

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[EFFORT]  EXERCICES PARTIES 4 & 5
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

FACILE :
Ex 4.1 : Créez un UserController avec GET /users/{id}, GET /users/me, PUT /users/{id}.
Ex 4.2 : Créez CreateUserRequest avec validation (prenom, nom, email, password).
Ex 4.3 : Implémentez GlobalExceptionHandler pour ResourceNotFoundException.

INTERMÉDIAIRE :
Ex 4.4 : Créez une validation personnalisée @EmailUnique qui vérifie en base.
Ex 4.5 : Implémentez AuthService.connecter avec gestion des tentatives et verrouillage.
Ex 4.6 : Créez un endpoint GET /tasks avec filtres (statut, priorité, recherche).

AVANCÉ :
Ex 4.7 : Implémentez la machine d'états des statuts de tâches avec validation des transitions.
Ex 4.8 : Créez un endpoint d'export CSV des tâches (Content-Type: text/csv).
Ex 4.9 : Implémentez un rate limiter sur /auth/login (max 5 tentatives/minute/IP).

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[LISTE] RÉSUMÉ PARTIES 4 & 5
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[OK] Controller REST complet avec tous les verbes HTTP
[OK] Extraction des données : @PathVariable, @RequestParam, @RequestBody, @RequestHeader
[OK] ResponseEntity pour contrôler précisément la réponse
[OK] Validation avec annotations Jakarta Validation
[OK] Validation personnalisée avec @Constraint
[OK] GlobalExceptionHandler avec @RestControllerAdvice
[OK] Service Layer avec logique métier complète (Auth, Task)
[OK] @Transactional en détail : propagation, isolation, timeout

[SOON_WITH_RIGHTWARDS_ARROW_ABOVE] PARTIE 6 : Persistence JPA/Hibernate — Entités, Relations, Requêtes

═══════════════════════════════════════════════════════════════════
FIN DE LA PARTIE 4 — spring_boot_part_4.txt
═══════════════════════════════════════════════════════════════════

================================================================================
   GUIDE SPRING BOOT MASTER — PARTIE 5
   PERSISTENCE : JPA, HIBERNATE, ENTITÉS & RELATIONS
   Chapitres 18 à 21
   Projet fil rouge : TaskFlow Backend
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 18 — JPA : JAVA PERSISTENCE API
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

════════════════════════════════════════
18.1 INTRODUCTION PÉDAGOGIQUE
════════════════════════════════════════

Qu'est-ce que JPA ?
-------------------
JPA (Java Persistence API) est une SPÉCIFICATION Java qui définit un ensemble
d'interfaces et d'annotations permettant de faire persister des objets Java
dans une base de données relationnelle.

JPA est une ABSTRACTION, pas une implémentation. Concrètement :
- JPA = le CONTRAT (les interfaces)
- Hibernate = l'IMPLÉMENTATION (les classes concrètes)

Analogie :
  JPA est à Java ce que JDBC est au bas niveau, mais en bien plus haut niveau.

  Sans JPA :
    String sql = "INSERT INTO tasks (title, description, status, user_id) VALUES (?, ?, ?, ?)";
    PreparedStatement ps = connection.prepareStatement(sql);
    ps.setString(1, task.getTitle());
    ps.setString(2, task.getDescription());
    ps.setString(3, task.getStatus().name());
    ps.setLong(4, task.getUserId());
    ps.executeUpdate();

  Avec JPA :
    taskRepository.save(task);  // C'est tout !

Pourquoi JPA existe ?
---------------------
Avant JPA, les développeurs utilisaient JDBC directement, ce qui posait plusieurs
problèmes :
1. Code répétitif (boilerplate) — même logique SQL pour chaque entité
2. Impedance mismatch — le monde objet et le monde relationnel sont différents
3. Pas de cache — chaque requête repart vers la base
4. Pas de gestion automatique des relations
5. Migration difficile entre SGBD

JPA résout ces problèmes grâce au concept d'ORM (Object-Relational Mapping).

════════════════════════════════════════
18.2 LE CONCEPT D'ORM EN PROFONDEUR
════════════════════════════════════════

ORM = Object-Relational Mapping

L'ORM fait la correspondance entre :

  MONDE OBJET             MONDE RELATIONNEL
  ─────────────────       ─────────────────────────
  Classe Java         <->   Table SQL
  Attribut            <->   Colonne
  Instance d'objet    <->   Ligne (Row)
  Type primitif       <->   Type SQL (VARCHAR, INTEGER...)
  Association         <->   Clé étrangère / Table de jointure
  Collection          <->   Relation 1-N ou N-N

Exemple concret :

  // Classe Java
  public class Task {
      private Long id;
      private String title;
      private TaskStatus status;
      private User assignee;
  }

  -- Table SQL correspondante
  CREATE TABLE tasks (
      id          BIGSERIAL PRIMARY KEY,
      title       VARCHAR(255) NOT NULL,
      status      VARCHAR(50) NOT NULL,
      assignee_id BIGINT REFERENCES users(id)
  );

════════════════════════════════════════
18.3 L'ENTITY MANAGER : CŒUR DE JPA
════════════════════════════════════════

L'EntityManager est l'objet central de JPA. Il gère :
- Le cycle de vie des entités
- Les transactions
- Les requêtes JPQL
- Le premier niveau de cache

États d'une entité :

  ┌─────────────┐    persist()    ┌──────────────┐
  │   TRANSIENT  │ ──────────────[BLACK_RIGHT-POINTING_POINTER] │   MANAGED    │
  │ (new object) │                 │ (tracked by  │
  └─────────────┘                 │ EntityManager)│
                                  └──────┬───────┘
                                         │ detach()
                                         [BLACK_DOWN-POINTING_TRIANGLE]
  ┌─────────────┐    merge()      ┌──────────────┐
  │  DETACHED   │ [BLACK_LEFT-POINTING_POINTER]────────────── │   REMOVED    │
  │(not tracked)│                 │ (to be del.) │
  └─────────────┘                 └──────────────┘

Description des états :
- TRANSIENT : objet créé avec `new`, JPA ne le connaît pas
- MANAGED : objet géré par l'EntityManager, synchronisé avec la BD
- DETACHED : l'EntityManager a été fermé, l'objet n'est plus suivi
- REMOVED : marqué pour suppression, sera supprimé au flush()

Dans Spring Boot (avec Spring Data JPA), vous n'utilisez PAS directement
l'EntityManager dans 99% des cas. Spring le gère pour vous via les Repository.

════════════════════════════════════════
18.4 SPRING DATA JPA
════════════════════════════════════════

Spring Data JPA est une couche au-dessus de JPA qui :
1. Élimine le code boilerplate (plus besoin d'implémenter les méthodes CRUD)
2. Génère les requêtes SQL automatiquement à partir du nom des méthodes
3. Intègre parfaitement avec Spring (transactions, IoC)

Hiérarchie des interfaces Repository :

  Repository<T, ID>
    └── CrudRepository<T, ID>         <- save, findById, findAll, delete...
          └── PagingAndSortingRepository<T, ID>  <- pagination + tri
                └── JpaRepository<T, ID>   <- flush, saveAndFlush, deleteInBatch...

Pour TaskFlow, on utilisera JpaRepository car il offre le plus de fonctionnalités.

════════════════════════════════════════
18.5 CONFIGURATION JPA DANS SPRING BOOT
════════════════════════════════════════

Dans application.properties (déjà vu partiellement) :

# ── Datasource ─────────────────────────────────────────────────────────────
spring.datasource.url=jdbc:postgresql://localhost:5432/taskflow_db
spring.datasource.username=taskflow_user
spring.datasource.password=taskflow_pass
spring.datasource.driver-class-name=org.postgresql.Driver

# Connection pool (HikariCP — le pool par défaut de Spring Boot)
spring.datasource.hikari.maximum-pool-size=20
spring.datasource.hikari.minimum-idle=5
spring.datasource.hikari.connection-timeout=20000
spring.datasource.hikari.idle-timeout=300000
spring.datasource.hikari.max-lifetime=1200000

# ── JPA / Hibernate ────────────────────────────────────────────────────────
# Stratégie de génération du schéma
# none     -> ne rien faire (PRODUCTION)
# validate -> valider le schéma sans modifier (RECOMMANDÉ si Flyway)
# update   -> mettre à jour le schéma (DÉVELOPPEMENT SEULEMENT)
# create   -> créer le schéma à chaque démarrage (TESTS)
# create-drop -> créer puis supprimer (TESTS)
spring.jpa.hibernate.ddl-auto=validate

# Afficher les requêtes SQL générées (dev uniquement)
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Dialecte PostgreSQL (Hibernate 6+ le détecte automatiquement)
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect

# Statistiques Hibernate (pour l'optimisation)
spring.jpa.properties.hibernate.generate_statistics=false

# Batch inserts (performance)
spring.jpa.properties.hibernate.jdbc.batch_size=25
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true

# Open Session In View (DÉSACTIVER en production !)
spring.jpa.open-in-view=false

════════════════════════════════════════
18.6 POURQUOI DÉSACTIVER OPEN-IN-VIEW ?
════════════════════════════════════════

Par défaut, Spring Boot active l'OSIV (Open Session In View) pattern :
- La session Hibernate reste ouverte pendant tout le cycle de la requête HTTP
- Cela permet de charger lazily les relations dans la vue (templates Thymeleaf)
- PROBLÈME : pour des APIs REST, cela maintient une connexion DB inutilement
  pendant la sérialisation JSON -> risque de saturation du pool de connexions

Solution : spring.jpa.open-in-view=false + gérer explicitement les transactions
dans la couche service.

════════════════════════════════════════
18.7 EXERCICES CHAPITRE 18
════════════════════════════════════════

EXERCICE 1 (Facile) — Concepts JPA
Listez 5 différences entre JDBC direct et JPA/Hibernate.

EXERCICE 2 (Facile) — États d'une entité
Tracez le cycle de vie d'un objet Task depuis sa création jusqu'à sa persistance
en base de données.

EXERCICE 3 (Facile) — Configuration
Écrivez la configuration application.properties minimale pour connecter Spring Boot
à une base PostgreSQL locale.

EXERCICE 4 (Intermédiaire) — Connection Pool
Expliquez ce qu'est HikariCP et pourquoi il est important de configurer
correctement le maximum-pool-size.

EXERCICE 5 (Intermédiaire) — DDL Auto
Quel ddl-auto utiliser en développement ? En test ? En production ? Justifiez.

EXERCICE 6 (Intermédiaire) — ORM Mapping
Faites la correspondance entre une classe Java Project et le DDL SQL correspondant.
La classe a : id, name, description, createdAt, owner (User), tasks (List<Task>).

EXERCICE 7 (Avancé) — Batch Size
Expliquez comment spring.jpa.properties.hibernate.jdbc.batch_size=25 améliore
les performances lors de l'insertion de 1000 entités.

EXERCICE 8 (Avancé) — OSIV
Démontrez avec un exemple de code pourquoi OSIV est problématique pour une API REST.

EXERCICE 9 (Avancé) — EntityManager
Écrivez le code équivalent à taskRepository.save(task) en utilisant directement
l'EntityManager (injection + persist dans une méthode @Transactional).

════════════════════════════════════════
18.8 CORRIGÉS CHAPITRE 18
════════════════════════════════════════

CORRIGÉ 1 :
  JDBC                          JPA/Hibernate
  ─────────────────────────     ────────────────────────────────
  SQL manuel                    SQL généré automatiquement
  Pas de cache                  Cache L1 (session) + L2 (optionnel)
  Pas de gestion des objets     Cycle de vie des entités géré
  ResultSet manuel              Mapping automatique vers objets Java
  Pas de requêtes nommées       JPQL, Criteria API, Query derivation

CORRIGÉ 3 :
  spring.datasource.url=jdbc:postgresql://localhost:5432/taskflow_db
  spring.datasource.username=taskflow_user
  spring.datasource.password=taskflow_pass
  spring.jpa.hibernate.ddl-auto=update
  spring.jpa.show-sql=true

CORRIGÉ 9 :
  @Service
  @Transactional
  public class TaskServiceImpl {

      @PersistenceContext
      private EntityManager entityManager;

      public Task saveTask(Task task) {
          if (task.getId() == null) {
              entityManager.persist(task);  // TRANSIENT -> MANAGED
              return task;
          } else {
              return entityManager.merge(task);  // DETACHED -> MANAGED
          }
      }
  }


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 19 — HIBERNATE : L'IMPLÉMENTATION JPA
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

════════════════════════════════════════
19.1 HIBERNATE EN PROFONDEUR
════════════════════════════════════════

Hibernate est l'implémentation JPA la plus utilisée (95% des projets Spring Boot).
Il ajoute des fonctionnalités au-delà du standard JPA :

1. Cache de 2ème niveau (L2 Cache) — Ehcache, Caffeine, Redis
2. Fetch strategies avancées (SELECT, JOIN, SUBSELECT, BATCH)
3. Filtres Hibernate (@Filter)
4. Types personnalisés
5. Audit automatique (avec Envers)
6. Multi-tenancy

════════════════════════════════════════
19.2 LE PROBLÈME N+1
════════════════════════════════════════

C'est LE problème le plus courant avec Hibernate.

Scénario : vous avez 100 utilisateurs, chacun avec des tâches.

Code naïf :
  List<User> users = userRepository.findAll();  // 1 requête SELECT users
  for (User user : users) {
      // Pour chaque user, Hibernate fait 1 requête pour charger ses tâches !
      System.out.println(user.getTasks().size());
  }
  // Total : 1 + 100 = 101 requêtes -> CATASTROPHIQUE

Solution 1 — JOIN FETCH dans JPQL :
  @Query("SELECT u FROM User u LEFT JOIN FETCH u.tasks WHERE u.active = true")
  List<User> findAllWithTasks();
  // Une seule requête SQL avec JOIN

Solution 2 — @EntityGraph :
  @EntityGraph(attributePaths = {"tasks", "tasks.assignee"})
  List<User> findAll();

Solution 3 — Batch fetching :
  @OneToMany
  @BatchSize(size = 25)
  private List<Task> tasks;
  // Hibernate charge les tasks en lots de 25 au lieu de 1 par 1

════════════════════════════════════════
19.3 LES STRATÉGIES DE FETCH
════════════════════════════════════════

FetchType.LAZY (par défaut pour @OneToMany, @ManyToMany) :
  -> La collection n'est chargée QUE lorsqu'on y accède
  -> Plus performant si on n'a pas toujours besoin des relations
  -> ATTENTION : LazyInitializationException si la session est fermée !

FetchType.EAGER (par défaut pour @ManyToOne, @OneToOne) :
  -> La collection est TOUJOURS chargée avec l'entité parente
  -> Pratique mais peut causer des problèmes de performance

RÈGLE D'OR :
  -> Toujours utiliser LAZY pour les collections (@OneToMany, @ManyToMany)
  -> Utiliser LAZY pour @ManyToOne/@OneToOne si la relation n'est pas toujours nécessaire
  -> Charger les relations explicitement avec JOIN FETCH ou @EntityGraph selon le besoin

════════════════════════════════════════
19.4 LES TRANSACTIONS HIBERNATE
════════════════════════════════════════

Hibernate utilise les transactions de la base de données.
Spring gère les transactions via @Transactional.

Dirty Checking (détection automatique des modifications) :
  @Transactional
  public Task updateTitle(Long id, String newTitle) {
      Task task = taskRepository.findById(id).orElseThrow();
      task.setTitle(newTitle);  // On modifie l'objet
      // PAS BESOIN d'appeler save() !
      // Hibernate détecte le changement et génère l'UPDATE automatiquement
      return task;  // au commit, Hibernate fait : UPDATE tasks SET title=? WHERE id=?
  }

COMMENT ça fonctionne ?
  Au début de la transaction, Hibernate prend un "snapshot" de chaque entité MANAGED.
  À la fin (flush/commit), il compare les entités actuelles avec le snapshot.
  Si des différences existent, il génère les UPDATE correspondants.

════════════════════════════════════════
19.5 CACHE HIBERNATE
════════════════════════════════════════

Cache L1 (Session Cache) — TOUJOURS ACTIF :
  - Portée = une transaction / session
  - Garantit qu'une même entité n'est chargée qu'UNE FOIS par transaction
  - Exemple :
    Task t1 = taskRepository.findById(1L).get();  // SELECT vers BD
    Task t2 = taskRepository.findById(1L).get();  // DEPUIS LE CACHE L1
    // t1 == t2 (même instance en mémoire)

Cache L2 (Process/Cluster Cache) — OPTIONNEL :
  - Portée = toute l'application (ou cluster)
  - Survit entre les transactions
  - Implémentations : Ehcache, Caffeine, Redis, Infinispan
  - Configuration dans Spring Boot :

    spring.jpa.properties.hibernate.cache.use_second_level_cache=true
    spring.jpa.properties.hibernate.cache.region.factory_class=
        org.hibernate.cache.jcache.JCacheCacheRegionFactory

    Sur l'entité :
    @Entity
    @Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
    public class Task { ... }

Cache de requête (Query Cache) :
  @Query("SELECT t FROM Task t WHERE t.status = :status")
  @QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true"))
  List<Task> findByStatus(@Param("status") TaskStatus status);

════════════════════════════════════════
19.6 FLYWAY : GESTION DES MIGRATIONS
════════════════════════════════════════

En production, on n'utilise JAMAIS ddl-auto=create ou update.
On utilise Flyway (ou Liquibase) pour versionner les migrations.

Structure :
  src/main/resources/
    db/migration/
      V1__init_schema.sql
      V2__add_projects_table.sql
      V3__add_task_priority.sql
      V4__add_indexes.sql

Règles de nommage Flyway :
  V{version}__{description}.sql
  V = versioned migration (irréversible)
  R = repeatable migration (réexécutée si le checksum change)
  U = undo migration (annulation)

V1__init_schema.sql (migration complète pour TaskFlow) :

-- ============================================================
-- MIGRATION V1 : Schéma initial TaskFlow
-- ============================================================

-- Extension pour UUID
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

-- ────────────────────────────────────────────────────────────
-- TABLE : users
-- ────────────────────────────────────────────────────────────
CREATE TABLE users (
    id                  BIGSERIAL PRIMARY KEY,
    uuid                UUID DEFAULT uuid_generate_v4() UNIQUE NOT NULL,
    email               VARCHAR(255) UNIQUE NOT NULL,
    password_hash       VARCHAR(255) NOT NULL,
    first_name          VARCHAR(100) NOT NULL,
    last_name           VARCHAR(100) NOT NULL,
    display_name        VARCHAR(200),
    avatar_url          VARCHAR(500),
    role                VARCHAR(50) NOT NULL DEFAULT 'USER',
    status              VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
    email_verified      BOOLEAN NOT NULL DEFAULT FALSE,
    failed_login_count  INTEGER NOT NULL DEFAULT 0,
    locked_until        TIMESTAMP,
    last_login_at       TIMESTAMP,
    created_at          TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMP NOT NULL DEFAULT NOW()
);

-- ────────────────────────────────────────────────────────────
-- TABLE : refresh_tokens
-- ────────────────────────────────────────────────────────────
CREATE TABLE refresh_tokens (
    id          BIGSERIAL PRIMARY KEY,
    token       VARCHAR(500) UNIQUE NOT NULL,
    user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    expires_at  TIMESTAMP NOT NULL,
    revoked     BOOLEAN NOT NULL DEFAULT FALSE,
    created_at  TIMESTAMP NOT NULL DEFAULT NOW()
);

-- ────────────────────────────────────────────────────────────
-- TABLE : projects
-- ────────────────────────────────────────────────────────────
CREATE TABLE projects (
    id          BIGSERIAL PRIMARY KEY,
    uuid        UUID DEFAULT uuid_generate_v4() UNIQUE NOT NULL,
    name        VARCHAR(255) NOT NULL,
    description TEXT,
    color       VARCHAR(7) DEFAULT '#6366F1',
    icon        VARCHAR(50),
    status      VARCHAR(50) NOT NULL DEFAULT 'ACTIVE',
    owner_id    BIGINT NOT NULL REFERENCES users(id),
    created_at  TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMP NOT NULL DEFAULT NOW()
);

-- ────────────────────────────────────────────────────────────
-- TABLE : project_members
-- ────────────────────────────────────────────────────────────
CREATE TABLE project_members (
    project_id  BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role        VARCHAR(50) NOT NULL DEFAULT 'MEMBER',
    joined_at   TIMESTAMP NOT NULL DEFAULT NOW(),
    PRIMARY KEY (project_id, user_id)
);

-- ────────────────────────────────────────────────────────────
-- TABLE : tasks
-- ────────────────────────────────────────────────────────────
CREATE TABLE tasks (
    id              BIGSERIAL PRIMARY KEY,
    uuid            UUID DEFAULT uuid_generate_v4() UNIQUE NOT NULL,
    title           VARCHAR(500) NOT NULL,
    description     TEXT,
    status          VARCHAR(50) NOT NULL DEFAULT 'TODO',
    priority        VARCHAR(50) NOT NULL DEFAULT 'MEDIUM',
    project_id      BIGINT REFERENCES projects(id) ON DELETE SET NULL,
    created_by      BIGINT NOT NULL REFERENCES users(id),
    assignee_id     BIGINT REFERENCES users(id) ON DELETE SET NULL,
    due_date        DATE,
    estimated_hours DECIMAL(6,2),
    actual_hours    DECIMAL(6,2),
    position        INTEGER NOT NULL DEFAULT 0,
    parent_task_id  BIGINT REFERENCES tasks(id) ON DELETE CASCADE,
    created_at      TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMP NOT NULL DEFAULT NOW()
);

-- ────────────────────────────────────────────────────────────
-- TABLE : tags
-- ────────────────────────────────────────────────────────────
CREATE TABLE tags (
    id          BIGSERIAL PRIMARY KEY,
    name        VARCHAR(100) NOT NULL,
    color       VARCHAR(7) DEFAULT '#64748B',
    project_id  BIGINT REFERENCES projects(id) ON DELETE CASCADE,
    UNIQUE(name, project_id)
);

-- ────────────────────────────────────────────────────────────
-- TABLE : task_tags (relation N-N)
-- ────────────────────────────────────────────────────────────
CREATE TABLE task_tags (
    task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
    tag_id  BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
    PRIMARY KEY (task_id, tag_id)
);

-- ────────────────────────────────────────────────────────────
-- TABLE : comments
-- ────────────────────────────────────────────────────────────
CREATE TABLE comments (
    id          BIGSERIAL PRIMARY KEY,
    content     TEXT NOT NULL,
    task_id     BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
    author_id   BIGINT NOT NULL REFERENCES users(id),
    edited      BOOLEAN NOT NULL DEFAULT FALSE,
    created_at  TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMP NOT NULL DEFAULT NOW()
);

-- ────────────────────────────────────────────────────────────
-- TABLE : attachments
-- ────────────────────────────────────────────────────────────
CREATE TABLE attachments (
    id              BIGSERIAL PRIMARY KEY,
    filename        VARCHAR(255) NOT NULL,
    original_name   VARCHAR(255) NOT NULL,
    content_type    VARCHAR(100),
    file_size       BIGINT,
    storage_path    VARCHAR(500) NOT NULL,
    task_id         BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
    uploaded_by     BIGINT NOT NULL REFERENCES users(id),
    created_at      TIMESTAMP NOT NULL DEFAULT NOW()
);

-- ────────────────────────────────────────────────────────────
-- TABLE : notifications
-- ────────────────────────────────────────────────────────────
CREATE TABLE notifications (
    id          BIGSERIAL PRIMARY KEY,
    type        VARCHAR(100) NOT NULL,
    title       VARCHAR(255) NOT NULL,
    message     TEXT,
    read        BOOLEAN NOT NULL DEFAULT FALSE,
    user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    data        JSONB,
    created_at  TIMESTAMP NOT NULL DEFAULT NOW()
);

-- ────────────────────────────────────────────────────────────
-- INDEX (Performance)
-- ────────────────────────────────────────────────────────────
CREATE INDEX idx_tasks_project_id     ON tasks(project_id);
CREATE INDEX idx_tasks_assignee_id    ON tasks(assignee_id);
CREATE INDEX idx_tasks_created_by     ON tasks(created_by);
CREATE INDEX idx_tasks_status         ON tasks(status);
CREATE INDEX idx_tasks_due_date       ON tasks(due_date);
CREATE INDEX idx_comments_task_id     ON comments(task_id);
CREATE INDEX idx_notifications_user   ON notifications(user_id, read);
CREATE INDEX idx_refresh_tokens_user  ON refresh_tokens(user_id);


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 20 — ENTITÉS JPA
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

════════════════════════════════════════
20.1 BASE ENTITY — CLASSE MÈRE
════════════════════════════════════════

package com.taskflow.backend.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

/**
 * Classe de base pour toutes les entités JPA.
 * Fournit les champs d'audit automatiques (createdAt, updatedAt)
 * et la gestion de l'identifiant.
 *
 * @MappedSuperclass -> cette classe n'a PAS sa propre table SQL.
 *   Ses champs sont hérités par les classes filles dans LEURS tables.
 *
 * @EntityListeners -> active l'audit automatique Spring Data JPA
 */
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter
@Setter
public abstract class BaseEntity {

    /**
     * @GeneratedValue(strategy = GenerationType.IDENTITY) :
     *   Utilise la séquence BIGSERIAL de PostgreSQL.
     *   Alternatives :
     *   - SEQUENCE : utilise une séquence Hibernate (plus performant en batch)
     *   - TABLE : table séquence (à éviter)
     *   - AUTO : Hibernate choisit (éviter)
     *   - UUID : pour des IDs de type UUID
     */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", updatable = false, nullable = false)
    private Long id;

    /**
     * @CreationTimestamp : Hibernate définit cette valeur automatiquement
     *   lors de la première insertion. Plus simple que @PrePersist.
     * updatable = false : empêche toute modification après création.
     */
    @CreationTimestamp
    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    /**
     * @UpdateTimestamp : Hibernate met à jour cette valeur à chaque modification.
     * Plus simple que @PreUpdate.
     */
    @UpdateTimestamp
    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;

    /**
     * equals() et hashCode() basés sur l'ID.
     * IMPORTANT : en JPA, ces méthodes doivent être soigneusement implémentées.
     *
     * Règle :
     * - Avant persistance (id == null) : chaque objet est unique (identité objet)
     * - Après persistance (id != null) : comparaison par ID
     *
     * Éviter @EqualsAndHashCode de Lombok sur les entités JPA !
     * (Lombok utilise tous les champs par défaut -> problèmes avec les proxies)
     */
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof BaseEntity other)) return false;
        return id != null && id.equals(other.id);
    }

    @Override
    public int hashCode() {
        return id != null ? id.hashCode() : System.identityHashCode(this);
    }
}

════════════════════════════════════════
20.2 ENTITÉ USER
════════════════════════════════════════

package com.taskflow.backend.entity;

import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.NaturalId;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

/**
 * Entité représentant un utilisateur de la plateforme.
 *
 * @Entity       : indique à JPA que cette classe est une entité persistante
 * @Table        : configure le nom de la table et les contraintes
 * @NoArgsConstructor : requis par JPA (constructeur sans argument obligatoire)
 * @AllArgsConstructor : pratique pour les tests
 * @Builder      : pattern Builder pour créer des instances
 *
 * NOTE : On n'utilise PAS @Data de Lombok sur les entités JPA car :
 *   - @Data génère equals/hashCode basés sur tous les champs
 *   - Cela cause des problèmes avec les proxies Hibernate
 *   - Cela peut déclencher des chargements en cascade non désirés
 *   On utilise @Getter @Setter séparément.
 */
@Entity
@Table(
    name = "users",
    uniqueConstraints = {
        @UniqueConstraint(name = "uk_users_email", columnNames = "email"),
        @UniqueConstraint(name = "uk_users_uuid",  columnNames = "uuid")
    },
    indexes = {
        @Index(name = "idx_users_email",  columnList = "email"),
        @Index(name = "idx_users_status", columnList = "status")
    }
)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User extends BaseEntity {

    /**
     * UUID public : exposé dans les APIs pour ne pas révéler l'ID séquentiel.
     * NaturalId : Hibernate optimise les lookups par uuid.
     */
    @NaturalId
    @Column(name = "uuid", nullable = false, updatable = false)
    @Builder.Default
    private UUID uuid = UUID.randomUUID();

    /**
     * @Column : configure précisément la colonne SQL.
     *   - nullable = false : contrainte NOT NULL
     *   - unique = true : contrainte UNIQUE (redondant avec @UniqueConstraint mais plus lisible)
     *   - length = 255 : taille VARCHAR
     */
    @Column(name = "email", nullable = false, unique = true, length = 255)
    private String email;

    /**
     * Le mot de passe est TOUJOURS stocké haché (BCrypt).
     * @JsonIgnore est sur le DTO, pas sur l'entité.
     * @Column(name = "password_hash") : nom explicite pour rappeler que c'est un hash.
     */
    @Column(name = "password_hash", nullable = false)
    private String passwordHash;

    @Column(name = "first_name", nullable = false, length = 100)
    private String firstName;

    @Column(name = "last_name", nullable = false, length = 100)
    private String lastName;

    @Column(name = "display_name", length = 200)
    private String displayName;

    @Column(name = "avatar_url", length = 500)
    private String avatarUrl;

    /**
     * @Enumerated(EnumType.STRING) : stocke le NOM de l'enum en base
     *   (ex: "ADMIN", "USER") et non son ordinal (0, 1, 2...).
     *
     * TOUJOURS utiliser EnumType.STRING en production :
     *   - L'ordinal change si on réordonne les valeurs de l'enum -> BUG silencieux
     *   - Le STRING est lisible directement en base de données
     */
    @Enumerated(EnumType.STRING)
    @Column(name = "role", nullable = false, length = 50)
    @Builder.Default
    private UserRole role = UserRole.USER;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false, length = 50)
    @Builder.Default
    private UserStatus status = UserStatus.ACTIVE;

    @Column(name = "email_verified", nullable = false)
    @Builder.Default
    private Boolean emailVerified = false;

    @Column(name = "failed_login_count", nullable = false)
    @Builder.Default
    private Integer failedLoginCount = 0;

    @Column(name = "locked_until")
    private LocalDateTime lockedUntil;

    @Column(name = "last_login_at")
    private LocalDateTime lastLoginAt;

    // ── Relations ─────────────────────────────────────────────────────────

    /**
     * @OneToMany : un utilisateur peut avoir plusieurs tâches assignées
     *
     * mappedBy = "assignee" : la RELATION est possédée par la table "tasks"
     *   (la colonne FK assignee_id est dans tasks).
     *   "assignee" est le NOM DU CHAMP dans Task qui référence User.
     *
     * cascade = CascadeType.ALL : ÉVITER pour les relations depuis User vers Tasks.
     *   Si on supprime un User, on ne veut pas supprimer toutes ses tâches.
     *   Utiliser des cascades ciblées.
     *
     * fetch = FetchType.LAZY : ne pas charger les tâches tant qu'on n'en a pas besoin.
     *
     * orphanRemoval = false : si on retire une tâche de la liste,
     *   elle n'est PAS supprimée de la BD (juste désassignée).
     */
    @OneToMany(mappedBy = "assignee", fetch = FetchType.LAZY)
    @Builder.Default
    private List<Task> assignedTasks = new ArrayList<>();

    /**
     * Les tâches créées par cet utilisateur.
     */
    @OneToMany(mappedBy = "createdBy", fetch = FetchType.LAZY)
    @Builder.Default
    private List<Task> createdTasks = new ArrayList<>();

    /**
     * Les projets dont cet utilisateur est propriétaire.
     */
    @OneToMany(mappedBy = "owner", fetch = FetchType.LAZY)
    @Builder.Default
    private List<Project> ownedProjects = new ArrayList<>();

    /**
     * Les tokens de refresh actifs de cet utilisateur.
     * cascade = ALL + orphanRemoval = true : si on supprime l'user,
     *   tous ses tokens de refresh sont supprimés.
     */
    @OneToMany(
        mappedBy = "user",
        cascade = CascadeType.ALL,
        orphanRemoval = true,
        fetch = FetchType.LAZY
    )
    @Builder.Default
    private List<RefreshToken> refreshTokens = new ArrayList<>();

    // ── Méthodes utilitaires ───────────────────────────────────────────────

    /**
     * Méthode utilitaire : vérifie si l'utilisateur est verrouillé.
     */
    public boolean isLocked() {
        return lockedUntil != null && lockedUntil.isAfter(LocalDateTime.now());
    }

    /**
     * Méthode utilitaire : incrémente le compteur d'échecs de connexion.
     * Si on atteint 5 tentatives, verrouille le compte 15 minutes.
     */
    public void incrementFailedLoginCount() {
        this.failedLoginCount++;
        if (this.failedLoginCount >= 5) {
            this.lockedUntil = LocalDateTime.now().plusMinutes(15);
        }
    }

    /**
     * Méthode utilitaire : réinitialise le compteur d'échecs.
     */
    public void resetFailedLoginCount() {
        this.failedLoginCount = 0;
        this.lockedUntil = null;
    }

    /**
     * Méthode utilitaire : retourne le nom complet.
     */
    public String getFullName() {
        return firstName + " " + lastName;
    }
}

════════════════════════════════════════
20.3 ENTITÉ PROJECT
════════════════════════════════════════

package com.taskflow.backend.entity;

import jakarta.persistence.*;
import lombok.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

@Entity
@Table(name = "projects")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class Project extends BaseEntity {

    @Column(name = "uuid", nullable = false, updatable = false, unique = true)
    @Builder.Default
    private UUID uuid = UUID.randomUUID();

    @Column(name = "name", nullable = false, length = 255)
    private String name;

    @Column(name = "description", columnDefinition = "TEXT")
    private String description;

    @Column(name = "color", length = 7)
    @Builder.Default
    private String color = "#6366F1";

    @Column(name = "icon", length = 50)
    private String icon;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false, length = 50)
    @Builder.Default
    private ProjectStatus status = ProjectStatus.ACTIVE;

    /**
     * @ManyToOne : plusieurs projets peuvent avoir le même propriétaire.
     *
     * @JoinColumn : définit la COLONNE DE CLÉ ÉTRANGÈRE dans la table "projects".
     *   name = "owner_id" : nom de la colonne FK
     *   nullable = false : chaque projet doit avoir un propriétaire
     *
     * fetch = FetchType.LAZY : ne pas charger le User à chaque chargement de Project.
     *   (défaut pour @ManyToOne dans les nouvelles versions d'Hibernate est EAGER
     *    -> le changer en LAZY est une bonne pratique)
     */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "owner_id", nullable = false)
    private User owner;

    /**
     * Les tâches de ce projet.
     *
     * cascade = {PERSIST, MERGE} : si on persiste/merge un project,
     *   ses nouvelles tâches sont aussi persistées.
     *   On n'utilise PAS CascadeType.REMOVE ici car la suppression d'un projet
     *   est gérée au niveau SQL (ON DELETE SET NULL).
     */
    @OneToMany(
        mappedBy = "project",
        cascade = {CascadeType.PERSIST, CascadeType.MERGE},
        fetch = FetchType.LAZY
    )
    @Builder.Default
    private List<Task> tasks = new ArrayList<>();

    /**
     * Les membres du projet (relation @ManyToMany avec attribut supplémentaire).
     * Ici on utilise une entité de jointure (ProjectMember) pour stocker le rôle.
     */
    @OneToMany(
        mappedBy = "project",
        cascade = CascadeType.ALL,
        orphanRemoval = true,
        fetch = FetchType.LAZY
    )
    @Builder.Default
    private List<ProjectMember> members = new ArrayList<>();

    // ── Méthodes helper pour gérer la relation bidirectionnelle ───────────

    /**
     * Helper pour ajouter une tâche ET maintenir la cohérence bidirectionnelle.
     * IMPORTANT : toujours maintenir les deux côtés d'une relation bidirectionnelle !
     */
    public void addTask(Task task) {
        tasks.add(task);
        task.setProject(this);
    }

    public void removeTask(Task task) {
        tasks.remove(task);
        task.setProject(null);
    }

    public void addMember(ProjectMember member) {
        members.add(member);
        member.setProject(this);
    }
}

════════════════════════════════════════
20.4 ENTITÉ TASK
════════════════════════════════════════

package com.taskflow.backend.entity;

import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;

@Entity
@Table(
    name = "tasks",
    indexes = {
        @Index(name = "idx_tasks_project_id",  columnList = "project_id"),
        @Index(name = "idx_tasks_assignee_id", columnList = "assignee_id"),
        @Index(name = "idx_tasks_status",      columnList = "status"),
        @Index(name = "idx_tasks_due_date",    columnList = "due_date")
    }
)
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class Task extends BaseEntity {

    @Column(name = "uuid", nullable = false, updatable = false, unique = true)
    @Builder.Default
    private UUID uuid = UUID.randomUUID();

    @Column(name = "title", nullable = false, length = 500)
    private String title;

    /**
     * columnDefinition = "TEXT" : type TEXT en PostgreSQL (illimité).
     * Utile pour les champs de description longue.
     */
    @Column(name = "description", columnDefinition = "TEXT")
    private String description;

    @Enumerated(EnumType.STRING)
    @Column(name = "status", nullable = false, length = 50)
    @Builder.Default
    private TaskStatus status = TaskStatus.TODO;

    @Enumerated(EnumType.STRING)
    @Column(name = "priority", nullable = false, length = 50)
    @Builder.Default
    private TaskPriority priority = TaskPriority.MEDIUM;

    // ── Relations ─────────────────────────────────────────────────────────

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "project_id")  // Nullable : une tâche peut ne pas avoir de projet
    private Project project;

    /**
     * La personne qui a créé la tâche.
     * updatable = false : une fois défini, on ne change pas l'auteur.
     */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "created_by", nullable = false, updatable = false)
    private User createdBy;

    /**
     * La personne assignée à la tâche (peut être null).
     */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "assignee_id")
    private User assignee;

    @Column(name = "due_date")
    private LocalDate dueDate;

    /**
     * BigDecimal pour les valeurs décimales exactes (pas de perte de précision).
     * precision = 6, scale = 2 -> ex: 9999.99
     */
    @Column(name = "estimated_hours", precision = 6, scale = 2)
    private BigDecimal estimatedHours;

    @Column(name = "actual_hours", precision = 6, scale = 2)
    private BigDecimal actualHours;

    @Column(name = "position", nullable = false)
    @Builder.Default
    private Integer position = 0;

    /**
     * Relation récursive : une tâche peut avoir une tâche parente (sous-tâche).
     * self-referential @ManyToOne
     */
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_task_id")
    private Task parentTask;

    /**
     * Les sous-tâches de cette tâche.
     */
    @OneToMany(
        mappedBy = "parentTask",
        cascade = {CascadeType.PERSIST, CascadeType.MERGE},
        fetch = FetchType.LAZY
    )
    @Builder.Default
    private List<Task> subTasks = new ArrayList<>();

    /**
     * @ManyToMany : une tâche peut avoir plusieurs tags, un tag peut être sur plusieurs tâches.
     *
     * @JoinTable : définit la table de jointure "task_tags".
     *   joinColumns : la FK vers la table courante (tasks)
     *   inverseJoinColumns : la FK vers l'autre table (tags)
     *
     * Utiliser Set<> et non List<> pour les @ManyToMany :
     *   - Hibernate génère des requêtes plus efficaces avec Set
     *   - Évite les doublons
     *   - Pas de problème avec equals/hashCode des entités Tag
     */
    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(
        name = "task_tags",
        joinColumns = @JoinColumn(name = "task_id"),
        inverseJoinColumns = @JoinColumn(name = "tag_id")
    )
    @Builder.Default
    private Set<Tag> tags = new HashSet<>();

    /**
     * Les commentaires de cette tâche.
     * orphanRemoval = true : si on retire un commentaire de la liste, il est supprimé en BD.
     */
    @OneToMany(
        mappedBy = "task",
        cascade = CascadeType.ALL,
        orphanRemoval = true,
        fetch = FetchType.LAZY
    )
    @Builder.Default
    private List<Comment> comments = new ArrayList<>();

    /**
     * Les pièces jointes.
     */
    @OneToMany(
        mappedBy = "task",
        cascade = CascadeType.ALL,
        orphanRemoval = true,
        fetch = FetchType.LAZY
    )
    @Builder.Default
    private List<Attachment> attachments = new ArrayList<>();

    // ── Méthodes helper ───────────────────────────────────────────────────

    public void addTag(Tag tag) {
        tags.add(tag);
    }

    public void removeTag(Tag tag) {
        tags.remove(tag);
    }

    public void addComment(Comment comment) {
        comments.add(comment);
        comment.setTask(this);
    }

    public boolean isOverdue() {
        return dueDate != null
            && LocalDate.now().isAfter(dueDate)
            && status != TaskStatus.DONE;
    }
}

════════════════════════════════════════
20.5 ENUM ENTITIES
════════════════════════════════════════

// ── TaskStatus.java ───────────────────────────────────────────────────────
package com.taskflow.backend.entity;

public enum TaskStatus {
    TODO,           // Tâche créée, pas encore commencée
    IN_PROGRESS,    // En cours de traitement
    IN_REVIEW,      // En attente de revue/validation
    DONE,           // Terminée
    CANCELLED,      // Annulée
    BLOCKED;        // Bloquée par une dépendance

    /**
     * Vérifie si une transition de statut est autorisée.
     * Implémente la machine d'états des tâches.
     */
    public boolean canTransitionTo(TaskStatus newStatus) {
        return switch (this) {
            case TODO         -> newStatus == IN_PROGRESS || newStatus == CANCELLED;
            case IN_PROGRESS  -> newStatus == IN_REVIEW || newStatus == DONE || newStatus == BLOCKED || newStatus == CANCELLED;
            case IN_REVIEW    -> newStatus == DONE || newStatus == IN_PROGRESS;
            case BLOCKED      -> newStatus == IN_PROGRESS || newStatus == CANCELLED;
            case DONE, CANCELLED -> false;  // États terminaux
        };
    }
}

// ── TaskPriority.java ─────────────────────────────────────────────────────
package com.taskflow.backend.entity;

public enum TaskPriority {
    LOW(1), MEDIUM(2), HIGH(3), CRITICAL(4);

    private final int level;

    TaskPriority(int level) { this.level = level; }

    public int getLevel() { return level; }
}

// ── UserRole.java ─────────────────────────────────────────────────────────
package com.taskflow.backend.entity;

public enum UserRole {
    USER,   // Utilisateur standard
    ADMIN,  // Administrateur de la plateforme
    MANAGER // Manager de projet
}

// ── UserStatus.java ───────────────────────────────────────────────────────
package com.taskflow.backend.entity;

public enum UserStatus {
    ACTIVE,         // Compte actif
    INACTIVE,       // Désactivé par l'admin
    SUSPENDED,      // Suspendu pour violation
    PENDING_EMAIL   // En attente de vérification email
}

// ── ProjectStatus.java ────────────────────────────────────────────────────
package com.taskflow.backend.entity;

public enum ProjectStatus {
    ACTIVE,     // Projet en cours
    ARCHIVED,   // Projet archivé
    COMPLETED   // Projet terminé
}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 21 — RELATIONS ENTRE ENTITÉS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

════════════════════════════════════════
21.1 LES 4 TYPES DE RELATIONS JPA
════════════════════════════════════════

@ManyToOne (N:1)
────────────────
  Plusieurs tâches -> un projet
  Plusieurs tâches -> un utilisateur (assignee)

  C'est la relation la plus courante. La FK est dans la table de l'entité annotée.

  @ManyToOne(fetch = FetchType.LAZY)
  @JoinColumn(name = "project_id")
  private Project project;

  SQL généré : task.project_id -> projects.id

@OneToMany (1:N)
────────────────
  Un projet -> plusieurs tâches
  C'est l'INVERSE de @ManyToOne. La FK est dans la TABLE ENFANT.

  @OneToMany(mappedBy = "project", fetch = FetchType.LAZY)
  private List<Task> tasks;

  mappedBy = "project" signifie : "la relation est gérée par le champ 'project'
  dans la classe Task". Il ne crée PAS de colonne dans la table projects.

@OneToOne (1:1)
───────────────
  Un utilisateur -> un profil
  Un ordre -> une facture

  // Côté possesseur (la table qui contient la FK)
  @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
  @JoinColumn(name = "profile_id", unique = true)
  private UserProfile profile;

  // Côté référencé
  @OneToOne(mappedBy = "profile")
  private User user;

@ManyToMany (N:N)
─────────────────
  Une tâche -> plusieurs tags
  Un tag -> plusieurs tâches

  Nécessite une table de jointure.

  // Côté propriétaire (ownership side)
  @ManyToMany
  @JoinTable(
      name = "task_tags",
      joinColumns = @JoinColumn(name = "task_id"),
      inverseJoinColumns = @JoinColumn(name = "tag_id")
  )
  private Set<Tag> tags;

  // Côté miroir (mappedBy)
  @ManyToMany(mappedBy = "tags")
  private Set<Task> tasks;

════════════════════════════════════════
21.2 RELATIONS AVEC ATTRIBUTS : ENTITÉ DE JOINTURE
════════════════════════════════════════

Quand la relation N-N a des attributs supplémentaires (ex: rôle d'un membre),
on crée une ENTITÉ DE JOINTURE au lieu de @ManyToMany.

// ── ProjectMember.java ────────────────────────────────────────────────────
package com.taskflow.backend.entity;

import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;

/**
 * Entité de jointure entre Project et User.
 * Représente l'appartenance d'un utilisateur à un projet, avec son rôle.
 *
 * On utilise une @IdClass ou @EmbeddedId pour la clé composite.
 * Ici on choisit @EmbeddedId pour plus de clarté.
 */
@Entity
@Table(name = "project_members")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class ProjectMember {

    /**
     * Clé primaire composite : (project_id, user_id)
     *
     * @EmbeddedId : utilise une classe @Embeddable comme PK composite.
     */
    @EmbeddedId
    @Builder.Default
    private ProjectMemberId id = new ProjectMemberId();

    /**
     * @MapsId("projectId") : lie le champ projectId de ProjectMemberId
     * à la relation @ManyToOne. Évite la redondance de la valeur.
     */
    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("projectId")
    @JoinColumn(name = "project_id")
    private Project project;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("userId")
    @JoinColumn(name = "user_id")
    private User user;

    @Enumerated(EnumType.STRING)
    @Column(name = "role", nullable = false, length = 50)
    @Builder.Default
    private ProjectMemberRole role = ProjectMemberRole.MEMBER;

    @Column(name = "joined_at", nullable = false, updatable = false)
    @Builder.Default
    private LocalDateTime joinedAt = LocalDateTime.now();
}

// ── ProjectMemberId.java ──────────────────────────────────────────────────
package com.taskflow.backend.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
import lombok.*;
import java.io.Serializable;

/**
 * Classe de clé primaire composite (doit implémenter Serializable).
 * @Embeddable : peut être embarquée dans une entité.
 */
@Embeddable
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@EqualsAndHashCode
public class ProjectMemberId implements Serializable {

    @Column(name = "project_id")
    private Long projectId;

    @Column(name = "user_id")
    private Long userId;
}

// ── ProjectMemberRole.java ────────────────────────────────────────────────
public enum ProjectMemberRole {
    VIEWER,   // Peut lire uniquement
    MEMBER,   // Peut créer et modifier ses tâches
    ADMIN     // Peut gérer le projet (ajouter membres, modifier paramètres)
}

════════════════════════════════════════
21.3 TAG ET COMMENT ENTITIES
════════════════════════════════════════

// ── Tag.java ──────────────────────────────────────────────────────────────
package com.taskflow.backend.entity;

import jakarta.persistence.*;
import lombok.*;
import java.util.HashSet;
import java.util.Set;

@Entity
@Table(
    name = "tags",
    uniqueConstraints = @UniqueConstraint(
        name = "uk_tags_name_project",
        columnNames = {"name", "project_id"}
    )
)
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class Tag extends BaseEntity {

    @Column(name = "name", nullable = false, length = 100)
    private String name;

    @Column(name = "color", length = 7)
    @Builder.Default
    private String color = "#64748B";

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "project_id")
    private Project project;

    @ManyToMany(mappedBy = "tags", fetch = FetchType.LAZY)
    @Builder.Default
    private Set<Task> tasks = new HashSet<>();
}

// ── Comment.java ──────────────────────────────────────────────────────────
package com.taskflow.backend.entity;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Table(name = "comments")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class Comment extends BaseEntity {

    @Column(name = "content", columnDefinition = "TEXT", nullable = false)
    private String content;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "task_id", nullable = false)
    private Task task;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id", nullable = false)
    private User author;

    @Column(name = "edited", nullable = false)
    @Builder.Default
    private Boolean edited = false;
}

// ── RefreshToken.java ─────────────────────────────────────────────────────
package com.taskflow.backend.entity;

import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "refresh_tokens")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class RefreshToken extends BaseEntity {

    @Column(name = "token", nullable = false, unique = true, length = 500)
    private String token;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Column(name = "expires_at", nullable = false)
    private LocalDateTime expiresAt;

    @Column(name = "revoked", nullable = false)
    @Builder.Default
    private Boolean revoked = false;

    public boolean isExpired() {
        return LocalDateTime.now().isAfter(expiresAt);
    }

    public boolean isValid() {
        return !revoked && !isExpired();
    }
}

════════════════════════════════════════
21.4 ACTIVATION DE L'AUDIT SPRING DATA JPA
════════════════════════════════════════

Pour activer les annotations @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy
de Spring Data JPA, il faut configurer l'auditing :

// ── JpaConfig.java ────────────────────────────────────────────────────────
package com.taskflow.backend.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.AuditorAware;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

import java.util.Optional;

/**
 * @EnableJpaAuditing : active l'audit automatique JPA.
 *   - auditorAwareRef : nom du bean qui fournit "l'auteur" actuel
 *     (pour @CreatedBy, @LastModifiedBy)
 */
@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
public class JpaConfig {

    /**
     * AuditorAware : fournit l'email de l'utilisateur connecté.
     * Spring Data l'utilise pour remplir @CreatedBy et @LastModifiedBy.
     */
    @Bean
    public AuditorAware<String> auditorProvider() {
        return () -> {
            Authentication auth = SecurityContextHolder.getContext().getAuthentication();
            if (auth == null || !auth.isAuthenticated()
                    || auth.getPrincipal().equals("anonymousUser")) {
                return Optional.of("system");
            }
            return Optional.of(auth.getName());
        };
    }
}

════════════════════════════════════════
21.5 BONNES PRATIQUES ENTITÉS JPA
════════════════════════════════════════

1. Ne JAMAIS exposer les entités directement dans les APIs
   -> Toujours utiliser des DTOs
   -> Évite de sérialiser des données sensibles (passwordHash, tokens...)
   -> Évite les cycles de sérialisation (User -> Tasks -> User -> ...)

2. Toujours utiliser FetchType.LAZY pour les collections
   -> Chargez explicitement les relations nécessaires avec JOIN FETCH ou @EntityGraph

3. @Enumerated(EnumType.STRING) — TOUJOURS
   -> L'ordinal change si on réordonne les valeurs = bug silencieux en production

4. equals() et hashCode() — Ne pas utiliser @Data de Lombok sur les entités
   -> Implémenter manuellement ou hériter de BaseEntity

5. Bidirectionnel -> maintenir les deux côtés
   -> Créer des méthodes helper (addTask/removeTask)
   -> Sinon les deux entités peuvent avoir des états incohérents

6. Éviter CascadeType.ALL par défaut
   -> Réfléchir à chaque type de cascade (PERSIST, MERGE, REMOVE, DETACH, REFRESH)
   -> CascadeType.ALL + orphanRemoval = true uniquement pour les agrégats "parents"

7. @Column(updatable = false) pour les champs immuables
   -> id, uuid, createdAt, createdBy

8. Utiliser UUID pour les IDs exposés publiquement
   -> Évite l'énumération séquentielle d'IDs

════════════════════════════════════════
21.6 EXERCICES CHAPITRE 20-21
════════════════════════════════════════

EXERCICE 1 (Facile) — Entité simple
Créez une entité `Attachment` avec les champs :
  - id, filename, originalName, contentType, fileSize, storagePath
  - Relation @ManyToOne vers Task
  - Relation @ManyToOne vers User (uploadedBy)

EXERCICE 2 (Facile) — Enum
Créez l'enum `NotificationType` avec les valeurs :
  TASK_ASSIGNED, TASK_COMMENT, TASK_DUE_SOON, PROJECT_INVITATION

EXERCICE 3 (Intermédiaire) — Relation @OneToOne
Créez une entité `UserProfile` avec :
  - bio (TEXT), website, linkedinUrl, githubUrl, timezone
  - Relation @OneToOne avec User (User possède la FK)

EXERCICE 4 (Intermédiaire) — Notification Entity
Créez l'entité `Notification` complète avec :
  - id, type (enum), title, message, read (boolean), userId (@ManyToOne), data (JSON)

  Pour stocker du JSON dans PostgreSQL :
  @Type(JsonBinaryType.class)
  @Column(columnDefinition = "jsonb")
  private Map<String, Object> data;

  (Nécessite la dépendance hypersistence-utils)

EXERCICE 5 (Avancé) — Requête optimisée
Écrivez une requête JPQL qui charge une Task avec :
  - Son projet
  - Son assignee
  - Ses tags
  Sans déclencher le problème N+1.

EXERCICE 6 (Avancé) — Version Optimiste
Ajoutez le verrouillage optimiste à l'entité Task pour éviter les conflits
de modification concurrente :
  @Version
  private Long version;
Expliquez ce que fait @Version et comment Hibernate l'utilise.

════════════════════════════════════════
21.7 CORRIGÉS CHAPITRES 20-21
════════════════════════════════════════

CORRIGÉ 1 — Attachment Entity :

@Entity
@Table(name = "attachments")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class Attachment extends BaseEntity {

    @Column(name = "filename", nullable = false, length = 255)
    private String filename;

    @Column(name = "original_name", nullable = false, length = 255)
    private String originalName;

    @Column(name = "content_type", length = 100)
    private String contentType;

    @Column(name = "file_size")
    private Long fileSize;

    @Column(name = "storage_path", nullable = false, length = 500)
    private String storagePath;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "task_id", nullable = false)
    private Task task;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "uploaded_by", nullable = false)
    private User uploadedBy;
}

CORRIGÉ 5 — Requête sans N+1 :

@Query("""
    SELECT DISTINCT t FROM Task t
    LEFT JOIN FETCH t.project
    LEFT JOIN FETCH t.assignee
    LEFT JOIN FETCH t.tags
    WHERE t.id = :id
    """)
Optional<Task> findByIdWithDetails(@Param("id") Long id);

CORRIGÉ 6 — Version Optimiste :

Dans Task.java :
  @Version
  @Column(name = "version")
  private Long version;

Ajouter dans la migration V2 :
  ALTER TABLE tasks ADD COLUMN version BIGINT NOT NULL DEFAULT 0;

Fonctionnement :
  - Hibernate ajoute une condition WHERE version = ? à chaque UPDATE
  - Si deux transactions modifient la même tâche en même temps :
    - La première réussit et incrémente version
    - La seconde échoue car version != attendue
    - Spring lève OptimisticLockException
  - Le client doit réessayer ou informer l'utilisateur du conflit

================================================================================
   FIN PARTIE 5 — Prochaine partie : CRUD complet avec Spring Data JPA
================================================================================

================================================================================
   GUIDE SPRING BOOT MASTER — PARTIE 6
   CRUD COMPLET AVEC SPRING DATA JPA
   Chapitres 22 à 25
   Projet fil rouge : TaskFlow Backend
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 22 — CREATE (CRÉER DES DONNÉES)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

════════════════════════════════════════
22.1 REPOSITORIES SPRING DATA JPA
════════════════════════════════════════

Avant de créer des données, définissons les repositories pour chaque entité.

// ── UserRepository.java ───────────────────────────────────────────────────
package com.taskflow.backend.repository;

import com.taskflow.backend.entity.User;
import com.taskflow.backend.entity.UserStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

/**
 * Repository pour l'entité User.
 *
 * JpaRepository<User, Long> :
 *   - User : type de l'entité
 *   - Long : type de la clé primaire (id)
 *
 * Méthodes héritées disponibles automatiquement :
 *   - save(user) / saveAll(users) / saveAndFlush(user)
 *   - findById(id) -> Optional<User>
 *   - findAll() / findAll(Pageable) / findAll(Sort)
 *   - findAllById(ids)
 *   - count()
 *   - existsById(id)
 *   - deleteById(id) / delete(user) / deleteAll()
 *   - getReferenceById(id) -> proxy (pas de SELECT immédiat)
 *
 * JpaSpecificationExecutor<User> : permet les requêtes dynamiques avec Criteria API
 */
@Repository
public interface UserRepository
    extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {

    // ── Query Method Derivation ───────────────────────────────────────────
    // Spring Data génère automatiquement le SQL à partir du nom de la méthode

    /**
     * Génère : SELECT * FROM users WHERE email = ?
     */
    Optional<User> findByEmail(String email);

    /**
     * Génère : SELECT * FROM users WHERE uuid = ?
     */
    Optional<User> findByUuid(UUID uuid);

    /**
     * Génère : SELECT CASE WHEN COUNT(*) > 0 THEN true ELSE false END
     *          FROM users WHERE email = ?
     */
    boolean existsByEmail(String email);

    /**
     * Génère : SELECT * FROM users WHERE status = ? AND emailVerified = ?
     */
    List<User> findByStatusAndEmailVerified(UserStatus status, Boolean emailVerified);

    /**
     * Génère : SELECT * FROM users WHERE email LIKE %?%
     */
    List<User> findByEmailContainingIgnoreCase(String emailPart);

    // ── @Query JPQL ───────────────────────────────────────────────────────
    // Pour les requêtes plus complexes, on écrit le JPQL manuellement

    /**
     * Chargement du User avec ses projets (JOIN FETCH pour éviter N+1).
     * JPQL utilise les noms des CHAMPS Java (pas les noms des colonnes SQL).
     */
    @Query("""
        SELECT u FROM User u
        LEFT JOIN FETCH u.ownedProjects
        WHERE u.id = :id
        """)
    Optional<User> findByIdWithProjects(@Param("id") Long id);

    /**
     * Chercher des utilisateurs par nom ou email (recherche full-text simplifiée).
     */
    @Query("""
        SELECT u FROM User u
        WHERE LOWER(u.email) LIKE LOWER(CONCAT('%', :search, '%'))
           OR LOWER(u.firstName) LIKE LOWER(CONCAT('%', :search, '%'))
           OR LOWER(u.lastName) LIKE LOWER(CONCAT('%', :search, '%'))
        ORDER BY u.firstName, u.lastName
        """)
    List<User> searchByEmailOrName(@Param("search") String search);

    // ── @Query SQL natif ──────────────────────────────────────────────────
    // Quand JPQL ne suffit pas (fonctions spécifiques à PostgreSQL, CTEs...)

    /**
     * nativeQuery = true : SQL pur PostgreSQL (non JPQL).
     * Utiliser avec parcimonie : moins portable entre SGBD.
     */
    @Query(
        value = """
            SELECT u.* FROM users u
            WHERE u.failed_login_count >= :minAttempts
              AND u.locked_until > NOW()
            """,
        nativeQuery = true
    )
    List<User> findLockedUsers(@Param("minAttempts") int minAttempts);

    // ── @Modifying ────────────────────────────────────────────────────────
    // Pour les UPDATE et DELETE avec @Query

    /**
     * @Modifying : indique que c'est une requête de modification (UPDATE/DELETE).
     * Doit être dans une méthode @Transactional.
     * clearAutomatically = true : vide le cache L1 après l'update
     *   (évite d'avoir des entités "stales" en mémoire)
     */
    @Modifying(clearAutomatically = true)
    @Query("""
        UPDATE User u SET u.lastLoginAt = :loginTime, u.failedLoginCount = 0
        WHERE u.id = :id
        """)
    void updateLastLoginAt(@Param("id") Long id, @Param("loginTime") LocalDateTime loginTime);

    @Modifying(clearAutomatically = true)
    @Query("UPDATE User u SET u.status = :status WHERE u.id = :id")
    void updateStatus(@Param("id") Long id, @Param("status") UserStatus status);
}

// ── TaskRepository.java ───────────────────────────────────────────────────
package com.taskflow.backend.repository;

import com.taskflow.backend.entity.Task;
import com.taskflow.backend.entity.TaskPriority;
import com.taskflow.backend.entity.TaskStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

public interface TaskRepository
    extends JpaRepository<Task, Long>, JpaSpecificationExecutor<Task> {

    Optional<Task> findByUuid(UUID uuid);

    /**
     * @EntityGraph : définit quelles relations charger en JOIN FETCH.
     * Plus lisible que d'écrire le JOIN FETCH dans chaque @Query.
     * attributePaths : les chemins de navigation dans le graphe d'objets.
     */
    @EntityGraph(attributePaths = {"project", "assignee", "createdBy", "tags"})
    Optional<Task> findWithDetailsByUuid(UUID uuid);

    // Trouver toutes les tâches d'un projet (avec pagination)
    Page<Task> findByProjectId(Long projectId, Pageable pageable);

    // Tâches par statut pour un projet
    Page<Task> findByProjectIdAndStatus(Long projectId, TaskStatus status, Pageable pageable);

    // Tâches assignées à un utilisateur
    Page<Task> findByAssigneeId(Long userId, Pageable pageable);

    // Tâches en retard
    @Query("""
        SELECT t FROM Task t
        WHERE t.dueDate < :today
          AND t.status NOT IN ('DONE', 'CANCELLED')
          AND t.assignee.id = :userId
        ORDER BY t.dueDate ASC
        """)
    List<Task> findOverdueTasks(@Param("userId") Long userId,
                                 @Param("today") LocalDate today);

    // Comptage par statut pour un projet (pour les statistiques)
    @Query("""
        SELECT t.status, COUNT(t)
        FROM Task t
        WHERE t.project.id = :projectId
        GROUP BY t.status
        """)
    List<Object[]> countByStatusForProject(@Param("projectId") Long projectId);

    // Recherche textuelle dans les tâches d'un projet
    @Query("""
        SELECT t FROM Task t
        WHERE t.project.id = :projectId
          AND (
              LOWER(t.title) LIKE LOWER(CONCAT('%', :search, '%'))
           OR LOWER(t.description) LIKE LOWER(CONCAT('%', :search, '%'))
          )
        """)
    Page<Task> searchInProject(@Param("projectId") Long projectId,
                                @Param("search") String search,
                                Pageable pageable);

    // Sous-tâches d'une tâche parent
    List<Task> findByParentTaskId(Long parentTaskId);

    @Query("""
        SELECT COUNT(t) FROM Task t
        WHERE t.project.id = :projectId AND t.status = :status
        """)
    long countByProjectIdAndStatus(@Param("projectId") Long projectId,
                                    @Param("status") TaskStatus status);
}

// ── ProjectRepository.java ────────────────────────────────────────────────
package com.taskflow.backend.repository;

import com.taskflow.backend.entity.Project;
import com.taskflow.backend.entity.ProjectStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

public interface ProjectRepository extends JpaRepository<Project, Long> {

    Optional<Project> findByUuid(UUID uuid);

    List<Project> findByOwnerIdAndStatus(Long ownerId, ProjectStatus status);

    /**
     * Projets d'un utilisateur : ceux qu'il possède OU dont il est membre.
     */
    @Query("""
        SELECT DISTINCT p FROM Project p
        LEFT JOIN p.members m
        WHERE (p.owner.id = :userId OR m.user.id = :userId)
          AND p.status = :status
        ORDER BY p.createdAt DESC
        """)
    Page<Project> findProjectsForUser(@Param("userId") Long userId,
                                       @Param("status") ProjectStatus status,
                                       Pageable pageable);

    boolean existsByUuid(UUID uuid);
}

════════════════════════════════════════
22.2 SERVICE LAYER — CREATE TASK
════════════════════════════════════════

package com.taskflow.backend.service.impl;

import com.taskflow.backend.dto.request.CreateTaskRequest;
import com.taskflow.backend.dto.response.TaskResponse;
import com.taskflow.backend.entity.*;
import com.taskflow.backend.exception.ResourceNotFoundException;
import com.taskflow.backend.exception.BusinessException;
import com.taskflow.backend.mapper.TaskMapper;
import com.taskflow.backend.repository.*;
import com.taskflow.backend.service.TaskService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class TaskServiceImpl implements TaskService {

    private final TaskRepository     taskRepository;
    private final UserRepository     userRepository;
    private final ProjectRepository  projectRepository;
    private final TagRepository      tagRepository;
    private final TaskMapper         taskMapper;

    /**
     * Crée une nouvelle tâche.
     *
     * Flux :
     * 1. Valider les relations (projet, assignee, tags existent)
     * 2. Construire l'entité Task
     * 3. Persister
     * 4. Mapper vers DTO de réponse
     *
     * @Transactional (sans readOnly=true) : nécessaire pour l'écriture.
     *   readOnly=true est défini au niveau classe pour les lectures,
     *   on override ici pour les écritures.
     */
    @Override
    @Transactional
    public TaskResponse createTask(CreateTaskRequest request, Long createdByUserId) {
        log.debug("Creating task: title='{}', projectId={}, createdBy={}",
            request.getTitle(), request.getProjectId(), createdByUserId);

        // 1. Charger le créateur (must exist — l'utilisateur est authentifié)
        User creator = userRepository.findById(createdByUserId)
            .orElseThrow(() -> new ResourceNotFoundException("User", createdByUserId));

        // 2. Charger le projet si spécifié
        Project project = null;
        if (request.getProjectId() != null) {
            project = projectRepository.findByUuid(request.getProjectId())
                .orElseThrow(() -> new ResourceNotFoundException(
                    "Project", request.getProjectId()));
        }

        // 3. Charger l'assignee si spécifié
        User assignee = null;
        if (request.getAssigneeId() != null) {
            assignee = userRepository.findByUuid(request.getAssigneeId())
                .orElseThrow(() -> new ResourceNotFoundException(
                    "User (assignee)", request.getAssigneeId()));

            // Vérifier que l'assignee est membre du projet si projet défini
            if (project != null) {
                final Long projectId = project.getId();
                final Long assigneeId = assignee.getId();
                boolean isMember = project.getMembers().stream()
                    .anyMatch(m -> m.getUser().getId().equals(assigneeId))
                    || project.getOwner().getId().equals(assigneeId);
                if (!isMember) {
                    throw new BusinessException(
                        "L'assignee n'est pas membre du projet spécifié");
                }
            }
        }

        // 4. Charger la tâche parent si spécifiée
        Task parentTask = null;
        if (request.getParentTaskId() != null) {
            parentTask = taskRepository.findByUuid(request.getParentTaskId())
                .orElseThrow(() -> new ResourceNotFoundException(
                    "Task (parent)", request.getParentTaskId()));
        }

        // 5. Charger les tags si spécifiés
        Set<Tag> tags = Set.of();
        if (request.getTagIds() != null && !request.getTagIds().isEmpty()) {
            tags = request.getTagIds().stream()
                .map(tagId -> tagRepository.findById(tagId)
                    .orElseThrow(() -> new ResourceNotFoundException("Tag", tagId)))
                .collect(Collectors.toSet());
        }

        // 6. Construire la tâche avec le pattern Builder
        Task task = Task.builder()
            .title(request.getTitle().trim())
            .description(request.getDescription())
            .status(TaskStatus.TODO)
            .priority(request.getPriority() != null ? request.getPriority() : TaskPriority.MEDIUM)
            .project(project)
            .createdBy(creator)
            .assignee(assignee)
            .dueDate(request.getDueDate())
            .estimatedHours(request.getEstimatedHours())
            .parentTask(parentTask)
            .build();

        // Ajouter les tags (méthode helper de Task)
        tags.forEach(task::addTag);

        // 7. Sauvegarder (JPA fait l'INSERT + génère l'ID)
        Task saved = taskRepository.save(task);

        log.info("Task created successfully: id={}, uuid={}, title='{}'",
            saved.getId(), saved.getUuid(), saved.getTitle());

        // 8. Mapper vers le DTO de réponse
        return taskMapper.toResponse(saved);
    }

    /**
     * Créer plusieurs tâches en une transaction.
     * saveAll() utilise le batch insert Hibernate (plus efficace que N x save()).
     */
    @Transactional
    public List<TaskResponse> createTasks(List<CreateTaskRequest> requests,
                                           Long createdByUserId) {
        // Pour les bulk inserts, construire toutes les entités puis saveAll()
        List<Task> tasks = requests.stream()
            .map(req -> buildTask(req, createdByUserId))
            .collect(Collectors.toList());

        List<Task> saved = taskRepository.saveAll(tasks);

        return saved.stream()
            .map(taskMapper::toResponse)
            .collect(Collectors.toList());
    }

    private Task buildTask(CreateTaskRequest request, Long userId) {
        User creator = userRepository.getReferenceById(userId);
        // getReferenceById() retourne un proxy SANS faire de SELECT
        // (utilisé quand on a juste besoin de la FK, pas des données)
        return Task.builder()
            .title(request.getTitle())
            .createdBy(creator)
            .build();
    }
}

════════════════════════════════════════
22.3 CONTROLLER LAYER — CREATE ENDPOINTS
════════════════════════════════════════

@RestController
@RequestMapping("/api/v1/tasks")
@RequiredArgsConstructor
@Slf4j
public class TaskController {

    private final TaskService taskService;

    /**
     * POST /api/v1/tasks
     * Créer une nouvelle tâche.
     *
     * @RequestBody : désérialise le JSON de la requête vers CreateTaskRequest
     * @Valid : déclenche la validation Jakarta Validation
     * @AuthenticationPrincipal : injecte l'utilisateur connecté
     * ResponseEntity.status(HttpStatus.CREATED) : HTTP 201 Created
     */
    @PostMapping
    public ResponseEntity<ApiResponse<TaskResponse>> createTask(
            @Valid @RequestBody CreateTaskRequest request,
            @AuthenticationPrincipal UserPrincipal currentUser) {

        TaskResponse task = taskService.createTask(request, currentUser.getId());

        return ResponseEntity
            .status(HttpStatus.CREATED)
            .body(ApiResponse.success("Tâche créée avec succès", task));
    }

    /**
     * POST /api/v1/tasks/bulk
     * Créer plusieurs tâches en une seule requête.
     */
    @PostMapping("/bulk")
    public ResponseEntity<ApiResponse<List<TaskResponse>>> createTasks(
            @Valid @RequestBody BulkCreateTaskRequest request,
            @AuthenticationPrincipal UserPrincipal currentUser) {

        List<TaskResponse> tasks =
            taskService.createTasks(request.getTasks(), currentUser.getId());

        return ResponseEntity
            .status(HttpStatus.CREATED)
            .body(ApiResponse.success(
                tasks.size() + " tâches créées avec succès", tasks));
    }
}

════════════════════════════════════════
22.4 DTO REQUEST — CreateTaskRequest
════════════════════════════════════════

package com.taskflow.backend.dto.request;

import com.taskflow.backend.entity.TaskPriority;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Set;
import java.util.UUID;

@Data
public class CreateTaskRequest {

    @NotBlank(message = "Le titre est obligatoire")
    @Size(min = 3, max = 500, message = "Le titre doit avoir entre 3 et 500 caractères")
    private String title;

    @Size(max = 10000, message = "La description ne peut pas dépasser 10000 caractères")
    private String description;

    private UUID projectId;  // Nullable

    private UUID assigneeId;  // Nullable

    private TaskPriority priority;  // Nullable -> défaut MEDIUM dans le service

    @FutureOrPresent(message = "La date d'échéance doit être présente ou future")
    private LocalDate dueDate;  // Nullable

    @DecimalMin(value = "0.1", message = "L'estimation doit être positive")
    @DecimalMax(value = "9999.99", message = "L'estimation ne peut pas dépasser 9999.99h")
    private BigDecimal estimatedHours;  // Nullable

    private UUID parentTaskId;  // Nullable (pour les sous-tâches)

    @Size(max = 10, message = "Maximum 10 tags par tâche")
    private Set<Long> tagIds;  // Nullable
}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 23 — READ (LIRE DES DONNÉES)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

════════════════════════════════════════
23.1 REQUÊTES DE LECTURE
════════════════════════════════════════

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class TaskServiceImpl implements TaskService {

    /**
     * Récupérer une tâche par son UUID public.
     * On utilise UUID (et non ID séquentiel) pour l'API publique.
     */
    @Override
    public TaskResponse getTaskByUuid(UUID uuid, Long currentUserId) {
        // Charge la tâche avec ses relations en une seule requête (JOIN FETCH)
        Task task = taskRepository.findWithDetailsByUuid(uuid)
            .orElseThrow(() -> new ResourceNotFoundException("Task", uuid));

        // Vérifier les droits d'accès
        verifyTaskReadAccess(task, currentUserId);

        return taskMapper.toResponse(task);
    }

    /**
     * Lister les tâches d'un projet avec pagination.
     */
    @Override
    public Page<TaskSummaryResponse> getProjectTasks(
            UUID projectUuid,
            TaskFilterRequest filter,
            Pageable pageable,
            Long currentUserId) {

        Project project = projectRepository.findByUuid(projectUuid)
            .orElseThrow(() -> new ResourceNotFoundException("Project", projectUuid));

        verifyProjectReadAccess(project, currentUserId);

        // Utilisation des Specifications pour les filtres dynamiques
        Specification<Task> spec = TaskSpecifications.forProject(project.getId())
            .and(TaskSpecifications.withStatus(filter.getStatus()))
            .and(TaskSpecifications.withPriority(filter.getPriority()))
            .and(TaskSpecifications.withAssignee(filter.getAssigneeId()))
            .and(TaskSpecifications.searchByTitle(filter.getSearch()));

        Page<Task> tasks = taskRepository.findAll(spec, pageable);

        return tasks.map(taskMapper::toSummaryResponse);
    }

    /**
     * Statistiques d'un projet.
     */
    @Override
    public ProjectStatsResponse getProjectStats(UUID projectUuid, Long currentUserId) {
        Project project = projectRepository.findByUuid(projectUuid)
            .orElseThrow(() -> new ResourceNotFoundException("Project", projectUuid));

        verifyProjectReadAccess(project, currentUserId);

        // Comptage par statut depuis la BD (pas de chargement des entités)
        List<Object[]> counts = taskRepository.countByStatusForProject(project.getId());

        Map<TaskStatus, Long> countByStatus = counts.stream()
            .collect(Collectors.toMap(
                row -> (TaskStatus) row[0],
                row -> (Long) row[1]
            ));

        long total = countByStatus.values().stream().mapToLong(Long::longValue).sum();
        long done  = countByStatus.getOrDefault(TaskStatus.DONE, 0L);

        return ProjectStatsResponse.builder()
            .totalTasks(total)
            .completedTasks(done)
            .progressPercentage(total > 0 ? (double) done / total * 100 : 0)
            .tasksByStatus(countByStatus)
            .build();
    }

    private void verifyTaskReadAccess(Task task, Long userId) {
        // Un utilisateur peut lire une tâche si :
        // - Il est le créateur
        // - Il est l'assignee
        // - Il est membre du projet
        // - Il est admin
        boolean hasAccess = task.getCreatedBy().getId().equals(userId)
            || (task.getAssignee() != null && task.getAssignee().getId().equals(userId))
            || (task.getProject() != null && isProjectMember(task.getProject(), userId));

        if (!hasAccess) {
            throw new AccessDeniedException("Accès non autorisé à cette tâche");
        }
    }

    private boolean isProjectMember(Project project, Long userId) {
        return project.getOwner().getId().equals(userId)
            || project.getMembers().stream()
                .anyMatch(m -> m.getUser().getId().equals(userId));
    }
}

════════════════════════════════════════
23.2 SPECIFICATIONS POUR FILTRES DYNAMIQUES
════════════════════════════════════════

Les Specifications permettent de construire des requêtes dynamiques de façon
type-safe sans concatener des String SQL.

package com.taskflow.backend.repository.spec;

import com.taskflow.backend.entity.*;
import org.springframework.data.jpa.domain.Specification;

/**
 * Classe utilitaire pour les Specifications de Task.
 * Chaque méthode retourne une Specification qu'on peut combiner avec .and() / .or().
 */
public class TaskSpecifications {

    // Privé : classe utilitaire, pas d'instanciation
    private TaskSpecifications() {}

    /**
     * Filtre par projet.
     * (root, query, cb) -> le lambda de la Specification :
     *   - root   : la racine de la requête (alias de l'entité Task)
     *   - query  : la CriteriaQuery (pour configurer DISTINCT, ORDER BY...)
     *   - cb     : CriteriaBuilder (pour construire les conditions)
     */
    public static Specification<Task> forProject(Long projectId) {
        return (root, query, cb) ->
            cb.equal(root.get("project").get("id"), projectId);
    }

    /**
     * Filtre par statut (null -> pas de filtre).
     */
    public static Specification<Task> withStatus(TaskStatus status) {
        return (root, query, cb) ->
            status == null ? cb.conjunction()  // "WHERE 1=1" -> pas de filtre
                           : cb.equal(root.get("status"), status);
    }

    public static Specification<Task> withPriority(TaskPriority priority) {
        return (root, query, cb) ->
            priority == null ? cb.conjunction()
                             : cb.equal(root.get("priority"), priority);
    }

    public static Specification<Task> withAssignee(Long assigneeId) {
        return (root, query, cb) ->
            assigneeId == null ? cb.conjunction()
                               : cb.equal(root.get("assignee").get("id"), assigneeId);
    }

    public static Specification<Task> searchByTitle(String search) {
        return (root, query, cb) ->
            (search == null || search.isBlank()) ? cb.conjunction()
                : cb.like(cb.lower(root.get("title")),
                           "%" + search.toLowerCase() + "%");
    }

    /**
     * Tâches en retard.
     */
    public static Specification<Task> isOverdue() {
        return (root, query, cb) -> cb.and(
            cb.lessThan(root.get("dueDate"),
                        java.time.LocalDate.now()),
            root.get("status").in(
                TaskStatus.TODO, TaskStatus.IN_PROGRESS, TaskStatus.BLOCKED)
        );
    }

    /**
     * Tâches dont le titre OU la description contient le terme de recherche.
     */
    public static Specification<Task> fullTextSearch(String search) {
        if (search == null || search.isBlank()) {
            return (root, query, cb) -> cb.conjunction();
        }
        String pattern = "%" + search.toLowerCase() + "%";
        return (root, query, cb) -> cb.or(
            cb.like(cb.lower(root.get("title")), pattern),
            cb.like(cb.lower(root.get("description")), pattern)
        );
    }
}

════════════════════════════════════════
23.3 CONTROLLER — READ ENDPOINTS
════════════════════════════════════════

@RestController
@RequestMapping("/api/v1/tasks")
@RequiredArgsConstructor
public class TaskController {

    private final TaskService taskService;

    /**
     * GET /api/v1/tasks/{uuid}
     */
    @GetMapping("/{uuid}")
    public ResponseEntity<ApiResponse<TaskResponse>> getTask(
            @PathVariable UUID uuid,
            @AuthenticationPrincipal UserPrincipal currentUser) {

        TaskResponse task = taskService.getTaskByUuid(uuid, currentUser.getId());

        return ResponseEntity.ok(ApiResponse.success(task));
    }

    /**
     * GET /api/v1/projects/{projectUuid}/tasks?status=TODO&priority=HIGH&page=0&size=20&sort=createdAt,desc
     */
    @GetMapping("/projects/{projectUuid}/tasks")
    public ResponseEntity<ApiResponse<Page<TaskSummaryResponse>>> getProjectTasks(
            @PathVariable UUID projectUuid,
            @ModelAttribute TaskFilterRequest filter,
            @PageableDefault(size = 20, sort = "createdAt",
                             direction = Sort.Direction.DESC) Pageable pageable,
            @AuthenticationPrincipal UserPrincipal currentUser) {

        Page<TaskSummaryResponse> tasks =
            taskService.getProjectTasks(projectUuid, filter, pageable, currentUser.getId());

        return ResponseEntity.ok(ApiResponse.success(tasks));
    }

    /**
     * GET /api/v1/tasks/my?status=IN_PROGRESS&page=0&size=10
     * Tâches assignées à l'utilisateur connecté.
     */
    @GetMapping("/my")
    public ResponseEntity<ApiResponse<Page<TaskSummaryResponse>>> getMyTasks(
            @RequestParam(required = false) TaskStatus status,
            @PageableDefault(size = 10) Pageable pageable,
            @AuthenticationPrincipal UserPrincipal currentUser) {

        Page<TaskSummaryResponse> tasks =
            taskService.getMyTasks(currentUser.getId(), status, pageable);

        return ResponseEntity.ok(ApiResponse.success(tasks));
    }
}

════════════════════════════════════════
23.4 MAPPER AVEC MAPSTRUCT
════════════════════════════════════════

package com.taskflow.backend.mapper;

import com.taskflow.backend.dto.response.TaskResponse;
import com.taskflow.backend.dto.response.TaskSummaryResponse;
import com.taskflow.backend.entity.Task;
import org.mapstruct.*;

/**
 * @Mapper : indique à MapStruct de générer l'implémentation.
 *
 * componentModel = "spring" : le mapper généré est un @Component Spring
 *   -> peut être injecté par @Autowired / @RequiredArgsConstructor
 *
 * nullValuePropertyMappingStrategy :
 *   IGNORE -> si la source est null, ne pas écraser la valeur de destination
 *   (utile pour les mises à jour partielles)
 */
@Mapper(
    componentModel = "spring",
    nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
)
public interface TaskMapper {

    /**
     * Mapping de Task -> TaskResponse.
     *
     * @Mapping : configure le mapping d'un champ spécifique.
     *   source : champ dans l'entité source
     *   target : champ dans le DTO cible
     *
     * Mappings automatiques (même nom + type compatible) :
     *   task.id -> response.id
     *   task.uuid -> response.uuid
     *   task.title -> response.title
     *   task.description -> response.description
     *   task.status -> response.status
     *   task.priority -> response.priority
     *   task.dueDate -> response.dueDate
     *   task.createdAt -> response.createdAt
     *   task.updatedAt -> response.updatedAt
     */
    @Mapping(source = "project.uuid",           target = "projectId")
    @Mapping(source = "project.name",           target = "projectName")
    @Mapping(source = "createdBy.uuid",         target = "createdById")
    @Mapping(source = "createdBy.displayName",  target = "createdByName")
    @Mapping(source = "assignee.uuid",          target = "assigneeId")
    @Mapping(source = "assignee.displayName",   target = "assigneeName")
    @Mapping(source = "assignee.avatarUrl",     target = "assigneeAvatarUrl")
    TaskResponse toResponse(Task task);

    /**
     * Résumé allégé (pour les listes paginées).
     */
    @Mapping(source = "project.uuid",           target = "projectId")
    @Mapping(source = "assignee.uuid",          target = "assigneeId")
    @Mapping(source = "assignee.displayName",   target = "assigneeName")
    TaskSummaryResponse toSummaryResponse(Task task);

    /**
     * Mise à jour partielle d'une entité Task depuis un DTO.
     * @MappingTarget : le paramètre est modifié (pas de nouvelle instance créée).
     * NullValuePropertyMappingStrategy.IGNORE : les champs null dans le DTO
     *   ne remplacent PAS les valeurs existantes dans l'entité.
     */
    @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
    void updateTaskFromRequest(UpdateTaskRequest request, @MappingTarget Task task);
}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 24 — UPDATE (MODIFIER DES DONNÉES)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

════════════════════════════════════════
24.1 PUT VS PATCH : QUELLE DIFFÉRENCE ?
════════════════════════════════════════

PUT : remplacement COMPLET
  - Le client envoie TOUS les champs de l'entité
  - Les champs manquants sont mis à null ou leur valeur par défaut
  - Idempotent : PUT(x) + PUT(x) = PUT(x)
  - Exemple : PUT /api/v1/tasks/uuid -> remplace toute la tâche

PATCH : mise à jour PARTIELLE
  - Le client envoie uniquement les champs à modifier
  - Les autres champs conservent leur valeur actuelle
  - Idempotent (si bien implémenté)
  - Exemple : PATCH /api/v1/tasks/uuid -> { "status": "DONE" }

Dans TaskFlow, on utilise principalement PATCH pour les mises à jour partielles.

════════════════════════════════════════
24.2 IMPLÉMENTATION UPDATE
════════════════════════════════════════

// ── Service Layer ─────────────────────────────────────────────────────────

@Transactional
public TaskResponse updateTask(UUID uuid, UpdateTaskRequest request, Long currentUserId) {
    // 1. Charger la tâche (avec ses relations nécessaires)
    Task task = taskRepository.findByUuid(uuid)
        .orElseThrow(() -> new ResourceNotFoundException("Task", uuid));

    // 2. Vérifier les droits de modification
    verifyTaskWriteAccess(task, currentUserId);

    // 3. Validation métier (avant de modifier l'entité)

    // Valider la transition de statut si demandée
    if (request.getStatus() != null && request.getStatus() != task.getStatus()) {
        if (!task.getStatus().canTransitionTo(request.getStatus())) {
            throw new BusinessException(
                String.format("Transition de statut invalide : %s -> %s",
                    task.getStatus(), request.getStatus()));
        }
    }

    // 4. Appliquer les modifications via MapStruct (ignore les nulls)
    taskMapper.updateTaskFromRequest(request, task);

    // Si un nouvel assignee est spécifié
    if (request.getAssigneeId() != null) {
        User newAssignee = userRepository.findByUuid(request.getAssigneeId())
            .orElseThrow(() -> new ResourceNotFoundException(
                "User (assignee)", request.getAssigneeId()));
        task.setAssignee(newAssignee);
    } else if (request.isRemoveAssignee()) {
        // Désassigner explicitement
        task.setAssignee(null);
    }

    // 5. Dirty Checking Hibernate : pas besoin d'appeler save() !
    //    Au commit, Hibernate détecte les changements et génère l'UPDATE.
    //    Cependant, appeler save() est aussi correct et plus explicite.
    Task updated = taskRepository.save(task);

    log.info("Task updated: uuid={}, updatedBy={}", uuid, currentUserId);

    return taskMapper.toResponse(updated);
}

/**
 * Changer uniquement le statut d'une tâche.
 * Méthode spécialisée plus performante que updateTask() pour cette opération.
 */
@Transactional
public TaskResponse changeTaskStatus(UUID uuid, TaskStatus newStatus, Long userId) {
    Task task = taskRepository.findByUuid(uuid)
        .orElseThrow(() -> new ResourceNotFoundException("Task", uuid));

    verifyTaskWriteAccess(task, userId);

    if (!task.getStatus().canTransitionTo(newStatus)) {
        throw new BusinessException(
            "Transition de statut invalide : " + task.getStatus() + " -> " + newStatus);
    }

    task.setStatus(newStatus);

    // Si la tâche passe à DONE, enregistrer l'heure de complétion
    if (newStatus == TaskStatus.DONE) {
        task.setActualHours(
            task.getEstimatedHours() != null ? task.getEstimatedHours()
                                             : java.math.BigDecimal.ZERO
        );
    }

    return taskMapper.toResponse(taskRepository.save(task));
}

/**
 * Réordonner les tâches dans un projet (drag & drop).
 * Met à jour le champ "position" de plusieurs tâches en une transaction.
 */
@Transactional
public void reorderTasks(UUID projectUuid, List<TaskPositionRequest> positions,
                          Long userId) {
    Project project = projectRepository.findByUuid(projectUuid)
        .orElseThrow(() -> new ResourceNotFoundException("Project", projectUuid));

    verifyProjectWriteAccess(project, userId);

    // Pour chaque tâche, mettre à jour sa position
    for (TaskPositionRequest pos : positions) {
        Task task = taskRepository.findByUuid(pos.getTaskId())
            .orElseThrow(() -> new ResourceNotFoundException("Task", pos.getTaskId()));
        task.setPosition(pos.getPosition());
    }
    // Dirty checking -> Hibernate génère N UPDATE statements
    // Pour de gros volumes, préférer @Modifying @Query UPDATE bulk
}

// ── UpdateTaskRequest ─────────────────────────────────────────────────────

@Data
public class UpdateTaskRequest {

    @Size(min = 3, max = 500)
    private String title;  // Nullable -> non modifié

    @Size(max = 10000)
    private String description;

    private TaskStatus status;

    private TaskPriority priority;

    private UUID assigneeId;

    private boolean removeAssignee = false;

    @FutureOrPresent
    private LocalDate dueDate;

    @DecimalMin("0.1")
    private BigDecimal estimatedHours;

    private BigDecimal actualHours;
}

// ── Controller PATCH ──────────────────────────────────────────────────────

@PatchMapping("/{uuid}")
public ResponseEntity<ApiResponse<TaskResponse>> updateTask(
        @PathVariable UUID uuid,
        @Valid @RequestBody UpdateTaskRequest request,
        @AuthenticationPrincipal UserPrincipal currentUser) {

    TaskResponse task = taskService.updateTask(uuid, request, currentUser.getId());
    return ResponseEntity.ok(ApiResponse.success("Tâche mise à jour", task));
}

@PatchMapping("/{uuid}/status")
public ResponseEntity<ApiResponse<TaskResponse>> changeStatus(
        @PathVariable UUID uuid,
        @RequestBody @Valid ChangeStatusRequest request,
        @AuthenticationPrincipal UserPrincipal currentUser) {

    TaskResponse task = taskService.changeTaskStatus(
        uuid, request.getStatus(), currentUser.getId());
    return ResponseEntity.ok(ApiResponse.success("Statut mis à jour", task));
}

@PutMapping("/projects/{projectUuid}/reorder")
public ResponseEntity<ApiResponse<Void>> reorderTasks(
        @PathVariable UUID projectUuid,
        @RequestBody @Valid List<TaskPositionRequest> positions,
        @AuthenticationPrincipal UserPrincipal currentUser) {

    taskService.reorderTasks(projectUuid, positions, currentUser.getId());
    return ResponseEntity.ok(ApiResponse.success("Tâches réordonnées", null));
}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 25 — DELETE (SUPPRIMER DES DONNÉES)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

════════════════════════════════════════
25.1 HARD DELETE VS SOFT DELETE
════════════════════════════════════════

Hard Delete (suppression physique) :
  DELETE FROM tasks WHERE id = ?
  [OK] Simple
  [X] Irréversible
  [X] Brise les références (commentaires, historique)
  [X] Impossible de récupérer les données

Soft Delete (suppression logique) :
  UPDATE tasks SET deleted_at = NOW(), deleted_by = ? WHERE id = ?
  [OK] Réversible
  [OK] Conserve l'historique
  [OK] Respecte les relations
  [X] Toutes les requêtes doivent filtrer WHERE deleted_at IS NULL
  [X] Les tables grossissent avec le temps

Pour TaskFlow, on implémente le SOFT DELETE sur les tâches et projets.

════════════════════════════════════════
25.2 SOFT DELETE AVEC HIBERNATE
════════════════════════════════════════

Hibernate offre @SQLRestriction (Hibernate 6.3+) et @Where (deprecated) pour
ajouter automatiquement une condition sur toutes les requêtes.

// Migration V3 : ajout soft delete
// V3__add_soft_delete.sql
ALTER TABLE tasks    ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE tasks    ADD COLUMN deleted_by BIGINT REFERENCES users(id);
ALTER TABLE projects ADD COLUMN deleted_at TIMESTAMP;
ALTER TABLE projects ADD COLUMN deleted_by BIGINT REFERENCES users(id);

// Dans l'entité Task, ajouter :
@Column(name = "deleted_at")
private LocalDateTime deletedAt;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "deleted_by")
private User deletedBy;

// Annotation Hibernate pour filtrer automatiquement les entités supprimées
// Sur TOUTES les requêtes via ce repository, "deleted_at IS NULL" est ajouté
@SQLRestriction("deleted_at IS NULL")
@Entity
@Table(name = "tasks")
public class Task extends BaseEntity {
    // ...
}

════════════════════════════════════════
25.3 IMPLÉMENTATION DELETE DANS LE SERVICE
════════════════════════════════════════

@Transactional
public void deleteTask(UUID uuid, Long userId) {
    Task task = taskRepository.findByUuid(uuid)
        .orElseThrow(() -> new ResourceNotFoundException("Task", uuid));

    // Vérifier que l'utilisateur peut supprimer cette tâche
    // (créateur ou admin du projet)
    verifyTaskDeleteAccess(task, userId);

    // Soft delete
    User deleter = userRepository.getReferenceById(userId);
    task.setDeletedAt(LocalDateTime.now());
    task.setDeletedBy(deleter);

    taskRepository.save(task);

    log.info("Task soft-deleted: uuid={}, deletedBy={}", uuid, userId);
}

@Transactional
public void restoreTask(UUID uuid, Long userId) {
    // Pour récupérer une tâche soft-deleted, il faut bypasser le filtre @SQLRestriction
    // -> Utiliser une @Query native
    Task task = taskRepository.findDeletedByUuid(uuid)
        .orElseThrow(() -> new ResourceNotFoundException("Deleted Task", uuid));

    verifyTaskDeleteAccess(task, userId);

    task.setDeletedAt(null);
    task.setDeletedBy(null);

    taskRepository.save(task);
    log.info("Task restored: uuid={}", uuid);
}

/**
 * Purge définitive des tâches supprimées depuis plus de 90 jours.
 * Peut être planifié avec @Scheduled.
 */
@Transactional
public int purgeOldDeletedTasks() {
    LocalDateTime cutoff = LocalDateTime.now().minusDays(90);
    int count = taskRepository.hardDeleteBeforeDate(cutoff);
    log.info("Purged {} old deleted tasks (older than {})", count, cutoff);
    return count;
}

// ── Dans TaskRepository : requête pour bypasser le soft delete filter ─────

/**
 * @SQLRestriction est au niveau de l'entité, donc toutes les requêtes JPQL
 * sont automatiquement filtrées. Pour lire les entités supprimées, on utilise
 * une requête SQL native qui ignore ce filtre.
 */
@Query(
    value = "SELECT * FROM tasks WHERE uuid = :uuid AND deleted_at IS NOT NULL",
    nativeQuery = true
)
Optional<Task> findDeletedByUuid(@Param("uuid") UUID uuid);

@Modifying
@Query(
    value = "DELETE FROM tasks WHERE deleted_at < :cutoff",
    nativeQuery = true
)
int hardDeleteBeforeDate(@Param("cutoff") LocalDateTime cutoff);

// ── Controller DELETE ─────────────────────────────────────────────────────

@DeleteMapping("/{uuid}")
public ResponseEntity<ApiResponse<Void>> deleteTask(
        @PathVariable UUID uuid,
        @AuthenticationPrincipal UserPrincipal currentUser) {

    taskService.deleteTask(uuid, currentUser.getId());
    return ResponseEntity.ok(ApiResponse.success("Tâche supprimée", null));
    // HTTP 200 OK avec message (ou 204 No Content sans body)
}

@PostMapping("/{uuid}/restore")
@PreAuthorize("hasRole('ADMIN') or @taskSecurity.isTaskOwner(#uuid, authentication)")
public ResponseEntity<ApiResponse<Void>> restoreTask(
        @PathVariable UUID uuid,
        @AuthenticationPrincipal UserPrincipal currentUser) {

    taskService.restoreTask(uuid, currentUser.getId());
    return ResponseEntity.ok(ApiResponse.success("Tâche restaurée", null));
}

════════════════════════════════════════
25.4 SUPPRESSION EN CASCADE
════════════════════════════════════════

Quand on supprime un projet, que faire des tâches ?

Option 1 : ON DELETE CASCADE (niveau SQL)
  Défini dans la migration : REFERENCES projects(id) ON DELETE CASCADE
  -> Suppression automatique et rapide
  -> Pas d'événements Spring (JPA ne voit pas ces suppressions)
  -> Difficile à auditer

Option 2 : CascadeType.REMOVE (niveau JPA)
  @OneToMany(cascade = CascadeType.REMOVE)
  -> Hibernate charge et supprime chaque entité enfant
  -> Déclenche les événements @PreRemove
  -> TRÈS LENT pour de grandes collections (N SELECT + N DELETE)

Option 3 : Service qui supprime explicitement
  @Transactional
  public void deleteProject(UUID projectUuid, Long userId) {
      Project project = ...;

      // 1. Soft-delete toutes les tâches du projet en une requête
      taskRepository.softDeleteByProjectId(project.getId(), userId, LocalDateTime.now());

      // 2. Soft-delete le projet
      project.setDeletedAt(LocalDateTime.now());
      projectRepository.save(project);
  }

  @Modifying
  @Query("""
      UPDATE Task t
      SET t.deletedAt = :deletedAt, t.deletedBy.id = :userId
      WHERE t.project.id = :projectId AND t.deletedAt IS NULL
      """)
  void softDeleteByProjectId(@Param("projectId") Long projectId,
                               @Param("userId") Long userId,
                               @Param("deletedAt") LocalDateTime deletedAt);

-> Option 3 est la plus recommandée en production

════════════════════════════════════════
25.5 EXERCICES CHAPITRE 22-25
════════════════════════════════════════

EXERCICE 1 (Facile) — Repository
Ajoutez dans TaskRepository les méthodes :
  - findByProjectIdAndPriority(Long projectId, TaskPriority priority)
  - countByAssigneeId(Long userId)
  - findByDueDateBefore(LocalDate date)

EXERCICE 2 (Facile) — CRUD Commentaires
Implémentez les 4 opérations CRUD pour l'entité Comment :
  - POST /api/v1/tasks/{uuid}/comments
  - GET  /api/v1/tasks/{uuid}/comments
  - PUT  /api/v1/comments/{id}
  - DELETE /api/v1/comments/{id}

EXERCICE 3 (Intermédiaire) — Specifications
Créez une Specification combinée qui filtre les tâches :
  - Dans un projet donné
  - En retard (dueDate < today)
  - Avec une priorité donnée (ou toutes si null)
  - Non supprimées

EXERCICE 4 (Intermédiaire) — Projection
Créez une interface de projection Spring Data pour retourner uniquement :
  id, uuid, title, status de Task (sans charger toutes les relations).

  public interface TaskProjection {
      Long getId();
      UUID getUuid();
      String getTitle();
      TaskStatus getStatus();
  }

  List<TaskProjection> findByProjectId(Long projectId);

EXERCICE 5 (Avancé) — Service Projet
Implémentez ProjectService avec :
  - createProject(CreateProjectRequest, Long userId)
  - addMember(UUID projectUuid, UUID userUuid, ProjectMemberRole role, Long ownerId)
  - removeMember(UUID projectUuid, UUID userUuid, Long ownerId)
  - archiveProject(UUID projectUuid, Long ownerId) : soft-delete du projet

EXERCICE 6 (Avancé) — Audit Trail
Créez une entité TaskAuditLog qui enregistre chaque modification de tâche :
  - taskId, fieldName, oldValue, newValue, changedBy, changedAt
  Ajoutez la création automatique de ces logs dans TaskServiceImpl.

════════════════════════════════════════
25.6 CORRIGÉS CHAPITRE 22-25
════════════════════════════════════════

CORRIGÉ 1 :

// Dans TaskRepository.java :

List<Task> findByProjectIdAndPriority(Long projectId, TaskPriority priority);

long countByAssigneeId(Long userId);

List<Task> findByDueDateBefore(LocalDate date);

CORRIGÉ 4 — Interface Projection :

// Dans TaskRepository.java :
public interface TaskProjection {
    Long getId();
    UUID getUuid();
    String getTitle();
    TaskStatus getStatus();
    // Les méthodes doivent correspondre EXACTEMENT au nom des champs de l'entité
}

// Méthode du repository :
List<TaskProjection> findByProjectId(Long projectId);

// Avantage : Hibernate génère SELECT id, uuid, title, status FROM tasks
// au lieu de SELECT * FROM tasks (moins de données transférées)

CORRIGÉ 5 — addMember :

@Transactional
public void addMember(UUID projectUuid, UUID userUuid,
                       ProjectMemberRole role, Long ownerId) {
    Project project = projectRepository.findByUuid(projectUuid)
        .orElseThrow(() -> new ResourceNotFoundException("Project", projectUuid));

    // Seul le propriétaire peut ajouter des membres
    if (!project.getOwner().getId().equals(ownerId)) {
        throw new AccessDeniedException("Seul le propriétaire peut ajouter des membres");
    }

    User user = userRepository.findByUuid(userUuid)
        .orElseThrow(() -> new ResourceNotFoundException("User", userUuid));

    // Vérifier si déjà membre
    boolean alreadyMember = project.getMembers().stream()
        .anyMatch(m -> m.getUser().getId().equals(user.getId()));
    if (alreadyMember) {
        throw new BusinessException("Cet utilisateur est déjà membre du projet");
    }

    ProjectMember member = ProjectMember.builder()
        .project(project)
        .user(user)
        .role(role)
        .build();

    project.addMember(member);
    // orphanRemoval + CascadeType.ALL sur la collection -> member est sauvegardé
}

================================================================================
   FIN PARTIE 6 — Prochaine partie : API REST avancée (DTO, Pagination, Filtres)
================================================================================

================================================================================
   GUIDE SPRING BOOT MASTER — PARTIE 7
   API REST AVANCÉE : DTO, PAGINATION & FILTRES AVANCÉS
   Chapitres 26 à 28
   Projet fil rouge : TaskFlow Backend
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 26 — DTO : DATA TRANSFER OBJECTS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

════════════════════════════════════════
26.1 POURQUOI LES DTO ?
════════════════════════════════════════

Un DTO (Data Transfer Object) est un objet qui transporte des données entre
les couches de l'application, notamment entre le Controller et le client HTTP.

PROBLÈMES avec l'exposition directe des entités JPA :

1. SÉCURITÉ : les entités contiennent des champs sensibles
   User entity -> passwordHash, failedLoginCount, lockedUntil
   -> Un oubli de @JsonIgnore expose le hash en prod !

2. CYCLES DE SÉRIALISATION :
   Task -> Project -> tasks -> Task -> ...
   -> StackOverflowError ou boucle infinie Jackson !

3. COUPLAGE API/BD :
   Si on renomme une colonne en BD, l'API change aussi
   -> Rupture de contrat avec les clients

4. SURCHARGE DE DONNÉES :
   GET /tasks renvoie les 50 champs de Task + User + Project...
   -> Trop de données, lenteur côté client

5. FLEXIBILITÉ :
   Un endpoint peut avoir besoin d'un sous-ensemble de champs
   ou de données agrégées qui n'existent pas dans l'entité

SOLUTION : DTO séparés par cas d'usage (Request / Response)

Architecture DTO dans TaskFlow :

  dto/
    request/
      CreateTaskRequest.java      <- Données reçues pour créer
      UpdateTaskRequest.java      <- Données reçues pour modifier
      CreateProjectRequest.java
      RegisterRequest.java
      LoginRequest.java
      ...
    response/
      TaskResponse.java           <- Données renvoyées (détail)
      TaskSummaryResponse.java    <- Données renvoyées (liste)
      UserResponse.java
      ProjectResponse.java
      AuthTokenResponse.java
      ...

════════════════════════════════════════
26.2 DTO DE RÉPONSE COMPLETS
════════════════════════════════════════

// ── TaskResponse.java (vue détaillée) ─────────────────────────────────────
package com.taskflow.backend.dto.response;

import com.taskflow.backend.entity.TaskPriority;
import com.taskflow.backend.entity.TaskStatus;
import lombok.Data;
import lombok.Builder;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import java.util.UUID;

/**
 * DTO de réponse complète pour une tâche.
 * Utilisé pour GET /tasks/{uuid}.
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class TaskResponse {
    private Long          id;
    private UUID          uuid;
    private String        title;
    private String        description;
    private TaskStatus    status;
    private TaskPriority  priority;
    private boolean       overdue;

    // Projet (info minimale)
    private UUID   projectId;
    private String projectName;

    // Créateur (info minimale)
    private UUID   createdById;
    private String createdByName;

    // Assignee (info minimale)
    private UUID   assigneeId;
    private String assigneeName;
    private String assigneeAvatarUrl;

    // Dates
    private LocalDate     dueDate;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    // Estimations
    private BigDecimal estimatedHours;
    private BigDecimal actualHours;
    private Integer    position;

    // Tâche parent
    private UUID   parentTaskId;
    private String parentTaskTitle;

    // Sous-tâches (résumé)
    private List<TaskSummaryResponse> subTasks;

    // Tags
    private Set<TagResponse> tags;

    // Commentaires récents (3 derniers)
    private List<CommentResponse> recentComments;
    private int                   totalComments;

    // Pièces jointes
    private List<AttachmentResponse> attachments;

    // Statistiques
    private int completedSubTasks;
    private int totalSubTasks;
}

// ── TaskSummaryResponse.java (vue liste) ──────────────────────────────────
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class TaskSummaryResponse {
    private UUID          uuid;
    private String        title;
    private TaskStatus    status;
    private TaskPriority  priority;
    private boolean       overdue;
    private UUID          projectId;
    private UUID          assigneeId;
    private String        assigneeName;
    private String        assigneeAvatarUrl;
    private LocalDate     dueDate;
    private int           totalComments;
    private int           totalSubTasks;
    private LocalDateTime updatedAt;
    private Set<TagResponse> tags;
}

// ── UserResponse.java ─────────────────────────────────────────────────────
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class UserResponse {
    private UUID      uuid;
    private String    email;
    private String    firstName;
    private String    lastName;
    private String    displayName;
    private String    avatarUrl;
    private UserRole  role;
    private UserStatus status;
    private boolean   emailVerified;
    private LocalDateTime createdAt;
    private LocalDateTime lastLoginAt;
}

// ── UserMiniResponse.java (référence minimale) ────────────────────────────
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class UserMiniResponse {
    private UUID   uuid;
    private String displayName;
    private String avatarUrl;
    private String email;
}

// ── ProjectResponse.java ──────────────────────────────────────────────────
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class ProjectResponse {
    private UUID          uuid;
    private String        name;
    private String        description;
    private String        color;
    private String        icon;
    private ProjectStatus status;
    private UserMiniResponse owner;
    private int           memberCount;
    private int           totalTasks;
    private int           completedTasks;
    private double        progressPercentage;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}

// ── AuthTokenResponse.java ────────────────────────────────────────────────
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class AuthTokenResponse {
    private String       accessToken;
    private String       tokenType;       // "Bearer"
    private long         expiresIn;       // secondes
    private UserResponse user;
    // Note: refreshToken envoyé en cookie HttpOnly (pas dans le body)
}

// ── TagResponse.java ──────────────────────────────────────────────────────
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class TagResponse {
    private Long   id;
    private String name;
    private String color;
}

// ── CommentResponse.java ──────────────────────────────────────────────────
@Data @Builder @NoArgsConstructor @AllArgsConstructor
public class CommentResponse {
    private Long             id;
    private String           content;
    private UserMiniResponse author;
    private boolean          edited;
    private LocalDateTime    createdAt;
    private LocalDateTime    updatedAt;
}

// ── ApiResponse.java (enveloppe générique) ────────────────────────────────
package com.taskflow.backend.dto.response;

import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;

/**
 * Enveloppe standard pour toutes les réponses de l'API.
 *
 * Exemple succès :
 * {
 *   "success": true,
 *   "message": "Tâche créée avec succès",
 *   "data": { ... },
 *   "timestamp": "2024-01-15T10:30:00"
 * }
 *
 * Exemple erreur :
 * {
 *   "success": false,
 *   "message": "Erreur de validation",
 *   "errors": ["Le titre est obligatoire", "La priorité est invalide"],
 *   "timestamp": "2024-01-15T10:30:00"
 * }
 *
 * @JsonInclude(NON_NULL) : les champs null ne sont pas inclus dans le JSON.
 *   (Ex: si errors est null, il n'apparaît pas dans la réponse succès)
 */
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {

    private boolean       success;
    private String        message;
    private T             data;
    private List<String>  errors;
    private LocalDateTime timestamp;

    // Factory methods

    public static <T> ApiResponse<T> success(T data) {
        return ApiResponse.<T>builder()
            .success(true)
            .data(data)
            .timestamp(LocalDateTime.now())
            .build();
    }

    public static <T> ApiResponse<T> success(String message, T data) {
        return ApiResponse.<T>builder()
            .success(true)
            .message(message)
            .data(data)
            .timestamp(LocalDateTime.now())
            .build();
    }

    public static <T> ApiResponse<T> error(String message, List<String> errors) {
        return ApiResponse.<T>builder()
            .success(false)
            .message(message)
            .errors(errors)
            .timestamp(LocalDateTime.now())
            .build();
    }

    public static <T> ApiResponse<T> error(String message) {
        return error(message, null);
    }
}

════════════════════════════════════════
26.3 MAPSTRUCT — MAPPING AVANCÉ
════════════════════════════════════════

MapStruct génère le code de mapping à la compilation.
Il est BEAUCOUP plus performant que la réflexion (ModelMapper).

// ── UserMapper.java ───────────────────────────────────────────────────────
package com.taskflow.backend.mapper;

import com.taskflow.backend.dto.request.RegisterRequest;
import com.taskflow.backend.dto.response.UserMiniResponse;
import com.taskflow.backend.dto.response.UserResponse;
import com.taskflow.backend.entity.User;
import org.mapstruct.*;

@Mapper(componentModel = "spring")
public interface UserMapper {

    UserResponse toResponse(User user);

    UserMiniResponse toMiniResponse(User user);

    /**
     * Mapping de RegisterRequest -> User.
     * Le mot de passe (passwordHash) est géré dans le service (BCrypt).
     * @Mapping(target = "passwordHash", ignore = true) : ne pas mapper ce champ.
     */
    @Mapping(target = "id",              ignore = true)
    @Mapping(target = "uuid",            ignore = true)
    @Mapping(target = "passwordHash",    ignore = true)
    @Mapping(target = "role",            ignore = true)
    @Mapping(target = "status",          ignore = true)
    @Mapping(target = "emailVerified",   ignore = true)
    @Mapping(target = "failedLoginCount",ignore = true)
    @Mapping(target = "lockedUntil",     ignore = true)
    @Mapping(target = "lastLoginAt",     ignore = true)
    @Mapping(target = "assignedTasks",   ignore = true)
    @Mapping(target = "createdTasks",    ignore = true)
    @Mapping(target = "ownedProjects",   ignore = true)
    @Mapping(target = "refreshTokens",   ignore = true)
    @Mapping(target = "createdAt",       ignore = true)
    @Mapping(target = "updatedAt",       ignore = true)
    User fromRegisterRequest(RegisterRequest request);

    /**
     * @AfterMapping : méthode appelée APRÈS le mapping automatique.
     * Permet d'enrichir le résultat avec des données calculées.
     */
    @AfterMapping
    default void enrichUserResponse(User user, @MappingTarget UserResponse response) {
        // Ajouter le nom complet calculé
        response.setDisplayName(
            user.getDisplayName() != null
                ? user.getDisplayName()
                : user.getFirstName() + " " + user.getLastName()
        );
    }
}

// ── ProjectMapper.java ────────────────────────────────────────────────────
@Mapper(componentModel = "spring", uses = {UserMapper.class})
public interface ProjectMapper {

    /**
     * uses = {UserMapper.class} : MapStruct délègue le mapping des User
     *   au UserMapper (pour owner -> UserMiniResponse).
     */
    @Mapping(source = "owner",             target = "owner")
    @Mapping(source = "members",           target = "memberCount",
             qualifiedByName = "membersToCount")
    @Mapping(source = "tasks",             target = "totalTasks",
             qualifiedByName = "tasksToCount")
    @Mapping(expression = "java(calculateProgress(project))",
             target = "progressPercentage")
    ProjectResponse toResponse(Project project);

    /**
     * @Named : qualificateur pour les mappings personnalisés.
     * qualifiedByName = "membersToCount" appelle cette méthode.
     */
    @Named("membersToCount")
    default int membersToCount(List<ProjectMember> members) {
        return members != null ? members.size() : 0;
    }

    @Named("tasksToCount")
    default int tasksToCount(List<Task> tasks) {
        return tasks != null ? tasks.size() : 0;
    }

    default double calculateProgress(Project project) {
        if (project.getTasks() == null || project.getTasks().isEmpty()) return 0;
        long done = project.getTasks().stream()
            .filter(t -> t.getStatus() == TaskStatus.DONE)
            .count();
        return (double) done / project.getTasks().size() * 100;
    }
}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 27 — PAGINATION
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

════════════════════════════════════════
27.1 POURQUOI PAGINER ?
════════════════════════════════════════

Sans pagination :
  GET /api/v1/tasks -> renvoie TOUTES les tâches de la BD
  -> Si 100 000 tâches -> OutOfMemoryError + timeout client + requête lente

Avec pagination :
  GET /api/v1/tasks?page=0&size=20 -> renvoie les 20 premières tâches
  -> Réponse rapide + peu de mémoire + expérience utilisateur fluide

Spring Data JPA intègre la pagination nativement via l'interface Pageable.

════════════════════════════════════════
27.2 UTILISATION DE PAGEABLE
════════════════════════════════════════

// ── Dans le Repository ────────────────────────────────────────────────────

public interface TaskRepository extends JpaRepository<Task, Long> {

    /**
     * Spring Data détecte le paramètre Pageable et génère :
     * SELECT * FROM tasks WHERE project_id = ?
     * ORDER BY created_at DESC
     * LIMIT 20 OFFSET 40
     *
     * + Un SELECT COUNT(*) FROM tasks WHERE project_id = ?
     *   pour calculer le nombre total de pages
     */
    Page<Task> findByProjectId(Long projectId, Pageable pageable);

    /**
     * Pour les requêtes @Query avec pagination, il faut parfois spécifier
     * le countQuery séparément (si la requête principale est complexe avec JOIN FETCH).
     *
     * Sans countQuery séparé, Spring fait un COUNT sur la requête complète,
     * ce qui peut être lent avec des JOIN FETCH.
     */
    @Query(
        value = """
            SELECT DISTINCT t FROM Task t
            LEFT JOIN FETCH t.assignee
            LEFT JOIN FETCH t.tags
            WHERE t.project.id = :projectId
            """,
        countQuery = "SELECT COUNT(DISTINCT t) FROM Task t WHERE t.project.id = :projectId"
    )
    Page<Task> findByProjectIdWithDetails(@Param("projectId") Long projectId,
                                           Pageable pageable);
}

// ── Dans le Service ───────────────────────────────────────────────────────

public Page<TaskSummaryResponse> getProjectTasks(UUID projectUuid,
                                                   Pageable pageable,
                                                   Long userId) {
    Project project = projectRepository.findByUuid(projectUuid)
        .orElseThrow(() -> new ResourceNotFoundException("Project", projectUuid));

    Page<Task> tasks = taskRepository.findByProjectId(project.getId(), pageable);

    // Page.map() transforme chaque élément sans changer la structure de pagination
    return tasks.map(taskMapper::toSummaryResponse);
}

// ── Dans le Controller ────────────────────────────────────────────────────

/**
 * @PageableDefault : valeurs par défaut pour Pageable si non spécifiées.
 *   size = 20      : 20 éléments par page
 *   sort = "createdAt" : trié par date de création
 *   direction = DESC : du plus récent au plus ancien
 */
@GetMapping("/projects/{projectUuid}/tasks")
public ResponseEntity<ApiResponse<Page<TaskSummaryResponse>>> getProjectTasks(
        @PathVariable UUID projectUuid,
        @PageableDefault(size = 20, sort = "createdAt",
                         direction = Sort.Direction.DESC) Pageable pageable,
        @AuthenticationPrincipal UserPrincipal currentUser) {

    Page<TaskSummaryResponse> page =
        taskService.getProjectTasks(projectUuid, pageable, currentUser.getId());

    return ResponseEntity.ok(ApiResponse.success(page));
}

Exemple de requête HTTP :
  GET /api/v1/projects/abc-123/tasks?page=2&size=10&sort=priority,desc&sort=dueDate,asc
  -> Page 3 (index 0), 10 par page, triées par priorité desc puis dueDate asc

════════════════════════════════════════
27.3 STRUCTURE DE LA RÉPONSE PAGINÉE
════════════════════════════════════════

Spring sérialise l'objet Page en JSON :

{
  "success": true,
  "data": {
    "content": [
      { "uuid": "...", "title": "Tâche 1", "status": "TODO", ... },
      { "uuid": "...", "title": "Tâche 2", "status": "DONE", ... }
    ],
    "pageable": {
      "pageNumber": 0,
      "pageSize": 20,
      "sort": { "sorted": true, "orders": [{"direction": "DESC", "property": "createdAt"}] },
      "offset": 0,
      "paged": true
    },
    "totalElements": 143,
    "totalPages": 8,
    "last": false,
    "first": true,
    "numberOfElements": 20,
    "size": 20,
    "number": 0,
    "empty": false
  }
}

Le client utilise totalElements, totalPages, first, last pour construire
le composant de pagination dans l'interface.

════════════════════════════════════════
27.4 PAGINATION PERSONNALISÉE
════════════════════════════════════════

Pour plus de contrôle sur la réponse, on peut créer un DTO PagedResponse :

// ── PagedResponse.java ────────────────────────────────────────────────────
package com.taskflow.backend.dto.response;

import lombok.Builder;
import lombok.Data;
import org.springframework.data.domain.Page;
import java.util.List;

/**
 * Wrapper personnalisé autour de Page<T> pour simplifier la réponse.
 * Évite de retourner l'objet Page<T> brut de Spring (verbose).
 */
@Data
@Builder
public class PagedResponse<T> {

    private List<T> content;
    private int     page;         // numéro de page (0-based)
    private int     size;         // taille de page
    private long    totalElements;// total d'éléments
    private int     totalPages;   // total de pages
    private boolean first;        // première page ?
    private boolean last;         // dernière page ?
    private boolean hasNext;      // y a-t-il une page suivante ?
    private boolean hasPrevious;  // y a-t-il une page précédente ?

    /**
     * Factory method depuis un Page<T> Spring.
     */
    public static <T> PagedResponse<T> from(Page<T> page) {
        return PagedResponse.<T>builder()
            .content(page.getContent())
            .page(page.getNumber())
            .size(page.getSize())
            .totalElements(page.getTotalElements())
            .totalPages(page.getTotalPages())
            .first(page.isFirst())
            .last(page.isLast())
            .hasNext(page.hasNext())
            .hasPrevious(page.hasPrevious())
            .build();
    }
}

// Utilisation dans le controller :
return ResponseEntity.ok(
    ApiResponse.success(PagedResponse.from(page)));

// Réponse JSON simplifiée :
{
  "success": true,
  "data": {
    "content": [ ... ],
    "page": 0,
    "size": 20,
    "totalElements": 143,
    "totalPages": 8,
    "first": true,
    "last": false,
    "hasNext": true,
    "hasPrevious": false
  }
}

════════════════════════════════════════
27.5 TRI (SORTING)
════════════════════════════════════════

Contrôler les champs triables pour éviter les injections :

// ── SortValidator.java ────────────────────────────────────────────────────
package com.taskflow.backend.util;

import org.springframework.data.domain.Sort;
import java.util.Set;

/**
 * Valide que les champs de tri sont dans une liste autorisée.
 * Évite le tri sur des champs sensibles ou inexistants.
 */
public class SortValidator {

    private static final Set<String> ALLOWED_TASK_SORT_FIELDS = Set.of(
        "createdAt", "updatedAt", "dueDate", "priority", "status", "title", "position"
    );

    private static final Set<String> ALLOWED_USER_SORT_FIELDS = Set.of(
        "email", "firstName", "lastName", "createdAt"
    );

    /**
     * Retourne un Sort sécurisé pour les tâches.
     * Si un champ non autorisé est demandé, utilise le tri par défaut.
     */
    public static Sort secureTaskSort(Sort requestedSort) {
        return secureSortForFields(requestedSort, ALLOWED_TASK_SORT_FIELDS,
                                    Sort.by(Sort.Direction.DESC, "createdAt"));
    }

    private static Sort secureSortForFields(Sort requested,
                                             Set<String> allowed,
                                             Sort defaultSort) {
        if (requested.isUnsorted()) return defaultSort;

        boolean allAllowed = requested.stream()
            .map(Sort.Order::getProperty)
            .allMatch(allowed::contains);

        return allAllowed ? requested : defaultSort;
    }
}

// Utilisation dans le service :
public Page<TaskSummaryResponse> getProjectTasks(Long projectId, Pageable pageable) {
    Sort safeSort = SortValidator.secureTaskSort(pageable.getSort());
    Pageable safePage = PageRequest.of(
        pageable.getPageNumber(), pageable.getPageSize(), safeSort);
    return taskRepository.findByProjectId(projectId, safePage)
        .map(taskMapper::toSummaryResponse);
}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 28 — FILTRES & RECHERCHE AVANCÉE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

════════════════════════════════════════
28.1 DTO FILTRE DE TÂCHES
════════════════════════════════════════

// ── TaskFilterRequest.java ────────────────────────────────────────────────
package com.taskflow.backend.dto.request;

import com.taskflow.backend.entity.TaskPriority;
import com.taskflow.backend.entity.TaskStatus;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;

/**
 * DTO pour les paramètres de filtrage des tâches.
 * Utilisé avec @ModelAttribute dans le Controller
 * -> lit les paramètres de requête HTTP (query params).
 *
 * URL : GET /tasks?status=TODO&priority=HIGH&search=login&dueDateFrom=2024-01-01
 */
@Data
public class TaskFilterRequest {

    // Filtres sur les enums (multiples valeurs possibles)
    private List<TaskStatus>   status;    // ?status=TODO&status=IN_PROGRESS

    private List<TaskPriority> priority;  // ?priority=HIGH&priority=CRITICAL

    // Filtre par assignee
    private UUID assigneeId;              // ?assigneeId=abc-123

    // Recherche textuelle
    private String search;                // ?search=login+page

    // Filtre sur les dates
    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
    private LocalDate dueDateFrom;        // ?dueDateFrom=2024-01-01

    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
    private LocalDate dueDateTo;          // ?dueDateTo=2024-12-31

    // Filtres booléens
    private Boolean overdue;              // ?overdue=true
    private Boolean unassigned;           // ?unassigned=true

    // Filtre par tags
    private List<Long> tagIds;            // ?tagIds=1&tagIds=2

    // Filtre par parent
    private Boolean rootTasksOnly;        // ?rootTasksOnly=true (pas de parent)
}

════════════════════════════════════════
28.2 SPECIFICATIONS AVANCÉES
════════════════════════════════════════

// ── TaskSpecifications.java (version complète) ────────────────────────────
package com.taskflow.backend.repository.spec;

import com.taskflow.backend.dto.request.TaskFilterRequest;
import com.taskflow.backend.entity.*;
import jakarta.persistence.criteria.*;
import org.springframework.data.jpa.domain.Specification;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;

public class TaskSpecifications {

    private TaskSpecifications() {}

    /**
     * Méthode principale : construit la Specification depuis le filtre DTO.
     * Combine toutes les conditions avec AND.
     */
    public static Specification<Task> from(TaskFilterRequest filter, Long projectId) {

        return (root, query, cb) -> {

            List<Predicate> predicates = new ArrayList<>();

            // Toujours filtrer par projet
            predicates.add(cb.equal(root.get("project").get("id"), projectId));

            // Filtrer par statuts (IN clause)
            if (filter.getStatus() != null && !filter.getStatus().isEmpty()) {
                predicates.add(root.get("status").in(filter.getStatus()));
            }

            // Filtrer par priorités
            if (filter.getPriority() != null && !filter.getPriority().isEmpty()) {
                predicates.add(root.get("priority").in(filter.getPriority()));
            }

            // Filtrer par assignee
            if (filter.getAssigneeId() != null) {
                // JOIN vers User pour accéder à l'UUID
                Join<Task, User> assigneeJoin =
                    root.join("assignee", JoinType.INNER);
                predicates.add(
                    cb.equal(assigneeJoin.get("uuid"), filter.getAssigneeId()));
            }

            // Tâches non assignées
            if (Boolean.TRUE.equals(filter.getUnassigned())) {
                predicates.add(cb.isNull(root.get("assignee")));
            }

            // Recherche textuelle (titre OU description)
            if (filter.getSearch() != null && !filter.getSearch().isBlank()) {
                String pattern = "%" + filter.getSearch().toLowerCase() + "%";
                predicates.add(cb.or(
                    cb.like(cb.lower(root.get("title")), pattern),
                    cb.like(cb.lower(root.get("description")), pattern)
                ));
            }

            // Filtre date de début
            if (filter.getDueDateFrom() != null) {
                predicates.add(
                    cb.greaterThanOrEqualTo(root.get("dueDate"),
                                            filter.getDueDateFrom()));
            }

            // Filtre date de fin
            if (filter.getDueDateTo() != null) {
                predicates.add(
                    cb.lessThanOrEqualTo(root.get("dueDate"),
                                         filter.getDueDateTo()));
            }

            // Tâches en retard
            if (Boolean.TRUE.equals(filter.getOverdue())) {
                predicates.add(cb.and(
                    cb.isNotNull(root.get("dueDate")),
                    cb.lessThan(root.get("dueDate"), LocalDate.now()),
                    root.get("status").in(
                        TaskStatus.TODO, TaskStatus.IN_PROGRESS, TaskStatus.BLOCKED)
                ));
            }

            // Filtrer par tags (EXISTS sous-requête)
            if (filter.getTagIds() != null && !filter.getTagIds().isEmpty()) {
                // JOIN vers la table task_tags
                Join<Task, Tag> tagJoin = root.join("tags", JoinType.INNER);
                predicates.add(tagJoin.get("id").in(filter.getTagIds()));
                // DISTINCT pour éviter les doublons quand une tâche a plusieurs tags
                query.distinct(true);
            }

            // Tâches racines uniquement (sans parent)
            if (Boolean.TRUE.equals(filter.getRootTasksOnly())) {
                predicates.add(cb.isNull(root.get("parentTask")));
            }

            return cb.and(predicates.toArray(new Predicate[0]));
        };
    }
}

════════════════════════════════════════
28.3 ENDPOINT DE RECHERCHE AVANCÉE
════════════════════════════════════════

@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class TaskSearchController {

    private final TaskService taskService;

    /**
     * GET /api/v1/projects/{projectUuid}/tasks
     *
     * Query params supportés :
     *   status=TODO,IN_PROGRESS
     *   priority=HIGH,CRITICAL
     *   search=texte libre
     *   assigneeId=uuid
     *   dueDateFrom=2024-01-01
     *   dueDateTo=2024-12-31
     *   overdue=true
     *   unassigned=true
     *   tagIds=1,2,3
     *   rootTasksOnly=true
     *   page=0&size=20
     *   sort=priority,desc
     */
    @GetMapping("/projects/{projectUuid}/tasks")
    public ResponseEntity<ApiResponse<PagedResponse<TaskSummaryResponse>>> searchTasks(
            @PathVariable UUID projectUuid,
            @ModelAttribute TaskFilterRequest filter,
            @PageableDefault(size = 20, sort = "position",
                             direction = Sort.Direction.ASC) Pageable pageable,
            @AuthenticationPrincipal UserPrincipal currentUser) {

        Page<TaskSummaryResponse> result =
            taskService.searchTasks(projectUuid, filter, pageable, currentUser.getId());

        return ResponseEntity.ok(
            ApiResponse.success(PagedResponse.from(result)));
    }

    /**
     * GET /api/v1/tasks/search?q=texte&projectId=uuid&...
     * Recherche globale dans tous les projets de l'utilisateur.
     */
    @GetMapping("/tasks/search")
    public ResponseEntity<ApiResponse<PagedResponse<TaskSummaryResponse>>> globalSearch(
            @RequestParam String q,
            @RequestParam(required = false) UUID projectId,
            @PageableDefault(size = 20) Pageable pageable,
            @AuthenticationPrincipal UserPrincipal currentUser) {

        Page<TaskSummaryResponse> result =
            taskService.globalSearch(q, projectId, pageable, currentUser.getId());

        return ResponseEntity.ok(
            ApiResponse.success(PagedResponse.from(result)));
    }
}

════════════════════════════════════════
28.4 SERVICE COMPLET AVEC FILTRAGE
════════════════════════════════════════

// Dans TaskServiceImpl :

@Override
public Page<TaskSummaryResponse> searchTasks(UUID projectUuid,
                                               TaskFilterRequest filter,
                                               Pageable pageable,
                                               Long userId) {
    Project project = projectRepository.findByUuid(projectUuid)
        .orElseThrow(() -> new ResourceNotFoundException("Project", projectUuid));

    verifyProjectReadAccess(project, userId);

    // Construire la Specification dynamique
    Specification<Task> spec = TaskSpecifications.from(filter, project.getId());

    // Exécuter la requête paginée
    Page<Task> tasks = taskRepository.findAll(spec, pageable);

    return tasks.map(taskMapper::toSummaryResponse);
}

@Override
public Page<TaskSummaryResponse> globalSearch(String query,
                                               UUID projectId,
                                               Pageable pageable,
                                               Long userId) {
    // Construction de la Specification combinée
    Specification<Task> spec = Specification.where(null);

    // Filtre par accessibilité (tâches de l'utilisateur)
    spec = spec.and((root, q, cb) -> {
        // L'utilisateur peut voir les tâches où :
        // - il est créateur OU assignee OU membre du projet
        Join<Task, User> creatorJoin  = root.join("createdBy", JoinType.LEFT);
        Join<Task, User> assigneeJoin = root.join("assignee",   JoinType.LEFT);

        return cb.or(
            cb.equal(creatorJoin.get("id"), userId),
            cb.equal(assigneeJoin.get("id"), userId)
        );
    });

    // Recherche textuelle
    if (query != null && !query.isBlank()) {
        String pattern = "%" + query.toLowerCase() + "%";
        spec = spec.and((root, q, cb) -> cb.or(
            cb.like(cb.lower(root.get("title")), pattern),
            cb.like(cb.lower(root.get("description")), pattern)
        ));
    }

    // Filtre par projet si spécifié
    if (projectId != null) {
        spec = spec.and((root, q, cb) ->
            cb.equal(root.get("project").get("uuid"), projectId));
    }

    return taskRepository.findAll(spec, pageable).map(taskMapper::toSummaryResponse);
}

════════════════════════════════════════
28.5 VERSIONING D'API
════════════════════════════════════════

Pourquoi versionner l'API ?
  - Permettre l'évolution de l'API sans casser les clients existants
  - Déprécier graduellement les anciennes versions
  - Maintenir plusieurs versions en parallèle

Stratégies :

1. URL Versioning (recommandé pour la simplicité) :
   /api/v1/tasks  ->  /api/v2/tasks

2. Header Versioning :
   GET /api/tasks
   Accept: application/vnd.taskflow.v2+json

3. Query Parameter :
   GET /api/tasks?version=2

Pour TaskFlow, on utilise URL versioning :

@RestController
@RequestMapping("/api/v1/tasks")
public class TaskControllerV1 { ... }

@RestController
@RequestMapping("/api/v2/tasks")
public class TaskControllerV2 {
    // V2 : réponse enrichie avec des métriques
    // Nouveaux paramètres de filtrage
    // Format de pagination différent
}

// Configuration Swagger pour documenter les versions :
@Bean
public GroupedOpenApi apiV1() {
    return GroupedOpenApi.builder()
        .group("v1")
        .pathsToMatch("/api/v1/**")
        .build();
}

@Bean
public GroupedOpenApi apiV2() {
    return GroupedOpenApi.builder()
        .group("v2")
        .pathsToMatch("/api/v2/**")
        .build();
}

════════════════════════════════════════
28.6 DOCUMENTATION OPENAPI / SWAGGER
════════════════════════════════════════

Spring Boot + SpringDoc OpenAPI génère automatiquement la documentation.

// ── OpenApiConfig.java ────────────────────────────────────────────────────
package com.taskflow.backend.config;

import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.Components;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class OpenApiConfig {

    @Bean
    public OpenAPI taskFlowOpenAPI() {
        return new OpenAPI()
            .info(new Info()
                .title("TaskFlow API")
                .version("v1.0")
                .description("API REST de gestion de tâches — TaskFlow Backend")
                .contact(new Contact()
                    .name("Équipe TaskFlow")
                    .email("api@taskflow.io")
                    .url("https://taskflow.io"))
                .license(new License()
                    .name("MIT")
                    .url("https://opensource.org/licenses/MIT")))
            // Sécurité JWT dans Swagger UI
            .addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
            .components(new Components()
                .addSecuritySchemes("bearerAuth",
                    new SecurityScheme()
                        .type(SecurityScheme.Type.HTTP)
                        .scheme("bearer")
                        .bearerFormat("JWT")
                        .name("bearerAuth")));
    }
}

// Annotations Swagger sur les controllers :
@Operation(
    summary = "Créer une tâche",
    description = "Crée une nouvelle tâche dans le projet spécifié",
    security = @SecurityRequirement(name = "bearerAuth")
)
@ApiResponses({
    @ApiResponse(responseCode = "201", description = "Tâche créée avec succès",
        content = @Content(schema = @Schema(implementation = TaskResponse.class))),
    @ApiResponse(responseCode = "400", description = "Données invalides"),
    @ApiResponse(responseCode = "401", description = "Non authentifié"),
    @ApiResponse(responseCode = "403", description = "Non autorisé"),
    @ApiResponse(responseCode = "404", description = "Projet non trouvé")
})
@PostMapping
public ResponseEntity<ApiResponse<TaskResponse>> createTask(...) { ... }

// URL Swagger UI : http://localhost:8080/swagger-ui/index.html
// URL OpenAPI JSON : http://localhost:8080/v3/api-docs

════════════════════════════════════════
28.7 EXERCICES CHAPITRE 26-28
════════════════════════════════════════

EXERCICE 1 (Facile) — DTO
Créez les DTOs CreateProjectRequest et ProjectResponse pour l'entité Project.

EXERCICE 2 (Facile) — Pagination
Ajoutez la pagination à l'endpoint GET /api/v1/users (liste des utilisateurs).

EXERCICE 3 (Intermédiaire) — Filtre
Créez UserFilterRequest avec : search (email/nom), role, status.
Implémentez la Specification correspondante.

EXERCICE 4 (Intermédiaire) — MapStruct
Créez CommentMapper avec :
  - toResponse(Comment) -> CommentResponse
  - updateFromRequest(UpdateCommentRequest, @MappingTarget Comment)

EXERCICE 5 (Avancé) — Recherche full-text PostgreSQL
Au lieu d'utiliser LIKE '%?%', utilisez la recherche full-text native de PostgreSQL :
  WHERE to_tsvector('french', title || ' ' || COALESCE(description, ''))
        @@ plainto_tsquery('french', ?)
Écrivez la requête native correspondante et expliquez ses avantages.

EXERCICE 6 (Avancé) — Cursor Pagination
Implémentez la pagination par curseur (keyset pagination) pour les tâches :
  Au lieu de ?page=0&size=20, utiliser ?afterId=123&size=20
  -> Plus performant pour les grandes tables (pas de OFFSET)

════════════════════════════════════════
28.8 CORRIGÉS CHAPITRE 26-28
════════════════════════════════════════

CORRIGÉ 3 — UserFilterSpec :

public static Specification<User> from(UserFilterRequest filter) {
    return (root, query, cb) -> {
        List<Predicate> predicates = new ArrayList<>();

        if (filter.getSearch() != null && !filter.getSearch().isBlank()) {
            String pattern = "%" + filter.getSearch().toLowerCase() + "%";
            predicates.add(cb.or(
                cb.like(cb.lower(root.get("email")), pattern),
                cb.like(cb.lower(root.get("firstName")), pattern),
                cb.like(cb.lower(root.get("lastName")), pattern)
            ));
        }
        if (filter.getRole() != null) {
            predicates.add(cb.equal(root.get("role"), filter.getRole()));
        }
        if (filter.getStatus() != null) {
            predicates.add(cb.equal(root.get("status"), filter.getStatus()));
        }
        return cb.and(predicates.toArray(new Predicate[0]));
    };
}

CORRIGÉ 6 — Cursor Pagination :

// Repository :
@Query("""
    SELECT t FROM Task t
    WHERE t.project.id = :projectId
      AND t.id > :afterId
    ORDER BY t.id ASC
    """)
List<Task> findNextPage(@Param("projectId") Long projectId,
                         @Param("afterId") Long afterId,
                         Pageable pageable);

// Service :
public CursorPageResponse<TaskSummaryResponse> getTasksWithCursor(
        Long projectId, Long afterId, int size) {
    PageRequest page = PageRequest.of(0, size + 1);  // +1 pour savoir s'il y a un suivant
    List<Task> tasks = taskRepository.findNextPage(projectId,
        afterId != null ? afterId : 0L, page);

    boolean hasNext = tasks.size() > size;
    List<Task> content = hasNext ? tasks.subList(0, size) : tasks;
    Long nextCursor = hasNext ? content.get(content.size() - 1).getId() : null;

    return CursorPageResponse.<TaskSummaryResponse>builder()
        .content(content.stream().map(taskMapper::toSummaryResponse).toList())
        .hasNext(hasNext)
        .nextCursor(nextCursor)
        .build();
}

// Avantages vs OFFSET :
// - OFFSET = scan de toutes les lignes précédentes (lent pour page=1000)
// - Cursor = utilise l'index sur l'id (O(log n) quelle que soit la page)
// - Pas de décalage si des éléments sont insérés/supprimés entre deux requêtes

================================================================================
   FIN PARTIE 7 — Prochaine partie : Spring Security + JWT complet
================================================================================

================================================================================
   GUIDE SPRING BOOT MASTER — PARTIE 8
   SPRING SECURITY & JWT
   Chapitres 29 à 31
   Projet fil rouge : TaskFlow Backend
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 29 — SPRING SECURITY : FONDATIONS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

════════════════════════════════════════
29.1 INTRODUCTION À SPRING SECURITY
════════════════════════════════════════

Spring Security est le framework de sécurité standard pour les applications Spring.
Il fournit :
- Authentification (qui êtes-vous ?)
- Autorisation (qu'avez-vous le droit de faire ?)
- Protection contre les attaques web (CSRF, XSS, Clickjacking...)
- Gestion des sessions
- OAuth2 / OpenID Connect

Flux de traitement Spring Security :

  HTTP Request
       │
       [BLACK_DOWN-POINTING_TRIANGLE]
  ┌──────────────────────────────────────┐
  │  FilterChain (Servlet Filters)       │
  │  ┌────────────────────────────────┐  │
  │  │ SecurityContextPersistence     │  │
  │  │ Filter                         │  │
  │  └───────────────┬────────────────┘  │
  │                  │                   │
  │  ┌───────────────[BLACK_DOWN-POINTING_TRIANGLE]────────────────┐  │
  │  │ JwtAuthenticationFilter        │  │  <- Notre filtre JWT personnalisé
  │  │ (valide le token Bearer)        │  │
  │  └───────────────┬────────────────┘  │
  │                  │                   │
  │  ┌───────────────[BLACK_DOWN-POINTING_TRIANGLE]────────────────┐  │
  │  │ ExceptionTranslationFilter     │  │
  │  │ (gère les 401/403)             │  │
  │  └───────────────┬────────────────┘  │
  │                  │                   │
  │  ┌───────────────[BLACK_DOWN-POINTING_TRIANGLE]────────────────┐  │
  │  │ FilterSecurityInterceptor      │  │
  │  │ (vérifie les autorisations)    │  │
  │  └────────────────────────────────┘  │
  └──────────────────────────────────────┘
       │
       [BLACK_DOWN-POINTING_TRIANGLE]
  DispatcherServlet -> Controller

════════════════════════════════════════
29.2 CONCEPTS CLÉS
════════════════════════════════════════

SecurityContext :
  Stocke l'authentification de l'utilisateur courant dans un ThreadLocal.
  Accessible via : SecurityContextHolder.getContext().getAuthentication()

Authentication :
  Interface représentant l'identité de l'utilisateur.
  Implémentations : UsernamePasswordAuthenticationToken, JwtAuthenticationToken...

UserDetails :
  Interface représentant les données utilisateur pour Spring Security.
  Méthodes : getUsername(), getPassword(), getAuthorities(), isEnabled()...

UserDetailsService :
  Interface pour charger un UserDetails depuis le username (email dans notre cas).
  Une seule méthode : loadUserByUsername(String username)

GrantedAuthority / SimpleGrantedAuthority :
  Représente un rôle ou une permission.
  Convention : les rôles commencent par "ROLE_" (ex: ROLE_ADMIN, ROLE_USER)

════════════════════════════════════════
29.3 CONFIGURATION SPRING SECURITY
════════════════════════════════════════

// ── SecurityConfig.java ───────────────────────────────────────────────────
package com.taskflow.backend.config;

import com.taskflow.backend.security.JwtAuthenticationFilter;
import com.taskflow.backend.security.SecurityAuthEntryPoint;
import com.taskflow.backend.security.SecurityAccessDeniedHandler;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

/**
 * Configuration principale de Spring Security pour TaskFlow.
 *
 * @EnableWebSecurity : active la sécurité web Spring
 * @EnableMethodSecurity : active les annotations @PreAuthorize, @PostAuthorize...
 *   prePostEnabled = true : active @PreAuthorize/@PostAuthorize
 *   securedEnabled = true : active @Secured
 */
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter  jwtAuthFilter;
    private final CustomUserDetailsService userDetailsService;
    private final SecurityAuthEntryPoint   authEntryPoint;
    private final SecurityAccessDeniedHandler accessDeniedHandler;

    /**
     * Bean principal : la chaîne de filtres de sécurité.
     * Remplace l'ancienne classe WebSecurityConfigurerAdapter (dépréciée).
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            // ── CSRF ─────────────────────────────────────────────────────
            // Désactiver CSRF pour les APIs REST stateless (JWT).
            // CSRF est utile pour les apps avec sessions (cookies de session).
            // JWT + Bearer header = pas de CSRF nécessaire.
            .csrf(AbstractHttpConfigurer::disable)

            // ── CORS ─────────────────────────────────────────────────────
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))

            // ── Session ───────────────────────────────────────────────────
            // STATELESS : Spring Security ne crée/utilise pas de session HTTP.
            // L'état d'authentification est dans le JWT à chaque requête.
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            // ── Gestion des erreurs ───────────────────────────────────────
            .exceptionHandling(ex -> ex
                // 401 Unauthorized : quand l'utilisateur n'est pas authentifié
                .authenticationEntryPoint(authEntryPoint)
                // 403 Forbidden : quand l'utilisateur n'a pas les droits
                .accessDeniedHandler(accessDeniedHandler)
            )

            // ── Règles d'autorisation ─────────────────────────────────────
            .authorizeHttpRequests(auth -> auth

                // Routes publiques (pas d'authentification requise)
                .requestMatchers(HttpMethod.POST,
                    "/api/v1/auth/register",
                    "/api/v1/auth/login",
                    "/api/v1/auth/refresh-token",
                    "/api/v1/auth/forgot-password",
                    "/api/v1/auth/reset-password"
                ).permitAll()

                // Documentation API (Swagger UI)
                .requestMatchers(
                    "/swagger-ui/**",
                    "/swagger-ui.html",
                    "/v3/api-docs/**",
                    "/swagger-resources/**"
                ).permitAll()

                // Actuator : health check public
                .requestMatchers("/actuator/health").permitAll()
                // Autres endpoints actuator : admin seulement
                .requestMatchers("/actuator/**").hasRole("ADMIN")

                // Routes admin
                .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")

                // Toutes les autres routes nécessitent une authentification
                .anyRequest().authenticated()
            )

            // ── Filtre JWT ────────────────────────────────────────────────
            // Ajouter notre filtre JWT AVANT UsernamePasswordAuthenticationFilter.
            // Il s'exécute pour chaque requête et valide le token Bearer.
            .addFilterBefore(jwtAuthFilter,
                             UsernamePasswordAuthenticationFilter.class)

            .build();
    }

    /**
     * Configuration CORS.
     * CORS = Cross-Origin Resource Sharing.
     * Nécessaire quand le frontend (React, Vue...) est sur un domaine différent.
     */
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();

        // Origines autorisées (frontend)
        config.setAllowedOriginPatterns(List.of(
            "http://localhost:3000",
            "http://localhost:5173",
            "https://*.taskflow.io"
        ));

        // Méthodes HTTP autorisées
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));

        // Headers autorisés dans les requêtes
        config.setAllowedHeaders(List.of(
            "Authorization",
            "Content-Type",
            "Accept",
            "X-Requested-With"
        ));

        // Autoriser les cookies (pour le refresh token en HttpOnly cookie)
        config.setAllowCredentials(true);

        // Durée de mise en cache des résultats CORS (1 heure)
        config.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }

    /**
     * BCryptPasswordEncoder : algorithme de hachage adaptatif.
     * strength = 12 : nombre de rounds (2^12 = 4096 itérations)
     * Plus le strength est élevé, plus c'est sécurisé mais lent.
     * 12 est un bon compromis pour la production (2024).
     */
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12);
    }

    /**
     * DaoAuthenticationProvider : utilise UserDetailsService + PasswordEncoder
     * pour vérifier les credentials (email + mot de passe).
     */
    @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }

    /**
     * AuthenticationManager : orchestre l'authentification.
     * Délègue au DaoAuthenticationProvider.
     * Utilisé dans AuthService pour authentifier l'utilisateur lors du login.
     */
    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }
}

════════════════════════════════════════
29.4 CUSTOM USER DETAILS SERVICE
════════════════════════════════════════

// ── CustomUserDetailsService.java ─────────────────────────────────────────
package com.taskflow.backend.security;

import com.taskflow.backend.entity.User;
import com.taskflow.backend.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

/**
 * Implémentation de UserDetailsService pour TaskFlow.
 * Spring Security appelle loadUserByUsername() lors de l'authentification.
 */
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    /**
     * Charge l'utilisateur depuis la BD par son email (= username dans notre cas).
     * Appelé par DaoAuthenticationProvider lors du login.
     */
    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String email)
            throws UsernameNotFoundException {

        User user = userRepository.findByEmail(email.toLowerCase())
            .orElseThrow(() -> new UsernameNotFoundException(
                "Utilisateur non trouvé : " + email));

        // Construire la liste des autorités (rôles)
        List<SimpleGrantedAuthority> authorities = List.of(
            new SimpleGrantedAuthority("ROLE_" + user.getRole().name())
        );

        // Retourner un UserPrincipal (notre classe qui implémente UserDetails)
        return UserPrincipal.fromUser(user, authorities);
    }
}

// ── UserPrincipal.java ────────────────────────────────────────────────────
package com.taskflow.backend.security;

import com.taskflow.backend.entity.User;
import com.taskflow.backend.entity.UserStatus;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.List;
import java.util.UUID;

/**
 * Notre implémentation de UserDetails.
 * Contient les informations de l'utilisateur accessibles via
 * @AuthenticationPrincipal dans les controllers.
 */
@Getter
public class UserPrincipal implements UserDetails {

    private final Long   id;
    private final UUID   uuid;
    private final String email;
    private final String passwordHash;
    private final Collection<? extends GrantedAuthority> authorities;
    private final boolean active;
    private final boolean emailVerified;

    private UserPrincipal(Long id, UUID uuid, String email, String passwordHash,
                          Collection<? extends GrantedAuthority> authorities,
                          boolean active, boolean emailVerified) {
        this.id            = id;
        this.uuid          = uuid;
        this.email         = email;
        this.passwordHash  = passwordHash;
        this.authorities   = authorities;
        this.active        = active;
        this.emailVerified = emailVerified;
    }

    public static UserPrincipal fromUser(User user,
            Collection<? extends GrantedAuthority> authorities) {
        return new UserPrincipal(
            user.getId(),
            user.getUuid(),
            user.getEmail(),
            user.getPasswordHash(),
            authorities,
            user.getStatus() == UserStatus.ACTIVE,
            user.getEmailVerified()
        );
    }

    // ── UserDetails interface ─────────────────────────────────────────────

    @Override
    public String getUsername() { return email; }

    @Override
    public String getPassword() { return passwordHash; }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    /**
     * Compte non expiré -> toujours true pour TaskFlow
     * (géré via status = INACTIVE)
     */
    @Override
    public boolean isAccountNonExpired() { return true; }

    /**
     * Compte non verrouillé -> true si l'utilisateur est actif.
     */
    @Override
    public boolean isAccountNonLocked() { return active; }

    /**
     * Credentials non expirés -> toujours true
     * (un changement de mot de passe invalide les tokens JWT)
     */
    @Override
    public boolean isCredentialsNonExpired() { return true; }

    /**
     * Compte activé -> true si l'utilisateur est actif.
     */
    @Override
    public boolean isEnabled() { return active; }
}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 30 — JWT : JSON WEB TOKENS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

════════════════════════════════════════
30.1 ANATOMIE D'UN JWT
════════════════════════════════════════

Un JWT est une chaîne Base64 composée de 3 parties séparées par des points :

  eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
  .eyJzdWIiOiJ1c2VyQHRhc2tmbG93LmlvIiwiaWF0IjoxNzA1MzMxMjAwLCJleHAiOjE3MDUzMzQ4MDB9
  .xhZBOkl_YpPFBQyoUagcTnEqwjCkpF3jIv1KA1PGP8Q

  ┌────────────────────────┐ . ┌──────────────────────────┐ . ┌────────────┐
  │  HEADER (Base64)        │   │  PAYLOAD (Base64)         │   │ SIGNATURE  │
  │  {                      │   │  {                        │   │            │
  │    "alg": "HS256",      │   │    "sub": "user@mail.io", │   │            │
  │    "typ": "JWT"         │   │    "iat": 1705331200,     │   │            │
  │  }                      │   │    "exp": 1705334800,     │   │            │
  │                         │   │    "role": "USER",        │   │            │
  │                         │   │    "uuid": "abc-123"      │   │            │
  │                         │   │  }                        │   │            │
  └────────────────────────┘   └──────────────────────────┘   └────────────┘

HEADER : algorithme de signature (HS256, RS256...) et type (JWT)
PAYLOAD : les claims (données) :
  - sub  : subject (identifiant unique = email ou userId)
  - iat  : issued at (timestamp de création)
  - exp  : expiration (timestamp d'expiration)
  - Champs personnalisés : role, uuid...
SIGNATURE : HMAC(base64(header) + "." + base64(payload), secret)
  -> Empêche la falsification du token

IMPORTANT : Le payload est encodé en Base64, PAS chiffré.
Toute personne qui intercepte le token peut lire le payload !
-> Ne JAMAIS mettre d'informations sensibles dans le payload JWT.

════════════════════════════════════════
30.2 STRATÉGIE ACCESS TOKEN + REFRESH TOKEN
════════════════════════════════════════

Access Token :
  - Durée de vie courte (15 minutes)
  - Envoyé dans chaque requête : Authorization: Bearer <token>
  - Stateless : Spring valide sans requête BD
  - Si volé, expire rapidement

Refresh Token :
  - Durée de vie longue (7 jours)
  - Stocké en BD (pour révocation) ET en cookie HttpOnly
  - Utilisé pour obtenir un nouveau access token
  - Peut être révoqué à tout moment (logout)

Flux d'authentification :

  CLIENT                              SERVEUR
  ──────                              ───────
  POST /auth/login (email+password)
                          ─────────────[BLACK_RIGHT-POINTING_POINTER]
                                        Vérifie credentials
                                        Génère access_token (15min)
                                        Génère refresh_token (7j)
                                        Stocke refresh_token en BD
                          [BLACK_LEFT-POINTING_POINTER]─────────────
  Reçoit access_token (body)
  Reçoit refresh_token (cookie HttpOnly)

  ... 15 minutes plus tard ...

  GET /api/tasks (avec access_token expiré)
                          ─────────────[BLACK_RIGHT-POINTING_POINTER]
                                        401 Unauthorized
                          [BLACK_LEFT-POINTING_POINTER]─────────────

  POST /auth/refresh-token (avec cookie refresh_token)
                          ─────────────[BLACK_RIGHT-POINTING_POINTER]
                                        Vérifie refresh_token en BD
                                        Génère nouveau access_token
                          [BLACK_LEFT-POINTING_POINTER]─────────────
  Reçoit nouveau access_token
  Retry la requête originale

  POST /auth/logout
                          ─────────────[BLACK_RIGHT-POINTING_POINTER]
                                        Révoque le refresh_token en BD
                          [BLACK_LEFT-POINTING_POINTER]─────────────

════════════════════════════════════════
30.3 SERVICE JWT
════════════════════════════════════════

// ── JwtService.java ───────────────────────────────────────────────────────
package com.taskflow.backend.security;

import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

/**
 * Service pour la génération et validation des JWT.
 * Utilise JJWT 0.12.3.
 */
@Service
@Slf4j
public class JwtService {

    @Value("${app.jwt.secret}")
    private String jwtSecret;

    @Value("${app.jwt.access-token-expiration-ms}")
    private long accessTokenExpMs;   // 15 minutes = 900_000 ms

    @Value("${app.jwt.refresh-token-expiration-ms}")
    private long refreshTokenExpMs;  // 7 jours = 604_800_000 ms

    // ── Génération ────────────────────────────────────────────────────────

    /**
     * Génère un access token JWT pour l'utilisateur donné.
     *
     * Claims inclus :
     * - sub (subject) : email de l'utilisateur
     * - uuid : UUID public de l'utilisateur
     * - role : rôle (USER, ADMIN, MANAGER)
     * - iat : issued at
     * - exp : expiration
     */
    public String generateAccessToken(UserPrincipal userPrincipal) {
        Map<String, Object> extraClaims = new HashMap<>();
        extraClaims.put("uuid", userPrincipal.getUuid().toString());
        extraClaims.put("role", userPrincipal.getAuthorities().iterator()
            .next().getAuthority().replace("ROLE_", ""));

        return buildToken(extraClaims, userPrincipal.getUsername(), accessTokenExpMs);
    }

    public String generateRefreshToken(String email) {
        return buildToken(new HashMap<>(), email, refreshTokenExpMs);
    }

    private String buildToken(Map<String, Object> claims, String subject, long expMs) {
        return Jwts.builder()
            .claims(claims)
            .subject(subject)
            .issuedAt(new Date(System.currentTimeMillis()))
            .expiration(new Date(System.currentTimeMillis() + expMs))
            .signWith(getSigningKey())
            .compact();
    }

    // ── Extraction ────────────────────────────────────────────────────────

    /**
     * Extrait le subject (email) du token.
     */
    public String extractEmail(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    public Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    private Claims extractAllClaims(String token) {
        return Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }

    // ── Validation ────────────────────────────────────────────────────────

    /**
     * Valide le token :
     * 1. La signature est correcte (clé secrète)
     * 2. Le token n'est pas expiré
     * 3. Le subject correspond à l'utilisateur
     */
    public boolean isTokenValid(String token, UserDetails userDetails) {
        try {
            String email = extractEmail(token);
            return email.equals(userDetails.getUsername()) && !isTokenExpired(token);
        } catch (JwtException | IllegalArgumentException e) {
            log.debug("JWT validation failed: {}", e.getMessage());
            return false;
        }
    }

    public boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    /**
     * Valide la signature et le format sans vérifier l'utilisateur.
     * Utile pour le refresh token.
     */
    public boolean isTokenSignatureValid(String token) {
        try {
            Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token);
            return true;
        } catch (JwtException e) {
            return false;
        }
    }

    // ── Clé de signature ─────────────────────────────────────────────────

    /**
     * Génère la clé HMAC-SHA256 depuis le secret Base64.
     * Le secret doit avoir au moins 32 octets (256 bits) pour HS256.
     */
    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(jwtSecret);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    /**
     * Durée de validité en secondes (pour la réponse AuthTokenResponse).
     */
    public long getAccessTokenExpirationSeconds() {
        return accessTokenExpMs / 1000;
    }
}

// ── Configuration dans application.properties ─────────────────────────────
# Générer un secret sécurisé : openssl rand -base64 64
app.jwt.secret=VGFza0Zsb3dCYWNrZW5kU3VwZXJTZWNyZXRLZXkyMDI0UHJvZHVjdGlvblJlYWR5S2V5

app.jwt.access-token-expiration-ms=900000      # 15 minutes
app.jwt.refresh-token-expiration-ms=604800000  # 7 jours

════════════════════════════════════════
30.4 FILTRE JWT
════════════════════════════════════════

// ── JwtAuthenticationFilter.java ──────────────────────────────────────────
package com.taskflow.backend.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

/**
 * Filtre Spring Security pour l'authentification JWT.
 *
 * OncePerRequestFilter : garantit que ce filtre est exécuté UNE SEULE FOIS
 * par requête HTTP (même si la requête est redispatched).
 *
 * Pour chaque requête :
 * 1. Extraire le token Bearer du header Authorization
 * 2. Valider le token
 * 3. Charger l'utilisateur
 * 4. Définir l'authentification dans le SecurityContext
 */
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService              jwtService;
    private final CustomUserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain
    ) throws ServletException, IOException {

        // 1. Extraire le token du header Authorization
        String token = extractTokenFromRequest(request);

        if (token == null) {
            // Pas de token -> passer au filtre suivant
            // Spring Security gérera l'accès (404 ou 401 selon la config)
            filterChain.doFilter(request, response);
            return;
        }

        try {
            // 2. Extraire l'email du token
            String email = jwtService.extractEmail(token);

            // 3. Vérifier qu'il n'y a pas déjà une authentification dans le contexte
            //    (évite de recharger l'utilisateur si déjà authentifié)
            if (email != null && SecurityContextHolder.getContext()
                                                      .getAuthentication() == null) {

                // 4. Charger l'utilisateur depuis la BD
                UserPrincipal userPrincipal =
                    (UserPrincipal) userDetailsService.loadUserByUsername(email);

                // 5. Valider le token
                if (jwtService.isTokenValid(token, userPrincipal)) {

                    // 6. Créer l'objet Authentication
                    UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                            userPrincipal,
                            null,
                            userPrincipal.getAuthorities()
                        );

                    // Ajouter les détails de la requête (IP, session...)
                    authentication.setDetails(
                        new WebAuthenticationDetailsSource().buildDetails(request));

                    // 7. Définir l'authentification dans le SecurityContext
                    SecurityContextHolder.getContext().setAuthentication(authentication);

                    log.debug("JWT authentication successful for user: {}", email);
                }
            }
        } catch (Exception e) {
            // Token invalide ou expiré -> pas d'authentification
            // Le filtre ExceptionTranslationFilter renverra un 401
            log.debug("JWT authentication failed: {}", e.getMessage());
        }

        // 8. Continuer la chaîne de filtres
        filterChain.doFilter(request, response);
    }

    /**
     * Extrait le token Bearer depuis le header Authorization.
     * Header format : "Authorization: Bearer eyJhbGciOi..."
     */
    private String extractTokenFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);  // Enlever "Bearer " (7 caractères)
        }
        return null;
    }

    /**
     * Exclure les routes d'authentification de ce filtre.
     * (Inutile dans notre cas car les routes /auth/** sont permitAll(),
     *  mais c'est une bonne pratique de performance)
     */
    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String path = request.getServletPath();
        return path.startsWith("/api/v1/auth/login")
            || path.startsWith("/api/v1/auth/register")
            || path.startsWith("/swagger-ui")
            || path.startsWith("/v3/api-docs");
    }
}

════════════════════════════════════════
30.5 GESTION DES ERREURS 401 ET 403
════════════════════════════════════════

// ── SecurityAuthEntryPoint.java ───────────────────────────────────────────
package com.taskflow.backend.security;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.taskflow.backend.dto.response.ApiResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

/**
 * Gestionnaire d'erreurs 401 (non authentifié).
 * Appelé par Spring Security quand un utilisateur non authentifié
 * accède à une ressource protégée.
 *
 * Par défaut, Spring redirige vers /login (pour les apps MVC).
 * Pour une API REST, on veut retourner un JSON 401.
 */
@Component
@RequiredArgsConstructor
public class SecurityAuthEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    @Override
    public void commence(HttpServletRequest request,
                          HttpServletResponse response,
                          AuthenticationException authException) throws IOException {

        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");

        ApiResponse<Void> error = ApiResponse.error(
            "Authentification requise. Veuillez vous connecter.");

        response.getWriter().write(
            objectMapper.writeValueAsString(error));
    }
}

// ── SecurityAccessDeniedHandler.java ─────────────────────────────────────
@Component
@RequiredArgsConstructor
public class SecurityAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper objectMapper;

    @Override
    public void handle(HttpServletRequest request,
                        HttpServletResponse response,
                        AccessDeniedException accessDeniedException) throws IOException {

        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");

        ApiResponse<Void> error = ApiResponse.error(
            "Accès refusé. Vous n'avez pas les permissions nécessaires.");

        response.getWriter().write(
            objectMapper.writeValueAsString(error));
    }
}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 31 — RÔLES & AUTORISATION AVANCÉE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

════════════════════════════════════════
31.1 @PreAuthorize
════════════════════════════════════════

@PreAuthorize évalue une expression SpEL AVANT l'exécution de la méthode.

// Vérifications basées sur les rôles
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long userId) { ... }

@PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')")
public void inviteUser(...) { ... }

@PreAuthorize("hasRole('USER')")  // tous les utilisateurs authentifiés
public TaskResponse createTask(...) { ... }

// Vérification combinée avec accès au paramètre
@PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id")
public UserResponse getUser(@PathVariable Long userId) { ... }
// -> Admin peut voir n'importe quel user, ou l'user peut voir son propre profil

// Vérification avec SpEL complexe
@PreAuthorize("""
    hasRole('ADMIN') or
    (hasRole('MANAGER') and @projectSecurity.isProjectManager(#projectUuid, authentication))
    """)
public void addProjectMember(UUID projectUuid, ...) { ... }

// @PostAuthorize : vérifie APRÈS l'exécution (plus rare)
@PostAuthorize("returnObject.createdById == authentication.principal.uuid")
public TaskResponse getMyTask(UUID uuid) { ... }

════════════════════════════════════════
31.2 BEAN DE SÉCURITÉ MÉTIER
════════════════════════════════════════

Pour les règles complexes, créer un bean Spring dédié :

// ── ProjectSecurity.java ──────────────────────────────────────────────────
package com.taskflow.backend.security;

import com.taskflow.backend.entity.Project;
import com.taskflow.backend.entity.ProjectMemberRole;
import com.taskflow.backend.repository.ProjectRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;

import java.util.UUID;

/**
 * Bean de sécurité métier pour les projets.
 * Référencé dans @PreAuthorize via "@projectSecurity".
 *
 * Exemple d'utilisation :
 * @PreAuthorize("@projectSecurity.isProjectOwner(#uuid, authentication)")
 */
@Component("projectSecurity")
@RequiredArgsConstructor
public class ProjectSecurity {

    private final ProjectRepository projectRepository;

    /**
     * Vérifie si l'utilisateur authentifié est le propriétaire du projet.
     */
    public boolean isProjectOwner(UUID projectUuid, Authentication auth) {
        if (auth == null || !auth.isAuthenticated()) return false;
        UserPrincipal principal = (UserPrincipal) auth.getPrincipal();
        return projectRepository.findByUuid(projectUuid)
            .map(p -> p.getOwner().getId().equals(principal.getId()))
            .orElse(false);
    }

    /**
     * Vérifie si l'utilisateur est membre du projet (avec n'importe quel rôle).
     */
    public boolean isProjectMember(UUID projectUuid, Authentication auth) {
        if (auth == null || !auth.isAuthenticated()) return false;
        UserPrincipal principal = (UserPrincipal) auth.getPrincipal();
        return projectRepository.findByUuid(projectUuid)
            .map(p -> {
                Long userId = principal.getId();
                return p.getOwner().getId().equals(userId)
                    || p.getMembers().stream()
                        .anyMatch(m -> m.getUser().getId().equals(userId));
            })
            .orElse(false);
    }

    /**
     * Vérifie si l'utilisateur est admin du projet.
     */
    public boolean isProjectAdmin(UUID projectUuid, Authentication auth) {
        if (auth == null || !auth.isAuthenticated()) return false;
        UserPrincipal principal = (UserPrincipal) auth.getPrincipal();
        return projectRepository.findByUuid(projectUuid)
            .map(p -> {
                Long userId = principal.getId();
                if (p.getOwner().getId().equals(userId)) return true;
                return p.getMembers().stream()
                    .anyMatch(m -> m.getUser().getId().equals(userId)
                              && m.getRole() == ProjectMemberRole.ADMIN);
            })
            .orElse(false);
    }
}

// ── TaskSecurity.java ─────────────────────────────────────────────────────
@Component("taskSecurity")
@RequiredArgsConstructor
public class TaskSecurity {

    private final TaskRepository taskRepository;

    public boolean isTaskOwner(UUID taskUuid, Authentication auth) {
        if (auth == null) return false;
        UserPrincipal principal = (UserPrincipal) auth.getPrincipal();
        return taskRepository.findByUuid(taskUuid)
            .map(t -> t.getCreatedBy().getId().equals(principal.getId()))
            .orElse(false);
    }

    public boolean canModifyTask(UUID taskUuid, Authentication auth) {
        if (auth == null) return false;
        UserPrincipal principal = (UserPrincipal) auth.getPrincipal();
        return taskRepository.findByUuid(taskUuid)
            .map(t -> {
                Long userId = principal.getId();
                return t.getCreatedBy().getId().equals(userId)
                    || (t.getAssignee() != null
                        && t.getAssignee().getId().equals(userId));
            })
            .orElse(false);
    }
}

════════════════════════════════════════
31.3 UTILISATION DANS LES CONTROLLERS
════════════════════════════════════════

@RestController
@RequestMapping("/api/v1/projects")
@RequiredArgsConstructor
public class ProjectController {

    private final ProjectService projectService;

    @GetMapping
    // Tout utilisateur authentifié peut voir ses projets
    public ResponseEntity<ApiResponse<PagedResponse<ProjectResponse>>> getMyProjects(
            @PageableDefault(size = 20) Pageable pageable,
            @AuthenticationPrincipal UserPrincipal currentUser) {

        return ResponseEntity.ok(ApiResponse.success(
            PagedResponse.from(
                projectService.getProjectsForUser(currentUser.getId(), pageable))));
    }

    @PostMapping
    public ResponseEntity<ApiResponse<ProjectResponse>> createProject(
            @Valid @RequestBody CreateProjectRequest request,
            @AuthenticationPrincipal UserPrincipal currentUser) {

        ProjectResponse project =
            projectService.createProject(request, currentUser.getId());
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(ApiResponse.success("Projet créé", project));
    }

    @PutMapping("/{uuid}")
    @PreAuthorize("hasRole('ADMIN') or @projectSecurity.isProjectOwner(#uuid, authentication)")
    public ResponseEntity<ApiResponse<ProjectResponse>> updateProject(
            @PathVariable UUID uuid,
            @Valid @RequestBody UpdateProjectRequest request,
            @AuthenticationPrincipal UserPrincipal currentUser) {

        ProjectResponse project = projectService.updateProject(uuid, request, currentUser.getId());
        return ResponseEntity.ok(ApiResponse.success("Projet mis à jour", project));
    }

    @DeleteMapping("/{uuid}")
    @PreAuthorize("hasRole('ADMIN') or @projectSecurity.isProjectOwner(#uuid, authentication)")
    public ResponseEntity<ApiResponse<Void>> deleteProject(
            @PathVariable UUID uuid,
            @AuthenticationPrincipal UserPrincipal currentUser) {

        projectService.deleteProject(uuid, currentUser.getId());
        return ResponseEntity.ok(ApiResponse.success("Projet supprimé", null));
    }

    @PostMapping("/{uuid}/members")
    @PreAuthorize("@projectSecurity.isProjectAdmin(#uuid, authentication)")
    public ResponseEntity<ApiResponse<Void>> addMember(
            @PathVariable UUID uuid,
            @Valid @RequestBody AddMemberRequest request,
            @AuthenticationPrincipal UserPrincipal currentUser) {

        projectService.addMember(uuid, request, currentUser.getId());
        return ResponseEntity.ok(ApiResponse.success("Membre ajouté", null));
    }
}

════════════════════════════════════════
31.4 ADMIN CONTROLLER
════════════════════════════════════════

@RestController
@RequestMapping("/api/v1/admin")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")  // Tous les endpoints de ce controller = ADMIN
public class AdminController {

    private final UserService    userService;
    private final ProjectService projectService;

    @GetMapping("/users")
    public ResponseEntity<ApiResponse<PagedResponse<UserResponse>>> getAllUsers(
            @ModelAttribute UserFilterRequest filter,
            @PageableDefault(size = 50) Pageable pageable) {

        Page<UserResponse> users = userService.getAllUsers(filter, pageable);
        return ResponseEntity.ok(ApiResponse.success(PagedResponse.from(users)));
    }

    @PutMapping("/users/{uuid}/status")
    public ResponseEntity<ApiResponse<UserResponse>> updateUserStatus(
            @PathVariable UUID uuid,
            @RequestBody UpdateUserStatusRequest request) {

        UserResponse user = userService.updateUserStatus(uuid, request.getStatus());
        return ResponseEntity.ok(ApiResponse.success("Statut mis à jour", user));
    }

    @GetMapping("/stats")
    public ResponseEntity<ApiResponse<PlatformStatsResponse>> getPlatformStats() {
        return ResponseEntity.ok(ApiResponse.success(userService.getPlatformStats()));
    }
}

════════════════════════════════════════
31.5 EXERCICES CHAPITRE 29-31
════════════════════════════════════════

EXERCICE 1 (Facile) — Concepts Spring Security
Expliquez la différence entre AuthenticationException (401) et
AccessDeniedException (403). Dans quel cas chaque exception est-elle levée ?

EXERCICE 2 (Facile) — Token JWT
Décodez manuellement ce JWT (Base64) et identifiez les claims :
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyQHRhc2tmbG93LmlvIiwicm9sZSI6IlVTRVIiLCJpYXQiOjE3MDUzMzEyMDAsImV4cCI6MTcwNTMzNDgwMH0.xxx

EXERCICE 3 (Intermédiaire) — JwtService
Ajoutez à JwtService une méthode :
  String extractUuid(String token)
qui extrait le claim "uuid" du token JWT.

EXERCICE 4 (Intermédiaire) — @PreAuthorize
Ajoutez les annotations @PreAuthorize appropriées sur ces endpoints :
  - GET /tasks/{uuid}           -> accessible si membre du projet
  - DELETE /tasks/{uuid}        -> accessible si créateur ou admin
  - POST /projects/{uuid}/archive -> accessible si propriétaire ou admin

EXERCICE 5 (Avancé) — Token Blacklist
Les access tokens JWT sont stateless (impossible de les révoquer directement).
Implémentez une blacklist en mémoire (ConcurrentHashSet) pour invalider
des tokens spécifiques lors du logout.
Expliquez les limitations de cette approche et comment Redis résout le problème.

EXERCICE 6 (Avancé) — Rotation des Refresh Tokens
Implémentez la rotation des refresh tokens :
  - Quand un refresh token est utilisé, le révoquer en BD
  - Générer un nouveau refresh token
  - Si un refresh token révoqué est utilisé -> révoquer TOUS les tokens de l'user
    (indique une possible compromission)

════════════════════════════════════════
31.6 CORRIGÉS CHAPITRE 29-31
════════════════════════════════════════

CORRIGÉ 3 — extractUuid :

public UUID extractUuid(String token) {
    String uuidStr = extractClaim(token,
        claims -> claims.get("uuid", String.class));
    return uuidStr != null ? UUID.fromString(uuidStr) : null;
}

CORRIGÉ 5 — Token Blacklist :

// JwtBlacklistService.java
@Service
public class JwtBlacklistService {

    // ConcurrentHashSet thread-safe pour l'accès concurrent
    private final Set<String> blacklistedTokens =
        ConcurrentHashMap.newKeySet();

    public void blacklistToken(String token) {
        blacklistedTokens.add(token);
    }

    public boolean isBlacklisted(String token) {
        return blacklistedTokens.contains(token);
    }

    // Nettoyage périodique des tokens expirés (évite les memory leaks)
    @Scheduled(fixedRate = 3600_000)  // toutes les heures
    public void cleanExpiredTokens(JwtService jwtService) {
        blacklistedTokens.removeIf(jwtService::isTokenExpired);
    }
}

// Dans JwtAuthenticationFilter :
if (jwtBlacklistService.isBlacklisted(token)) {
    filterChain.doFilter(request, response);
    return;
}

// Limitations :
// - Stockage en mémoire -> perdu au redémarrage
// - Pas scalable en cluster (chaque instance a sa propre blacklist)
// Solution Redis : stocker dans Redis avec TTL = durée restante du token
//   redisTemplate.opsForValue().set("blacklist:" + token, "1",
//       jwtService.extractExpiration(token).toInstant().minus(...)...)

CORRIGÉ 6 — Rotation Refresh Token :

@Transactional
public AuthTokenResponse refreshToken(String refreshToken, HttpServletResponse response) {

    // 1. Vérifier la signature
    if (!jwtService.isTokenSignatureValid(refreshToken)) {
        throw new InvalidTokenException("Refresh token invalide");
    }

    // 2. Chercher le token en BD
    RefreshToken storedToken = refreshTokenRepository.findByToken(refreshToken)
        .orElseThrow(() -> new InvalidTokenException("Refresh token non trouvé"));

    // 3. Si déjà révoqué -> compromission possible
    if (storedToken.getRevoked()) {
        // Révoquer TOUS les tokens de cet utilisateur
        refreshTokenRepository.revokeAllForUser(storedToken.getUser().getId());
        throw new SecurityException("Token compromis. Reconnectez-vous.");
    }

    // 4. Si expiré
    if (storedToken.isExpired()) {
        storedToken.setRevoked(true);
        refreshTokenRepository.save(storedToken);
        throw new TokenExpiredException("Refresh token expiré");
    }

    // 5. Rotation : révoquer l'ancien token
    storedToken.setRevoked(true);
    refreshTokenRepository.save(storedToken);

    // 6. Générer un nouveau pair de tokens
    User user = storedToken.getUser();
    UserPrincipal principal = UserPrincipal.fromUser(user,
        List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().name())));

    String newAccessToken = jwtService.generateAccessToken(principal);
    String newRefreshToken = jwtService.generateRefreshToken(user.getEmail());

    // 7. Persister le nouveau refresh token
    RefreshToken newStoredToken = RefreshToken.builder()
        .token(newRefreshToken)
        .user(user)
        .expiresAt(LocalDateTime.now().plusDays(7))
        .build();
    refreshTokenRepository.save(newStoredToken);

    // 8. Mettre le nouveau refresh token en cookie HttpOnly
    setCookieRefreshToken(response, newRefreshToken);

    return AuthTokenResponse.builder()
        .accessToken(newAccessToken)
        .tokenType("Bearer")
        .expiresIn(jwtService.getAccessTokenExpirationSeconds())
        .build();
}

================================================================================
   FIN PARTIE 8 — Prochaine partie : Gestion avancée des erreurs
================================================================================


================================================================================
  SPRING BOOT MASTER GUIDE — TASKFLOW BACKEND
  PARTIE 9 : GESTION DES ERREURS AVANCÉE
  Chapitres 32–33
================================================================================

TABLE DES MATIÈRES
──────────────────
  Chapitre 32 : Stratégie globale de gestion des erreurs
    32.1  Pourquoi une stratégie d'erreurs est critique en production
    32.2  Hiérarchie des exceptions dans TaskFlow
    32.3  Codes d'erreur métier (ErrorCode enum)
    32.4  @ControllerAdvice et @RestControllerAdvice
    32.5  GlobalExceptionHandler complet
    32.6  Gestion des erreurs de validation
    32.7  Gestion des erreurs Spring Security
    32.8  Gestion des erreurs de base de données
    32.9  Bonnes pratiques, erreurs fréquentes, exercices

  Chapitre 33 : Logging structuré & traçabilité des erreurs
    33.1  Logback et SLF4J — configuration complète
    33.2  MDC (Mapped Diagnostic Context) — corrélation de requêtes
    33.3  Correlation ID par requête HTTP
    33.4  Alertes et intégration Sentry/Slack
    33.5  Bonnes pratiques, erreurs fréquentes, exercices

================================================================================
  CHAPITRE 32 : STRATÉGIE GLOBALE DE GESTION DES ERREURS
================================================================================

────────────────────────────────────────────────────────────────────────────────
32.1  POURQUOI UNE STRATÉGIE D'ERREURS EST CRITIQUE EN PRODUCTION
────────────────────────────────────────────────────────────────────────────────

INTRODUCTION
────────────
Une API REST professionnelle ne laisse jamais fuiter ses erreurs internes.
Un message "NullPointerException at com.taskflow.backend.service.TaskService:142"
exposé au client est à la fois :
  — Un risque de sécurité (révèle la structure interne)
  — Une mauvaise UX (message incompréhensible)
  — Un manque de professionnalisme

La stratégie d'erreurs de TaskFlow repose sur 4 principes :

  1. COHÉRENCE   -> Toutes les erreurs ont le même format JSON
  2. CLARTÉ      -> Messages lisibles par le frontend et le développeur
  3. TRAÇABILITÉ -> Chaque erreur a un identifiant unique (correlationId)
  4. SÉCURITÉ    -> On ne révèle jamais les détails internes en production

FLUX D'UNE ERREUR DANS TASKFLOW
────────────────────────────────

  Client HTTP
      │
      [BLACK_DOWN-POINTING_TRIANGLE]
  DispatcherServlet
      │
      [BLACK_DOWN-POINTING_TRIANGLE]
  JwtAuthenticationFilter ──(401)──[BLACK_RIGHT-POINTING_POINTER] SecurityAuthEntryPoint
      │
      [BLACK_DOWN-POINTING_TRIANGLE]
  Controller (@Valid)
      │  ─ MethodArgumentNotValidException
      │  ─ ConstraintViolationException
      [BLACK_DOWN-POINTING_TRIANGLE]
  Service Layer
      │  ─ TaskNotFoundException
      │  ─ UnauthorizedException
      │  ─ BusinessRuleException
      [BLACK_DOWN-POINTING_TRIANGLE]
  Repository Layer
      │  ─ DataIntegrityViolationException
      │  ─ OptimisticLockingFailureException
      [BLACK_DOWN-POINTING_TRIANGLE]
  GlobalExceptionHandler (@RestControllerAdvice)
      │
      [BLACK_DOWN-POINTING_TRIANGLE]
  ApiResponse<Void> {
    success: false,
    message: "...",
    errors: [...],
    correlationId: "uuid"
  }
      │
      [BLACK_DOWN-POINTING_TRIANGLE]
  Client HTTP (JSON structuré, code HTTP approprié)


CODES HTTP UTILISÉS DANS TASKFLOW
──────────────────────────────────

  400 Bad Request          -> Validation échouée, données invalides
  401 Unauthorized         -> Non authentifié, token absent/expiré
  403 Forbidden            -> Authentifié mais non autorisé
  404 Not Found            -> Ressource introuvable
  409 Conflict             -> Conflit de données (email déjà pris, etc.)
  422 Unprocessable Entity -> Règle métier violée
  429 Too Many Requests    -> Rate limiting
  500 Internal Server Error -> Erreur inattendue côté serveur
  503 Service Unavailable  -> Service externe indisponible


────────────────────────────────────────────────────────────────────────────────
32.2  HIÉRARCHIE DES EXCEPTIONS DANS TASKFLOW
────────────────────────────────────────────────────────────────────────────────

ARBORESCENCE DES EXCEPTIONS
────────────────────────────

  RuntimeException
  └── TaskFlowException (base commune — abstract)
      ├── ResourceNotFoundException    (404)
      │   ├── TaskNotFoundException
      │   ├── UserNotFoundException
      │   ├── ProjectNotFoundException
      │   └── TagNotFoundException
      ├── AuthenticationException      (401)
      │   ├── InvalidCredentialsException
      │   ├── TokenExpiredException
      │   └── AccountLockedException
      ├── AuthorizationException       (403)
      │   └── InsufficientPermissionException
      ├── ConflictException            (409)
      │   ├── EmailAlreadyExistsException
      │   └── ProjectNameConflictException
      ├── BusinessRuleException        (422)
      │   ├── TaskStatusTransitionException
      │   ├── ProjectLimitExceededException
      │   └── SelfAssignmentException
      └── ExternalServiceException     (503)
          ├── EmailServiceException
          └── StorageServiceException


PACKAGE STRUCTURE
─────────────────

  com.taskflow.backend.exception/
  ├── base/
  │   └── TaskFlowException.java
  ├── auth/
  │   ├── InvalidCredentialsException.java
  │   ├── TokenExpiredException.java
  │   └── AccountLockedException.java
  ├── resource/
  │   ├── TaskNotFoundException.java
  │   ├── UserNotFoundException.java
  │   ├── ProjectNotFoundException.java
  │   └── TagNotFoundException.java
  ├── business/
  │   ├── TaskStatusTransitionException.java
  │   ├── ProjectLimitExceededException.java
  │   └── SelfAssignmentException.java
  ├── conflict/
  │   ├── EmailAlreadyExistsException.java
  │   └── ProjectNameConflictException.java
  └── external/
      ├── EmailServiceException.java
      └── StorageServiceException.java


────────────────────────────────────────────────────────────────────────────────
32.3  CODES D'ERREUR MÉTIER (ErrorCode ENUM)
────────────────────────────────────────────────────────────────────────────────

POURQUOI DES CODES D'ERREUR ?
──────────────────────────────
Les codes d'erreur permettent au frontend de distinguer précisément le type
d'erreur et d'afficher le bon message traduit, sans dépendre du texte anglais
du backend.

  Sans codes :  "Email already exists"
                -> Le frontend fait du pattern matching sur la chaîne
                -> Fragile, cassant si le message change

  Avec codes :  errorCode: "CONFLICT_EMAIL_EXISTS"
                -> Le frontend affiche "Cet email est déjà utilisé" (FR)
                -> ou "This email is already taken" (EN)
                -> Robuste, découplé


FILE: src/main/java/com/taskflow/backend/exception/ErrorCode.java
──────────────────────────────────────────────────────────────────

package com.taskflow.backend.exception;

/**
 * Codes d'erreur métier de TaskFlow.
 *
 * Convention de nommage :
 *   CATÉGORIE_DESCRIPTION_COURTE
 *
 * Catégories :
 *   AUTH_      -> Authentification / Autorisation
 *   TASK_      -> Gestion des tâches
 *   PROJECT_   -> Gestion des projets
 *   USER_      -> Gestion des utilisateurs
 *   CONFLICT_  -> Conflits de données
 *   VALIDATION_-> Validation des champs
 *   SYSTEM_    -> Erreurs système internes
 *   EXTERNAL_  -> Services externes
 */
public enum ErrorCode {

    // ─── AUTH ─────────────────────────────────────────────────────────────
    AUTH_INVALID_CREDENTIALS("Identifiants incorrects"),
    AUTH_TOKEN_EXPIRED("Le token d'accès a expiré"),
    AUTH_TOKEN_INVALID("Token invalide ou malformé"),
    AUTH_TOKEN_MISSING("Token d'authentification manquant"),
    AUTH_ACCOUNT_LOCKED("Compte temporairement verrouillé"),
    AUTH_ACCOUNT_DISABLED("Compte désactivé"),
    AUTH_REFRESH_TOKEN_INVALID("Refresh token invalide ou expiré"),
    AUTH_INSUFFICIENT_PERMISSIONS("Permissions insuffisantes pour cette action"),

    // ─── USER ─────────────────────────────────────────────────────────────
    USER_NOT_FOUND("Utilisateur introuvable"),
    USER_SELF_ACTION_FORBIDDEN("Action interdite sur votre propre compte"),

    // ─── PROJECT ──────────────────────────────────────────────────────────
    PROJECT_NOT_FOUND("Projet introuvable"),
    PROJECT_LIMIT_EXCEEDED("Limite de projets atteinte pour ce plan"),
    PROJECT_MEMBER_ALREADY_EXISTS("Cet utilisateur est déjà membre du projet"),
    PROJECT_MEMBER_NOT_FOUND("Membre du projet introuvable"),
    PROJECT_OWNER_CANNOT_LEAVE("Le propriétaire ne peut pas quitter son propre projet"),

    // ─── TASK ─────────────────────────────────────────────────────────────
    TASK_NOT_FOUND("Tâche introuvable"),
    TASK_INVALID_STATUS_TRANSITION("Transition de statut invalide"),
    TASK_CANNOT_ASSIGN_SELF("Impossible de s'auto-assigner"),
    TASK_ASSIGNEE_NOT_PROJECT_MEMBER("L'assigné doit être membre du projet"),
    TASK_PARENT_CYCLE_DETECTED("Cycle détecté dans la hiérarchie des tâches"),
    TASK_PARENT_NOT_IN_SAME_PROJECT("La tâche parente doit être dans le même projet"),

    // ─── TAG ──────────────────────────────────────────────────────────────
    TAG_NOT_FOUND("Tag introuvable"),
    TAG_DUPLICATE("Ce tag existe déjà dans ce projet"),

    // ─── CONFLICT ─────────────────────────────────────────────────────────
    CONFLICT_EMAIL_EXISTS("Cet email est déjà utilisé"),
    CONFLICT_PROJECT_NAME("Ce nom de projet existe déjà dans votre espace"),
    CONFLICT_OPTIMISTIC_LOCK("Conflit de mise à jour concurrente, veuillez réessayer"),

    // ─── VALIDATION ───────────────────────────────────────────────────────
    VALIDATION_FIELD_REQUIRED("Ce champ est obligatoire"),
    VALIDATION_FIELD_INVALID("Valeur de champ invalide"),
    VALIDATION_DATE_RANGE("La date de début doit être antérieure à la date de fin"),
    VALIDATION_FILE_TOO_LARGE("Fichier trop volumineux"),
    VALIDATION_FILE_TYPE_UNSUPPORTED("Type de fichier non supporté"),

    // ─── SYSTEM ───────────────────────────────────────────────────────────
    SYSTEM_INTERNAL_ERROR("Erreur interne du serveur"),
    SYSTEM_DATABASE_ERROR("Erreur de base de données"),
    SYSTEM_NOT_IMPLEMENTED("Fonctionnalité non encore disponible"),

    // ─── EXTERNAL ─────────────────────────────────────────────────────────
    EXTERNAL_EMAIL_SERVICE("Service email temporairement indisponible"),
    EXTERNAL_STORAGE_SERVICE("Service de stockage temporairement indisponible");

    private final String defaultMessage;

    ErrorCode(String defaultMessage) {
        this.defaultMessage = defaultMessage;
    }

    public String getDefaultMessage() {
        return defaultMessage;
    }
}


────────────────────────────────────────────────────────────────────────────────
32.4  EXCEPTIONS DE BASE TASKFLOW
────────────────────────────────────────────────────────────────────────────────

FILE: src/main/java/com/taskflow/backend/exception/base/TaskFlowException.java
────────────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.exception.base;

import com.taskflow.backend.exception.ErrorCode;
import lombok.Getter;
import org.springframework.http.HttpStatus;

/**
 * Exception de base pour toutes les exceptions métier de TaskFlow.
 *
 * Toutes les exceptions métier héritent de cette classe.
 * Elle porte le code HTTP, le code d'erreur métier et le message.
 */
@Getter
public abstract class TaskFlowException extends RuntimeException {

    private final HttpStatus status;
    private final ErrorCode errorCode;

    protected TaskFlowException(HttpStatus status,
                                 ErrorCode errorCode,
                                 String message) {
        super(message);
        this.status    = status;
        this.errorCode = errorCode;
    }

    protected TaskFlowException(HttpStatus status,
                                 ErrorCode errorCode,
                                 String message,
                                 Throwable cause) {
        super(message, cause);
        this.status    = status;
        this.errorCode = errorCode;
    }

    /**
     * Retourne le code HTTP numérique (ex: 404, 422).
     */
    public int getHttpStatusCode() {
        return status.value();
    }
}


FILE: src/main/java/com/taskflow/backend/exception/base/ResourceNotFoundException.java
─────────────────────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.exception.base;

import com.taskflow.backend.exception.ErrorCode;
import org.springframework.http.HttpStatus;

/**
 * Exception 404 — ressource introuvable.
 * Base pour TaskNotFoundException, UserNotFoundException, etc.
 */
public abstract class ResourceNotFoundException extends TaskFlowException {

    protected ResourceNotFoundException(ErrorCode errorCode, String message) {
        super(HttpStatus.NOT_FOUND, errorCode, message);
    }
}


FILE: src/main/java/com/taskflow/backend/exception/base/ConflictException.java
────────────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.exception.base;

import com.taskflow.backend.exception.ErrorCode;
import org.springframework.http.HttpStatus;

/**
 * Exception 409 — conflit de données.
 */
public abstract class ConflictException extends TaskFlowException {

    protected ConflictException(ErrorCode errorCode, String message) {
        super(HttpStatus.CONFLICT, errorCode, message);
    }
}


FILE: src/main/java/com/taskflow/backend/exception/base/BusinessRuleException.java
──────────────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.exception.base;

import com.taskflow.backend.exception.ErrorCode;
import org.springframework.http.HttpStatus;

/**
 * Exception 422 — règle métier violée.
 * Différent de 400 : les données sont valides syntaxiquement,
 * mais violent une règle de gestion.
 */
public abstract class BusinessRuleException extends TaskFlowException {

    protected BusinessRuleException(ErrorCode errorCode, String message) {
        super(HttpStatus.UNPROCESSABLE_ENTITY, errorCode, message);
    }
}


────────────────────────────────────────────────────────────────────────────────
EXCEPTIONS CONCRÈTES — EXEMPLES
────────────────────────────────────────────────────────────────────────────────

FILE: src/main/java/com/taskflow/backend/exception/resource/TaskNotFoundException.java
────────────────────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.exception.resource;

import com.taskflow.backend.exception.ErrorCode;
import com.taskflow.backend.exception.base.ResourceNotFoundException;
import java.util.UUID;

public class TaskNotFoundException extends ResourceNotFoundException {

    public TaskNotFoundException(UUID taskUuid) {
        super(
            ErrorCode.TASK_NOT_FOUND,
            "Tâche introuvable avec l'UUID : " + taskUuid
        );
    }

    public TaskNotFoundException(String message) {
        super(ErrorCode.TASK_NOT_FOUND, message);
    }
}


FILE: src/main/java/com/taskflow/backend/exception/resource/UserNotFoundException.java
────────────────────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.exception.resource;

import com.taskflow.backend.exception.ErrorCode;
import com.taskflow.backend.exception.base.ResourceNotFoundException;
import java.util.UUID;

public class UserNotFoundException extends ResourceNotFoundException {

    public UserNotFoundException(UUID userUuid) {
        super(ErrorCode.USER_NOT_FOUND, "Utilisateur introuvable : " + userUuid);
    }

    public UserNotFoundException(String email) {
        super(ErrorCode.USER_NOT_FOUND, "Utilisateur introuvable avec l'email : " + email);
    }
}


FILE: src/main/java/com/taskflow/backend/exception/resource/ProjectNotFoundException.java
──────────────────────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.exception.resource;

import com.taskflow.backend.exception.ErrorCode;
import com.taskflow.backend.exception.base.ResourceNotFoundException;
import java.util.UUID;

public class ProjectNotFoundException extends ResourceNotFoundException {

    public ProjectNotFoundException(UUID projectUuid) {
        super(ErrorCode.PROJECT_NOT_FOUND, "Projet introuvable : " + projectUuid);
    }
}


FILE: src/main/java/com/taskflow/backend/exception/conflict/EmailAlreadyExistsException.java
──────────────────────────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.exception.conflict;

import com.taskflow.backend.exception.ErrorCode;
import com.taskflow.backend.exception.base.ConflictException;

public class EmailAlreadyExistsException extends ConflictException {

    public EmailAlreadyExistsException(String email) {
        super(
            ErrorCode.CONFLICT_EMAIL_EXISTS,
            "L'email '" + email + "' est déjà associé à un compte"
        );
    }
}


FILE: src/main/java/com/taskflow/backend/exception/business/TaskStatusTransitionException.java
────────────────────────────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.exception.business;

import com.taskflow.backend.exception.ErrorCode;
import com.taskflow.backend.exception.base.BusinessRuleException;
import com.taskflow.backend.model.enums.TaskStatus;

public class TaskStatusTransitionException extends BusinessRuleException {

    public TaskStatusTransitionException(TaskStatus current, TaskStatus target) {
        super(
            ErrorCode.TASK_INVALID_STATUS_TRANSITION,
            String.format(
                "Impossible de passer du statut '%s' à '%s'. " +
                "Transitions autorisées : %s",
                current.name(),
                target.name(),
                String.join(", ",
                    current.getAllowedTransitions().stream()
                           .map(TaskStatus::name)
                           .toList()
                )
            )
        );
    }
}


FILE: src/main/java/com/taskflow/backend/exception/auth/AccountLockedException.java
─────────────────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.exception.auth;

import com.taskflow.backend.exception.ErrorCode;
import com.taskflow.backend.exception.base.TaskFlowException;
import org.springframework.http.HttpStatus;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

public class AccountLockedException extends TaskFlowException {

    public AccountLockedException(LocalDateTime unlocksAt) {
        super(
            HttpStatus.UNAUTHORIZED,
            ErrorCode.AUTH_ACCOUNT_LOCKED,
            String.format(
                "Compte verrouillé suite à trop de tentatives échouées. " +
                "Réessayez après : %s",
                unlocksAt.format(DateTimeFormatter.ofPattern("HH:mm:ss"))
            )
        );
    }
}


────────────────────────────────────────────────────────────────────────────────
32.5  GLOBALEXCEPTIONHANDLER COMPLET
────────────────────────────────────────────────────────────────────────────────

THÉORIE : @RestControllerAdvice
────────────────────────────────
@RestControllerAdvice = @ControllerAdvice + @ResponseBody
-> Intercepte les exceptions levées par n'importe quel @RestController
-> Retourne automatiquement du JSON (pas besoin de @ResponseBody sur chaque méthode)

@ExceptionHandler(MonException.class)
-> Méthode invoquée quand MonException (ou ses sous-classes) est levée
-> Peut recevoir l'exception + HttpServletRequest en paramètre

Ordre de priorité des handlers :
  1. Handler pour la classe exacte (TaskNotFoundException)
  2. Handler pour la superclasse (ResourceNotFoundException)
  3. Handler pour Exception.class (catch-all)

FILE: src/main/java/com/taskflow/backend/exception/GlobalExceptionHandler.java
─────────────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.exception;

import com.taskflow.backend.dto.response.ApiResponse;
import com.taskflow.backend.exception.base.TaskFlowException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.NoHandlerFoundException;

import java.util.List;

/**
 * Gestionnaire global des exceptions pour l'API TaskFlow.
 *
 * Chaque méthode handler :
 *  - Logue l'erreur avec le niveau approprié
 *  - Retourne un ApiResponse<Void> structuré avec correlationId
 *  - N'expose jamais les détails internes (stack traces, SQL, etc.)
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    // ─────────────────────────────────────────────────────────────────────────
    // 1. EXCEPTIONS MÉTIER TASKFLOW (TaskFlowException et sous-classes)
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Gère toutes les exceptions héritant de TaskFlowException.
     * Ce handler est le principal pour les erreurs 4xx métier.
     *
     * Logue en WARN (pas ERROR) car c'est une erreur attendue.
     */
    @ExceptionHandler(TaskFlowException.class)
    public ResponseEntity<ApiResponse<Void>> handleTaskFlowException(
            TaskFlowException ex,
            HttpServletRequest request) {

        log.warn(
            "[{}] {} — {} {} : {}",
            ex.getErrorCode().name(),
            ex.getStatus(),
            request.getMethod(),
            request.getRequestURI(),
            ex.getMessage()
        );

        return ResponseEntity
            .status(ex.getStatus())
            .body(ApiResponse.error(
                ex.getMessage(),
                List.of(ex.getErrorCode().name()),
                getCorrelationId(request)
            ));
    }


    // ─────────────────────────────────────────────────────────────────────────
    // 2. VALIDATION — @Valid sur RequestBody
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Levée quand @Valid échoue sur un @RequestBody.
     * Collecte tous les messages de validation par champ.
     *
     * Exemple de réponse :
     * {
     *   "success": false,
     *   "message": "Validation échouée (2 erreur(s))",
     *   "errors": ["title : Le titre est obligatoire", "dueDate : Date invalide"]
     * }
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<Void>> handleValidationException(
            MethodArgumentNotValidException ex,
            HttpServletRequest request) {

        List<String> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(this::formatFieldError)
            .toList();

        String message = String.format(
            "Validation échouée (%d erreur(s))", errors.size()
        );

        log.debug("[VALIDATION] {} {} — {} : {}",
            request.getMethod(), request.getRequestURI(),
            message, errors);

        return ResponseEntity
            .badRequest()
            .body(ApiResponse.error(message, errors, getCorrelationId(request)));
    }

    /**
     * Levée quand @Validated échoue sur des paramètres @RequestParam / @PathVariable.
     */
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ApiResponse<Void>> handleConstraintViolation(
            ConstraintViolationException ex,
            HttpServletRequest request) {

        List<String> errors = ex.getConstraintViolations()
            .stream()
            .map(cv -> {
                // "methodName.paramName : message"
                String path = cv.getPropertyPath().toString();
                String field = path.contains(".")
                    ? path.substring(path.lastIndexOf('.') + 1)
                    : path;
                return field + " : " + cv.getMessage();
            })
            .toList();

        return ResponseEntity
            .badRequest()
            .body(ApiResponse.error(
                "Paramètre(s) invalide(s)", errors, getCorrelationId(request)
            ));
    }


    // ─────────────────────────────────────────────────────────────────────────
    // 3. ERREURS HTTP SPRING MVC
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Corps JSON illisible (JSON malformé, type incompatible).
     */
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<ApiResponse<Void>> handleMessageNotReadable(
            HttpMessageNotReadableException ex,
            HttpServletRequest request) {

        log.debug("[BAD_REQUEST] Corps JSON illisible : {}", ex.getMessage());

        return ResponseEntity
            .badRequest()
            .body(ApiResponse.error(
                "Corps de la requête illisible ou mal formé",
                List.of(ErrorCode.VALIDATION_FIELD_INVALID.name()),
                getCorrelationId(request)
            ));
    }

    /**
     * Type de paramètre incompatible (ex: UUID invalide dans le path).
     */
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<ApiResponse<Void>> handleTypeMismatch(
            MethodArgumentTypeMismatchException ex,
            HttpServletRequest request) {

        String message = String.format(
            "Paramètre '%s' invalide : valeur '%s' incompatible avec le type attendu %s",
            ex.getName(),
            ex.getValue(),
            ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "inconnu"
        );

        return ResponseEntity
            .badRequest()
            .body(ApiResponse.error(message, getCorrelationId(request)));
    }

    /**
     * Paramètre @RequestParam obligatoire absent.
     */
    @ExceptionHandler(MissingServletRequestParameterException.class)
    public ResponseEntity<ApiResponse<Void>> handleMissingParam(
            MissingServletRequestParameterException ex,
            HttpServletRequest request) {

        return ResponseEntity
            .badRequest()
            .body(ApiResponse.error(
                "Paramètre obligatoire manquant : " + ex.getParameterName(),
                getCorrelationId(request)
            ));
    }

    /**
     * Route inexistante (404).
     * Nécessite spring.mvc.throw-exception-if-no-handler-found=true
     * et spring.web.resources.add-mappings=false dans application.properties
     */
    @ExceptionHandler(NoHandlerFoundException.class)
    public ResponseEntity<ApiResponse<Void>> handleNoHandlerFound(
            NoHandlerFoundException ex,
            HttpServletRequest request) {

        return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body(ApiResponse.error(
                "Endpoint introuvable : " + ex.getRequestURL(),
                getCorrelationId(request)
            ));
    }


    // ─────────────────────────────────────────────────────────────────────────
    // 4. ERREURS SPRING SECURITY
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Erreur d'authentification Spring Security.
     * (Distincte de nos AuthenticationException métier)
     */
    @ExceptionHandler(AuthenticationException.class)
    public ResponseEntity<ApiResponse<Void>> handleSpringAuthException(
            AuthenticationException ex,
            HttpServletRequest request) {

        log.warn("[AUTH_FAILED] {} {} : {}",
            request.getMethod(), request.getRequestURI(), ex.getMessage());

        String message = (ex instanceof BadCredentialsException)
            ? "Identifiants incorrects"
            : "Authentification requise";

        return ResponseEntity
            .status(HttpStatus.UNAUTHORIZED)
            .body(ApiResponse.error(
                message,
                List.of(ErrorCode.AUTH_INVALID_CREDENTIALS.name()),
                getCorrelationId(request)
            ));
    }

    /**
     * Accès refusé par Spring Security (@PreAuthorize, etc.).
     */
    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ApiResponse<Void>> handleAccessDenied(
            AccessDeniedException ex,
            HttpServletRequest request) {

        log.warn("[ACCESS_DENIED] {} {} : {}",
            request.getMethod(), request.getRequestURI(), ex.getMessage());

        return ResponseEntity
            .status(HttpStatus.FORBIDDEN)
            .body(ApiResponse.error(
                "Accès refusé : vous n'avez pas les permissions nécessaires",
                List.of(ErrorCode.AUTH_INSUFFICIENT_PERMISSIONS.name()),
                getCorrelationId(request)
            ));
    }


    // ─────────────────────────────────────────────────────────────────────────
    // 5. ERREURS BASE DE DONNÉES
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Violation de contrainte DB (unique key, FK, not null).
     * NE PAS exposer le message SQL brut !
     *
     * On traduit les contraintes connues en messages lisibles.
     */
    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<ApiResponse<Void>> handleDataIntegrityViolation(
            DataIntegrityViolationException ex,
            HttpServletRequest request) {

        log.error("[DB_CONSTRAINT] {} {} : {}",
            request.getMethod(), request.getRequestURI(),
            ex.getMostSpecificCause().getMessage());

        String message = translateDbConstraintMessage(
            ex.getMostSpecificCause().getMessage()
        );

        return ResponseEntity
            .status(HttpStatus.CONFLICT)
            .body(ApiResponse.error(
                message,
                List.of(ErrorCode.SYSTEM_DATABASE_ERROR.name()),
                getCorrelationId(request)
            ));
    }

    /**
     * Conflit de verrou optimiste (deux requêtes modifient la même entité).
     * L'entité doit avoir @Version pour activer l'optimistic locking.
     */
    @ExceptionHandler(OptimisticLockingFailureException.class)
    public ResponseEntity<ApiResponse<Void>> handleOptimisticLock(
            OptimisticLockingFailureException ex,
            HttpServletRequest request) {

        log.warn("[OPTIMISTIC_LOCK] {} {} — Conflit de version",
            request.getMethod(), request.getRequestURI());

        return ResponseEntity
            .status(HttpStatus.CONFLICT)
            .body(ApiResponse.error(
                "Conflit de mise à jour : la ressource a été modifiée entre-temps. " +
                "Veuillez recharger et réessayer.",
                List.of(ErrorCode.CONFLICT_OPTIMISTIC_LOCK.name()),
                getCorrelationId(request)
            ));
    }


    // ─────────────────────────────────────────────────────────────────────────
    // 6. CATCH-ALL — Erreur 500 inattendue
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Handler de dernier recours pour toute exception non gérée.
     *
     * IMPORTANT :
     *  - Logue en ERROR (alerte l'équipe)
     *  - Ne révèle JAMAIS les détails techniques au client
     *  - Utilise le correlationId pour permettre de retrouver les logs
     */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleUnexpectedException(
            Exception ex,
            HttpServletRequest request) {

        String correlationId = getCorrelationId(request);

        // Log complet avec stack trace pour l'équipe technique
        log.error(
            "[UNEXPECTED_ERROR] correlationId={} — {} {} : {}",
            correlationId,
            request.getMethod(),
            request.getRequestURI(),
            ex.getMessage(),
            ex  // Spring Logback logge automatiquement le stack trace
        );

        // Message générique côté client (pas de stack trace !)
        return ResponseEntity
            .internalServerError()
            .body(ApiResponse.error(
                "Une erreur interne est survenue. " +
                "Veuillez contacter le support avec la référence : " + correlationId,
                List.of(ErrorCode.SYSTEM_INTERNAL_ERROR.name()),
                correlationId
            ));
    }


    // ─────────────────────────────────────────────────────────────────────────
    // MÉTHODES UTILITAIRES PRIVÉES
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Formate une erreur de validation de champ.
     * Format : "nomDuChamp : message d'erreur"
     */
    private String formatFieldError(FieldError error) {
        String field   = error.getField();
        String message = error.getDefaultMessage();
        Object rejected = error.getRejectedValue();

        if (rejected != null && !rejected.toString().isBlank()) {
            return String.format("%s : %s (valeur rejetée : '%s')",
                field, message, rejected);
        }
        return field + " : " + message;
    }

    /**
     * Traduit les messages d'erreur de contrainte PostgreSQL
     * en messages lisibles par l'utilisateur.
     *
     * Les noms de contraintes doivent correspondre aux contraintes
     * définies dans les migrations Flyway (V1__init_schema.sql).
     */
    private String translateDbConstraintMessage(String dbMessage) {
        if (dbMessage == null) return "Contrainte de données violée";

        // uk_users_email -> email déjà utilisé
        if (dbMessage.contains("uk_users_email")) {
            return "Cet email est déjà associé à un compte existant";
        }
        // uk_projects_owner_name -> nom de projet déjà utilisé
        if (dbMessage.contains("uk_projects_owner_name")) {
            return "Vous avez déjà un projet avec ce nom";
        }
        // uk_project_members -> déjà membre
        if (dbMessage.contains("uk_project_members")) {
            return "Cet utilisateur est déjà membre de ce projet";
        }
        // uk_tags_project_name -> tag déjà existant
        if (dbMessage.contains("uk_tags_project_name")) {
            return "Ce tag existe déjà dans ce projet";
        }
        // Violation FK générique
        if (dbMessage.contains("foreign key") || dbMessage.contains("fk_")) {
            return "Référence vers une ressource inexistante ou déjà supprimée";
        }
        // Not null générique
        if (dbMessage.contains("not-null") || dbMessage.contains("null value")) {
            return "Un champ obligatoire n'a pas été fourni";
        }

        // Message générique (ne révèle pas le SQL)
        return "Contrainte de données violée";
    }

    /**
     * Récupère le correlationId depuis le header HTTP (posé par le filtre),
     * ou génère un UUID si absent.
     */
    private String getCorrelationId(HttpServletRequest request) {
        String id = request.getHeader("X-Correlation-ID");
        return (id != null && !id.isBlank()) ? id
             : java.util.UUID.randomUUID().toString();
    }
}


────────────────────────────────────────────────────────────────────────────────
32.6  MISE À JOUR DE ApiResponse<T>
────────────────────────────────────────────────────────────────────────────────

On enrichit ApiResponse avec le correlationId et les factory methods pour erreurs :

FILE: src/main/java/com/taskflow/backend/dto/response/ApiResponse.java
────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.dto.response;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Getter;

import java.time.Instant;
import java.util.List;

/**
 * Réponse API unifiée pour toutes les réponses de TaskFlow.
 *
 * Structure JSON :
 * {
 *   "success": true/false,
 *   "message": "...",
 *   "data": {...},         // null si pas de données (omis si null)
 *   "errors": [...],       // null si pas d'erreurs (omis si null)
 *   "correlationId": "...",// présent uniquement sur les erreurs
 *   "timestamp": "..."
 * }
 */
@Getter
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResponse<T> {

    @JsonProperty("success")
    private final boolean success;

    @JsonProperty("message")
    private final String message;

    @JsonProperty("data")
    private final T data;

    @JsonProperty("errors")
    private final List<String> errors;

    @JsonProperty("correlationId")
    private final String correlationId;

    @JsonProperty("timestamp")
    private final Instant timestamp;


    // ─── FACTORY METHODS — SUCCÈS ──────────────────────────────────────────

    public static <T> ApiResponse<T> success(String message, T data) {
        return ApiResponse.<T>builder()
            .success(true)
            .message(message)
            .data(data)
            .timestamp(Instant.now())
            .build();
    }

    public static <T> ApiResponse<T> success(T data) {
        return success("Opération réussie", data);
    }

    public static ApiResponse<Void> success(String message) {
        return ApiResponse.<Void>builder()
            .success(true)
            .message(message)
            .timestamp(Instant.now())
            .build();
    }


    // ─── FACTORY METHODS — ERREUR ──────────────────────────────────────────

    public static ApiResponse<Void> error(String message,
                                           List<String> errors,
                                           String correlationId) {
        return ApiResponse.<Void>builder()
            .success(false)
            .message(message)
            .errors(errors)
            .correlationId(correlationId)
            .timestamp(Instant.now())
            .build();
    }

    public static ApiResponse<Void> error(String message, String correlationId) {
        return error(message, null, correlationId);
    }

    public static ApiResponse<Void> error(String message) {
        return error(message, null, null);
    }


    // ─── FACTORY METHODS — PAGINATION ─────────────────────────────────────

    public static <T> ApiResponse<PagedResponse<T>> paged(
            String message, PagedResponse<T> pagedData) {
        return ApiResponse.<PagedResponse<T>>builder()
            .success(true)
            .message(message)
            .data(pagedData)
            .timestamp(Instant.now())
            .build();
    }
}


────────────────────────────────────────────────────────────────────────────────
32.7  CONFIGURATION application.properties POUR LES ERREURS
────────────────────────────────────────────────────────────────────────────────

Dans src/main/resources/application.properties, ajouter :

# ─── Gestion des erreurs ───────────────────────────────────────────────────────

# Désactiver la white label error page de Spring Boot
server.error.whitelabel.enabled=false

# Lever NoHandlerFoundException quand aucune route ne correspond
# (nécessaire pour que GlobalExceptionHandler intercepte les 404)
spring.mvc.throw-exception-if-no-handler-found=true

# Désactiver la résolution automatique des ressources statiques
# (sinon les 404 sont gérées par le handler de ressources statiques)
spring.web.resources.add-mappings=false

# En production : ne pas inclure le message d'exception dans les réponses d'erreur
# (Spring Boot ErrorController — pour les erreurs hors @RestController)
server.error.include-message=never
server.error.include-exception=false
server.error.include-stacktrace=never
server.error.include-binding-errors=never


────────────────────────────────────────────────────────────────────────────────
32.8  EXEMPLES DE RÉPONSES D'ERREUR JSON
────────────────────────────────────────────────────────────────────────────────

── EXEMPLE 1 : Tâche introuvable (404) ─────────────────────────────────────────

HTTP/1.1 404 Not Found
Content-Type: application/json

{
  "success": false,
  "message": "Tâche introuvable avec l'UUID : 550e8400-e29b-41d4-a716-446655440000",
  "errors": ["TASK_NOT_FOUND"],
  "correlationId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "timestamp": "2024-11-15T14:23:45.123Z"
}


── EXEMPLE 2 : Validation échouée (400) ────────────────────────────────────────

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "success": false,
  "message": "Validation échouée (3 erreur(s))",
  "errors": [
    "title : Le titre est obligatoire",
    "title : Le titre doit contenir entre 1 et 255 caractères (valeur rejetée : '')",
    "dueDate : La date d'échéance doit être dans le futur"
  ],
  "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "timestamp": "2024-11-15T14:23:45.123Z"
}


── EXEMPLE 3 : Transition de statut invalide (422) ─────────────────────────────

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "success": false,
  "message": "Impossible de passer du statut 'DONE' à 'TODO'. Transitions autorisées : ARCHIVED",
  "errors": ["TASK_INVALID_STATUS_TRANSITION"],
  "correlationId": "c3d4e5f6-a7b8-9012-cdef-345678901234",
  "timestamp": "2024-11-15T14:23:45.123Z"
}


── EXEMPLE 4 : Erreur 500 inattendue ───────────────────────────────────────────

HTTP/1.1 500 Internal Server Error
Content-Type: application/json

{
  "success": false,
  "message": "Une erreur interne est survenue. Veuillez contacter le support avec la référence : d4e5f6a7-b8c9-0123-def0-456789012345",
  "errors": ["SYSTEM_INTERNAL_ERROR"],
  "correlationId": "d4e5f6a7-b8c9-0123-def0-456789012345",
  "timestamp": "2024-11-15T14:23:45.123Z"
}

Note : la stack trace est dans les LOGS serveur (avec le même correlationId),
       pas dans la réponse HTTP.


── EXEMPLE 5 : Email déjà existant (409) ───────────────────────────────────────

HTTP/1.1 409 Conflict
Content-Type: application/json

{
  "success": false,
  "message": "L'email 'alice@example.com' est déjà associé à un compte",
  "errors": ["CONFLICT_EMAIL_EXISTS"],
  "correlationId": "e5f6a7b8-c9d0-1234-ef01-567890123456",
  "timestamp": "2024-11-15T14:23:45.123Z"
}


────────────────────────────────────────────────────────────────────────────────
32.9  BONNES PRATIQUES — ERREURS FRÉQUENTES — EXERCICES
────────────────────────────────────────────────────────────────────────────────

BONNES PRATIQUES
────────────────
1. JAMAIS de messages d'erreur différents selon l'environnement.
   Un utilisateur malveillant pourrait déduire si un email existe
   à partir d'un message "Email incorrect" vs "Mot de passe incorrect".
   -> Toujours "Identifiants incorrects" (message générique)

2. Logger les erreurs AVANT de les transformer.
   Ne pas attraper une exception, la transformer, puis la relancer
   sans logger l'originale.

3. Codes d'erreur stables ≠ messages.
   Les messages peuvent être traduits/modifiés.
   Les codes (TASK_NOT_FOUND) sont des contrats stables avec le frontend.

4. Distinguer 401 vs 403 :
   401 = "Je ne sais pas qui tu es"   (non authentifié)
   403 = "Je sais qui tu es, mais non" (non autorisé)

5. Ne JAMAIS logger les mots de passe, tokens JWT, ou données sensibles.
   Utiliser @JsonIgnore + logger uniquement les UUIDs et emails.

6. Le handler catch-all (Exception.class) doit logger le stack trace COMPLET.
   C'est la seule façon de déboguer les erreurs inattendues en production.


ERREURS FRÉQUENTES
──────────────────
[X] Oublier @RestControllerAdvice (utiliser @ControllerAdvice sans @ResponseBody)
  -> Les méthodes handler retournent du HTML, pas du JSON

[X] Handler trop général en premier
  @ExceptionHandler(Exception.class)  // En premier -> intercepte tout !
  @ExceptionHandler(TaskNotFoundException.class)  // Jamais atteint
  -> Solution : Spring utilise le handler le plus spécifique, mais rester vigilant

[X] Lever une exception dans le handler d'exception
  -> Cause une boucle infinie ou une réponse vide
  -> Toujours wrapper le code dans les handlers avec try/catch

[X] Retourner des données sensibles dans les messages d'erreur
  "Column 'password_hash' cannot be null" — exposé directement
  -> Toujours passer par translateDbConstraintMessage()

[X] Utiliser System.out.println() au lieu du logger
  -> Pas de niveau (INFO/WARN/ERROR), pas de contexte, pas de format
  -> Toujours utiliser @Slf4j + log.warn() / log.error()


EXERCICES
─────────

Niveau Débutant :
  1. Créer une exception ProjectLimitExceededException qui indique
     le nombre de projets actuel et la limite du plan.
     Format du message : "Limite de 5 projets atteinte (plan Free). Passez à Pro."

  2. Ajouter un handler dans GlobalExceptionHandler pour
     HttpRequestMethodNotAllowedException (méthode HTTP non autorisée, 405).

  3. Écrire un test unitaire (avec MockMvc) qui vérifie qu'un POST sur
     /api/v1/tasks avec un body JSON vide retourne bien un 400 avec le champ
     "success": false.

Niveau Intermédiaire :
  4. Implémenter un rate limiter simple sur l'endpoint /api/v1/auth/login
     (max 10 requêtes par minute par IP) et lever une TooManyRequestsException
     (429) avec un header Retry-After.

  5. Créer un handler pour les exceptions liées à Hibernate
     (LazyInitializationException, HibernateException) qui retourne un 500
     générique sans exposer les détails Hibernate.

  6. Ajouter un champ "path" dans ApiResponse<Void> pour inclure l'URL
     de la requête dans chaque réponse d'erreur.

Niveau Avancé :
  7. Implémenter un ErrorResponseCache qui, si la même exception (même
     errorCode + même userId) est levée plus de 10 fois en 5 minutes,
     envoie une alerte email à l'équipe (utiliser un event Spring).

  8. Créer un ErrorLogRepository et une entité ErrorLog en base de données
     qui persiste toutes les erreurs 5xx avec : correlationId, userId, path,
     method, errorCode, stackTrace (tronqué à 2000 chars), createdAt.

  9. Implémenter un endpoint admin GET /api/v1/admin/errors qui permet
     de rechercher les erreurs récentes par correlationId, userId ou errorCode
     avec pagination.


================================================================================
  CHAPITRE 33 : LOGGING STRUCTURÉ & TRAÇABILITÉ DES ERREURS
================================================================================

────────────────────────────────────────────────────────────────────────────────
33.1  LOGBACK ET SLF4J — CONFIGURATION COMPLÈTE
────────────────────────────────────────────────────────────────────────────────

INTRODUCTION : SLF4J vs Logback
────────────────────────────────

  SLF4J (Simple Logging Facade for Java)
    -> Interface abstraite : log.info(), log.error(), etc.
    -> Indépendant de l'implémentation sous-jacente

  Logback
    -> Implémentation concrète de SLF4J
    -> Inclus par défaut dans Spring Boot (via spring-boot-starter-logging)
    -> Plus performant que Log4j (logger asynchrone, appenders configurables)

  Avec Lombok :
    @Slf4j
    public class TaskService {
        // Génère automatiquement :
        // private static final Logger log = LoggerFactory.getLogger(TaskService.class);
    }

NIVEAUX DE LOG (du plus verbeux au plus critique)
──────────────────────────────────────────────────

  TRACE -> Détails très fins (entrée/sortie de méthodes)
  DEBUG -> Informations de débogage (valeurs de variables)
  INFO  -> Événements importants (démarrage, connexion, opération réussie)
  WARN  -> Situation anormale mais récupérable (erreur 4xx, retry)
  ERROR -> Erreur critique nécessitant attention (exception 5xx, perte de données)

  En production :
    -> Activer INFO minimum (pas de DEBUG/TRACE — trop de volume)
    -> ERROR et WARN sont toujours enregistrés

FORMAT DE LOG STRUCTURÉ
────────────────────────
Un log structuré est un log parseable par des outils comme ELK (Elasticsearch,
Logstash, Kibana) ou Loki + Grafana.

  Non structuré (mauvais en production) :
    2024-11-15 14:23:45 ERROR Erreur dans TaskService

  Structuré (JSON — recommandé en production) :
    {
      "timestamp": "2024-11-15T14:23:45.123Z",
      "level": "ERROR",
      "logger": "com.taskflow.backend.service.TaskServiceImpl",
      "message": "Tâche introuvable",
      "correlationId": "f47ac10b",
      "userId": "a1b2c3d4",
      "taskUuid": "550e8400"
    }


FILE: src/main/resources/logback-spring.xml
────────────────────────────────────────────

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <!-- ── Propriétés ─────────────────────────────────────────────────────── -->
    <springProperty scope="context" name="APP_NAME"
                    source="spring.application.name"
                    defaultValue="taskflow"/>
    <springProperty scope="context" name="APP_ENV"
                    source="spring.profiles.active"
                    defaultValue="local"/>

    <!-- ── Appender CONSOLE (développement) ──────────────────────────────── -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <!-- Format lisible en développement -->
            <pattern>
%d{HH:mm:ss.SSS} %highlight(%-5level) [%cyan(%thread)] %yellow(%logger{36})
  %msg %mdc{correlationId:- }%n
            </pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>

    <!-- ── Appender FILE (production — JSON structuré) ────────────────────── -->
    <appender name="FILE_JSON" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/taskflow.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!-- Rotation journalière, conservation 30 jours -->
            <fileNamePattern>logs/taskflow.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <timeBasedFileNamingAndTriggeringPolicy
                    class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
                <maxFileSize>100MB</maxFileSize>
            </timeBasedFileNamingAndTriggeringPolicy>
            <maxHistory>30</maxHistory>
            <totalSizeCap>3GB</totalSizeCap>
        </rollingPolicy>
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <!-- Inclut automatiquement MDC (correlationId, userId, etc.) -->
            <includeMdc>true</includeMdc>
            <!-- Champs personnalisés -->
            <customFields>{"app":"${APP_NAME}","env":"${APP_ENV}"}</customFields>
            <!-- Exclure les champs verbeux -->
            <excludeMdcKeyName>ignore</excludeMdcKeyName>
        </encoder>
    </appender>

    <!-- ── Appender ERREURS (fichier séparé pour les ERROR) ──────────────── -->
    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/taskflow-errors.log</file>
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>ERROR</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>logs/taskflow-errors.%d{yyyy-MM-dd}.log.gz</fileNamePattern>
            <maxHistory>90</maxHistory>
        </rollingPolicy>
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <includeMdc>true</includeMdc>
        </encoder>
    </appender>

    <!-- ── Appender ASYNC (wrapper pour performance en production) ───────── -->
    <appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
        <!-- Ne pas perdre de logs si la queue est pleine -->
        <discardingThreshold>0</discardingThreshold>
        <!-- Taille de la queue (messages en attente) -->
        <queueSize>512</queueSize>
        <!-- Inclure le contexte appelant pour avoir la ligne de code -->
        <includeCallerData>true</includeCallerData>
        <appender-ref ref="FILE_JSON"/>
    </appender>

    <!-- ── Loggers spécifiques ────────────────────────────────────────────── -->

    <!-- Nos classes — DEBUG en dev, INFO en prod -->
    <logger name="com.taskflow.backend" level="DEBUG" additivity="false">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="ASYNC_FILE"/>
        <appender-ref ref="ERROR_FILE"/>
    </logger>

    <!-- Hibernate SQL — activer uniquement pour débogage -->
    <logger name="org.hibernate.SQL" level="DEBUG" additivity="false">
        <appender-ref ref="CONSOLE"/>
    </logger>

    <!-- Hibernate paramètres SQL -->
    <logger name="org.hibernate.orm.jdbc.bind" level="TRACE" additivity="false">
        <appender-ref ref="CONSOLE"/>
    </logger>

    <!-- Spring Security — trop verbeux en DEBUG -->
    <logger name="org.springframework.security" level="INFO"/>

    <!-- HikariCP — pool de connexions -->
    <logger name="com.zaxxer.hikari" level="INFO"/>

    <!-- ── Configuration par profil ──────────────────────────────────────── -->

    <!-- Profil local/développement -->
    <springProfile name="local,dev">
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
        </root>
    </springProfile>

    <!-- Profil production -->
    <springProfile name="prod">
        <root level="WARN">
            <appender-ref ref="ASYNC_FILE"/>
            <appender-ref ref="ERROR_FILE"/>
        </root>
    </springProfile>

    <!-- Profil test -->
    <springProfile name="test">
        <root level="ERROR">
            <appender-ref ref="CONSOLE"/>
        </root>
    </springProfile>

</configuration>


DÉPENDANCE logstash-logback-encoder (pour JSON structuré) :
────────────────────────────────────────────────────────────

Dans pom.xml :

<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>7.4</version>
</dependency>


────────────────────────────────────────────────────────────────────────────────
33.2  MDC (MAPPED DIAGNOSTIC CONTEXT)
────────────────────────────────────────────────────────────────────────────────

THÉORIE
────────
Le MDC est un dictionnaire clé-valeur attaché au thread courant.
Toutes les instructions de log émises sur ce thread incluront automatiquement
les valeurs MDC dans leur sortie.

Cas d'usage :
  -> Ajouter le correlationId à TOUS les logs d'une requête
  -> Ajouter l'userId à tous les logs pendant qu'un user est connecté
  -> Retrouver facilement tous les logs d'une requête spécifique

Usage :
  MDC.put("correlationId", "f47ac10b");
  log.info("Traitement de la tâche");   // incluera correlationId
  log.warn("Tâche introuvable");        // incluera aussi correlationId
  MDC.remove("correlationId");          // nettoyer après la requête
  // ou MDC.clear(); pour tout effacer


────────────────────────────────────────────────────────────────────────────────
33.3  CORRELATION ID PAR REQUÊTE HTTP
────────────────────────────────────────────────────────────────────────────────

OBJECTIF
─────────
Associer un identifiant unique à chaque requête HTTP entrante.
Cet identifiant :
  -> Est propagé dans TOUS les logs de la requête (via MDC)
  -> Est retourné au client dans le header X-Correlation-ID
  -> Permet de retrouver tous les logs d'une requête en production
  -> Est utilisé dans les réponses d'erreur pour aider le support


FILE: src/main/java/com/taskflow/backend/filter/CorrelationIdFilter.java
──────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.core.annotation.Order;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.UUID;

/**
 * Filtre HTTP qui attribue un identifiant unique à chaque requête.
 *
 * Comportement :
 *  1. Récupère X-Correlation-ID du header entrant (si fourni par un gateway)
 *     ou génère un nouvel UUID
 *  2. Injecte cet ID dans le MDC pour que TOUS les logs l'incluent
 *  3. Ajoute X-Correlation-ID dans le header de la réponse
 *  4. Nettoie le MDC après la requête (OBLIGATOIRE avec thread pools)
 *
 * @Order(1) : S'exécute AVANT JwtAuthenticationFilter (Order 2)
 */
@Slf4j
@Component
@Order(1)
public class CorrelationIdFilter extends OncePerRequestFilter {

    // Nom du header HTTP standard pour l'identifiant de corrélation
    public static final String CORRELATION_ID_HEADER = "X-Correlation-ID";

    // Clé MDC — doit correspondre au pattern dans logback-spring.xml
    public static final String CORRELATION_ID_MDC_KEY = "correlationId";

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain) throws ServletException, IOException {

        String correlationId = resolveCorrelationId(request);

        try {
            // Injecter dans le MDC — TOUS les logs suivants l'incluront
            MDC.put(CORRELATION_ID_MDC_KEY, correlationId);

            // Injecter dans l'attribut de requête pour GlobalExceptionHandler
            request.setAttribute(CORRELATION_ID_HEADER, correlationId);

            // Retourner l'ID dans le header de réponse
            response.setHeader(CORRELATION_ID_HEADER, correlationId);

            log.debug("-> {} {} [{}]",
                request.getMethod(), request.getRequestURI(), correlationId);

            filterChain.doFilter(request, response);

            log.debug("<- {} {} [{}] — {}",
                request.getMethod(), request.getRequestURI(),
                correlationId, response.getStatus());

        } finally {
            // TOUJOURS nettoyer le MDC (thread pooling — le thread est réutilisé)
            MDC.remove(CORRELATION_ID_MDC_KEY);
        }
    }

    /**
     * Résout le correlationId :
     * 1. Récupère X-Correlation-ID du header entrant (propagation par gateway)
     * 2. Sinon génère un nouvel UUID
     */
    private String resolveCorrelationId(HttpServletRequest request) {
        String headerValue = request.getHeader(CORRELATION_ID_HEADER);

        if (headerValue != null && !headerValue.isBlank()) {
            // Valider le format pour éviter l'injection de données malveillantes dans les logs
            // (Log Injection Attack)
            return sanitize(headerValue);
        }

        return UUID.randomUUID().toString();
    }

    /**
     * Supprime les caractères dangereux pour les logs (newlines, tabs)
     * afin de prévenir les Log Injection Attacks.
     */
    private String sanitize(String value) {
        if (value == null) return null;
        // Limiter à 64 chars, supprimer les newlines et autres caractères de contrôle
        return value
            .replaceAll("[\\r\\n\\t]", "_")
            .substring(0, Math.min(value.length(), 64))
            .trim();
    }
}


────────────────────────────────────────────────────────────────────────────────
FILTRE MDC POUR L'UTILISATEUR CONNECTÉ
────────────────────────────────────────────────────────────────────────────────

On enrichit le MDC avec l'UUID de l'utilisateur connecté APRÈS validation du JWT :

FILE: src/main/java/com/taskflow/backend/filter/UserContextFilter.java
────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.core.annotation.Order;
import org.springframework.lang.NonNull;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import com.taskflow.backend.security.UserPrincipal;
import java.io.IOException;

/**
 * Filtre qui injecte l'userId dans le MDC APRÈS l'authentification JWT.
 *
 * @Order(3) : S'exécute après JwtAuthenticationFilter (Order 2)
 */
@Slf4j
@Component
@Order(3)
public class UserContextFilter extends OncePerRequestFilter {

    public static final String USER_ID_MDC_KEY = "userId";
    public static final String USER_EMAIL_MDC_KEY = "userEmail";

    @Override
    protected void doFilterInternal(
            @NonNull HttpServletRequest request,
            @NonNull HttpServletResponse response,
            @NonNull FilterChain filterChain) throws ServletException, IOException {

        try {
            Authentication auth = SecurityContextHolder.getContext().getAuthentication();

            if (auth != null && auth.isAuthenticated()
                    && auth.getPrincipal() instanceof UserPrincipal principal) {

                // Enrichir le MDC avec les infos de l'utilisateur connecté
                MDC.put(USER_ID_MDC_KEY, principal.getUuid().toString());
                MDC.put(USER_EMAIL_MDC_KEY, maskEmail(principal.getEmail()));
            }

            filterChain.doFilter(request, response);

        } finally {
            MDC.remove(USER_ID_MDC_KEY);
            MDC.remove(USER_EMAIL_MDC_KEY);
        }
    }

    /**
     * Masque partiellement l'email pour les logs (RGPD).
     * "alice@example.com" -> "al***@example.com"
     */
    private String maskEmail(String email) {
        if (email == null || !email.contains("@")) return "unknown";

        String[] parts = email.split("@");
        String local = parts[0];
        String domain = parts[1];

        String masked = local.length() <= 2
            ? local + "***"
            : local.substring(0, 2) + "***";

        return masked + "@" + domain;
    }
}


────────────────────────────────────────────────────────────────────────────────
33.4  EXEMPLE DE LOGS AVEC CORRELATION ID
────────────────────────────────────────────────────────────────────────────────

Voici ce que l'on voit dans les logs pour une requête POST /api/v1/tasks
qui échoue parce que le projet n'existe pas :

  Format développement (CONSOLE) :
  ─────────────────────────────────
  14:23:45.001 DEBUG [http-nio-8080-exec-3] c.t.b.filter.CorrelationIdFilter
    -> POST /api/v1/tasks [f47ac10b]

  14:23:45.012 DEBUG [http-nio-8080-exec-3] c.t.b.service.TaskServiceImpl
    Création de tâche pour le projet UUID=550e8400 f47ac10b

  14:23:45.023 WARN  [http-nio-8080-exec-3] c.t.b.exception.GlobalExceptionHandler
    [PROJECT_NOT_FOUND] 404 — POST /api/v1/tasks : Projet introuvable : 550e8400 f47ac10b

  14:23:45.025 DEBUG [http-nio-8080-exec-3] c.t.b.filter.CorrelationIdFilter
    <- POST /api/v1/tasks [f47ac10b] — 404

  Format JSON (FILE — production) :
  ───────────────────────────────────
  {"timestamp":"2024-11-15T14:23:45.001Z","level":"DEBUG","logger":"...CorrelationIdFilter",
   "message":"-> POST /api/v1/tasks [f47ac10b]","correlationId":"f47ac10b","app":"taskflow","env":"prod"}

  {"timestamp":"2024-11-15T14:23:45.023Z","level":"WARN","logger":"...GlobalExceptionHandler",
   "message":"[PROJECT_NOT_FOUND] 404 — POST /api/v1/tasks : Projet introuvable : 550e8400",
   "correlationId":"f47ac10b","userId":"a1b2c3d4","userEmail":"al***@example.com",
   "app":"taskflow","env":"prod"}

Avantage : avec le correlationId "f47ac10b", on peut retrouver TOUS les logs
de cette requête avec une simple requête Kibana/Loki :
  correlationId:"f47ac10b"


────────────────────────────────────────────────────────────────────────────────
33.5  STRATÉGIE DE LOGGING PAR COUCHE
────────────────────────────────────────────────────────────────────────────────

RÈGLES PAR COUCHE
──────────────────

Controller (@RestController) :
  -> INFO : entrée des endpoints importants (création, modification, suppression)
  -> DEBUG : GET (trop fréquents pour INFO)
  -> PAS d'erreur : géré par GlobalExceptionHandler

  @PostMapping
  public ResponseEntity<ApiResponse<TaskResponse>> createTask(...) {
      log.info("Création tâche — projet={}, titre='{}'",
          request.getProjectUuid(), request.getTitle());
      ...
  }

Service (@Service) :
  -> INFO : opérations métier importantes (tâche créée, statut changé, email envoyé)
  -> DEBUG : logique interne, requêtes construites
  -> WARN  : cas anormaux récupérables (retry, fallback)

  public TaskResponse createTask(CreateTaskRequest request) {
      log.debug("Vérification des droits sur le projet {}", request.getProjectUuid());
      // ...
      log.info("Tâche '{}' créée (uuid={}) dans le projet {}",
          task.getTitle(), task.getUuid(), project.getUuid());
      return mapper.toResponse(task);
  }

Repository (@Repository) :
  -> Ne pas logger dans les repositories (Spring Data gère via Hibernate)
  -> Activer hibernate.show_sql=true en dev si besoin

Filter/Interceptor :
  -> DEBUG uniquement (trop fréquents pour INFO)

NIVEAUX PAR TYPE D'ÉVÉNEMENT
──────────────────────────────

  INFO  : "Utilisateur {} connecté"
  INFO  : "Tâche {} créée dans le projet {}"
  INFO  : "Email de réinitialisation envoyé à {}"
  WARN  : "Tentative de connexion échouée pour {} (tentative {}/5)"
  WARN  : "Token JWT expiré pour l'utilisateur {}"
  WARN  : "Retry n°{} vers le service email après échec"
  ERROR : "Impossible d'envoyer l'email à {} — service indisponible"
  ERROR : "Exception inattendue — correlationId={}"
  ERROR : "Connexion à la base de données perdue"


────────────────────────────────────────────────────────────────────────────────
33.6  ALERTES ET INTÉGRATION MONITORING
────────────────────────────────────────────────────────────────────────────────

SPRING BOOT ACTUATOR — ENDPOINTS DE SANTÉ
──────────────────────────────────────────

Spring Boot Actuator expose des endpoints de monitoring intégrés.

Dans pom.xml :
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

Dans application.properties :
# Activer les endpoints Actuator
management.endpoints.web.exposure.include=health,info,metrics,loggers
management.endpoint.health.show-details=when-authorized
management.endpoint.health.show-components=when-authorized

# Sécuriser les endpoints Actuator (hors /health)
management.endpoints.web.base-path=/actuator

Endpoints disponibles :
  GET /actuator/health         -> État général de l'application
  GET /actuator/health/db      -> État de la connexion DB
  GET /actuator/metrics        -> Métriques JVM, HTTP, cache
  GET /actuator/loggers        -> Niveaux de log configurés
  POST /actuator/loggers/{name}-> Modifier le niveau de log en direct
    Body: {"configuredLevel": "DEBUG"}


CUSTOM HEALTH INDICATOR
────────────────────────
On peut créer des health indicators personnalisés pour des dépendances externes :

FILE: src/main/java/com/taskflow/backend/health/EmailServiceHealthIndicator.java
──────────────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.health;

import lombok.RequiredArgsConstructor;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;

/**
 * Health indicator pour le service email.
 * Vérifié par GET /actuator/health/emailService
 */
@Component
@RequiredArgsConstructor
public class EmailServiceHealthIndicator implements HealthIndicator {

    // Dans un vrai projet : injection du service email
    // private final EmailService emailService;

    @Override
    public Health health() {
        try {
            // Vérifier la disponibilité du service email
            // emailService.ping();
            boolean emailUp = checkEmailService();

            if (emailUp) {
                return Health.up()
                    .withDetail("provider", "SendGrid")
                    .withDetail("status", "opérationnel")
                    .build();
            } else {
                return Health.down()
                    .withDetail("provider", "SendGrid")
                    .withDetail("status", "indisponible")
                    .withDetail("action", "utilisation du mode dégradé (logs)")
                    .build();
            }
        } catch (Exception e) {
            return Health.down(e)
                .withDetail("provider", "SendGrid")
                .build();
        }
    }

    private boolean checkEmailService() {
        // Implémentation réelle : ping SMTP ou API SendGrid
        return true; // Stub pour l'exemple
    }
}


RÉPONSE /actuator/health :
──────────────────────────

{
  "status": "UP",
  "components": {
    "db": {
      "status": "UP",
      "details": {
        "database": "PostgreSQL",
        "validationQuery": "isValid()"
      }
    },
    "emailService": {
      "status": "UP",
      "details": {
        "provider": "SendGrid",
        "status": "opérationnel"
      }
    },
    "diskSpace": {
      "status": "UP",
      "details": {
        "total": 107374182400,
        "free": 21474836480,
        "threshold": 10485760
      }
    }
  }
}


────────────────────────────────────────────────────────────────────────────────
33.7  BONNES PRATIQUES — ERREURS FRÉQUENTES — EXERCICES
────────────────────────────────────────────────────────────────────────────────

BONNES PRATIQUES
────────────────
1. MDC.clear() dans un bloc finally — TOUJOURS.
   Sinon les valeurs MDC persistent sur le thread pool et polluent les logs
   des requêtes suivantes.

2. Ne jamais logger des données sensibles :
   Mauvais : log.info("Login réussi pour {} avec mot de passe {}", email, password)
   Bon     : log.info("Login réussi pour {}", maskEmail(email))

3. Logger les exceptions avec leur stack trace en ERROR :
   log.error("Erreur inattendue", ex)  // ex en dernier argument = stack trace
   log.error("Erreur inattendue : {}", ex.getMessage())  // MAUVAIS — pas de stack

4. Utiliser des messages structurés avec paramètres (pas la concaténation) :
   Mauvais : log.info("Tâche " + task.getUuid() + " créée")  // Alloue une String même si INFO désactivé
   Bon     : log.info("Tâche {} créée", task.getUuid())       // Évaluation lazy

5. Un log par événement métier, pas par ligne de code.
   Éviter de logger entrée et sortie de chaque méthode (trop de volume).


ERREURS FRÉQUENTES
──────────────────
[X] Oublier le MDC.remove() dans le filter -> Fuite de contexte entre requêtes
[X] Logger des mots de passe, tokens ou numéros de carte en clair
[X] Level ERROR pour des erreurs 4xx (qui sont normales et attendues)
[X] Appender synchrone en production -> Ralentit les requêtes HTTP
[X] Rotation de fichiers logs non configurée -> Disque plein en production
[X] Logback non configuré -> Spring Boot utilise son format par défaut, incompatible ELK
[X] log.info("...") + ex.printStackTrace() -> Double logging, format incohérent


EXERCICES
─────────

Niveau Débutant :
  1. Configurer logback-spring.xml pour logger les SQL Hibernate uniquement
     en profil "dev" (pas en "prod") et dans un fichier séparé sql.log.

  2. Créer un AuditLog (INFO) dans TaskServiceImpl qui logue chaque changement
     de statut de tâche : "Statut de la tâche {} modifié : {} -> {} par {}"

  3. Modifier GlobalExceptionHandler pour récupérer le correlationId depuis
     l'attribut de requête (request.getAttribute) plutôt que depuis le header.

Niveau Intermédiaire :
  4. Implémenter un RequestLoggingFilter qui logue le body des requêtes POST/PUT
     entrant (en masquant les champs sensibles : password, token, cardNumber)
     dans un fichier séparé api-requests.log.

  5. Créer un @Aspect (Spring AOP) qui logue automatiquement l'entrée et sortie
     de chaque méthode annotée avec @LogExecution (annotation personnalisée à créer)
     en incluant le temps d'exécution en millisecondes.

  6. Configurer un appender Slack dans Logback qui envoie un message dans un
     canal #alerts quand un log de niveau ERROR est émis en profil "prod".
     (Utiliser l'API Incoming Webhooks de Slack)

Niveau Avancé :
  7. Implémenter la propagation du correlationId dans les appels RestTemplate
     (vers d'autres services) via un ClientHttpRequestInterceptor qui injecte
     automatiquement le X-Correlation-ID depuis le MDC dans les headers sortants.

  8. Mettre en place un dashboard de monitoring avec Spring Boot Actuator +
     Micrometer + Prometheus. Créer des métriques personnalisées :
     - taskflow.tasks.created.total (Counter)
     - taskflow.tasks.status.change.duration (Timer)
     - taskflow.active.users (Gauge — via SecurityContextHolder)

  9. Intégrer Sentry pour le monitoring des erreurs en production :
     — Capturer toutes les exceptions ERROR dans GlobalExceptionHandler
     — Associer l'utilisateur Sentry à partir du UserPrincipal
     — Ajouter le correlationId comme "breadcrumb" Sentry
     — Filtrer les exceptions attendues (404, 401) pour ne pas polluer Sentry


================================================================================
  RÉCAPITULATIF DES FICHIERS CRÉÉS DANS CETTE PARTIE
================================================================================

  exception/
  ├── ErrorCode.java                            <- Enum de tous les codes d'erreur
  ├── GlobalExceptionHandler.java               <- @RestControllerAdvice complet
  ├── base/
  │   ├── TaskFlowException.java                <- Exception de base (abstract)
  │   ├── ResourceNotFoundException.java        <- Base 404
  │   ├── ConflictException.java                <- Base 409
  │   └── BusinessRuleException.java            <- Base 422
  ├── resource/
  │   ├── TaskNotFoundException.java
  │   ├── UserNotFoundException.java
  │   └── ProjectNotFoundException.java
  ├── conflict/
  │   └── EmailAlreadyExistsException.java
  ├── business/
  │   └── TaskStatusTransitionException.java
  └── auth/
      └── AccountLockedException.java

  dto/response/
  └── ApiResponse.java                          <- Enrichi avec correlationId

  filter/
  ├── CorrelationIdFilter.java                  <- Correlation ID MDC injection
  └── UserContextFilter.java                    <- UserId MDC injection

  health/
  └── EmailServiceHealthIndicator.java          <- Custom health check

  resources/
  └── logback-spring.xml                        <- Configuration Logback complète


================================================================================
  FIN DE LA PARTIE 9
  Partie suivante -> Partie 10 : Tests Unitaires et d'Intégration
  (JUnit 5, Mockito, @WebMvcTest, Testcontainers, @DataJpaTest)
================================================================================



================================================================================
  SPRING BOOT MASTER GUIDE — TASKFLOW BACKEND
  PARTIE 10 : TESTS UNITAIRES ET D'INTÉGRATION
  Chapitres 34–35
================================================================================

TABLE DES MATIÈRES
──────────────────
  Chapitre 34 : Tests Unitaires avec JUnit 5 et Mockito
    34.1  Philosophie des tests — Pyramide de tests
    34.2  JUnit 5 — annotations et assertions essentielles
    34.3  Mockito — mocks, stubs et vérification
    34.4  Tester la couche Service
    34.5  Tester la couche Controller avec @WebMvcTest
    34.6  Tester GlobalExceptionHandler
    34.7  Bonnes pratiques, erreurs fréquentes, exercices

  Chapitre 35 : Tests d'Intégration avec Testcontainers
    35.1  Pourquoi Testcontainers ?
    35.2  Configuration de base Testcontainers + PostgreSQL
    35.3  @DataJpaTest — tester les repositories
    35.4  @SpringBootTest — tests end-to-end
    35.5  Tester la sécurité (JWT, @PreAuthorize)
    35.6  Bonnes pratiques, erreurs fréquentes, exercices

================================================================================
  CHAPITRE 34 : TESTS UNITAIRES AVEC JUNIT 5 ET MOCKITO
================================================================================

────────────────────────────────────────────────────────────────────────────────
34.1  PHILOSOPHIE DES TESTS — PYRAMIDE DE TESTS
────────────────────────────────────────────────────────────────────────────────

LA PYRAMIDE DE TESTS
─────────────────────

                          [BLACK_UP-POINTING_TRIANGLE]
                         /E2E\         <- 5%  : Tests end-to-end (Selenium, Cypress)
                        /─────\             Lents, coûteux, fragiles
                       /  INT  \       <- 20% : Tests d'intégration (@SpringBootTest)
                      /─────────\           Moderate speed, real DB
                     /   UNIT    \     <- 75% : Tests unitaires (JUnit + Mockito)
                    /─────────────\         Rapides, isolés, nombreux

  Règle d'or : plus on monte dans la pyramide, plus les tests sont :
    -> Lents à exécuter
    -> Coûteux à maintenir
    -> Proches du comportement réel

  Dans TaskFlow, objectif de couverture :
    -> Service layer   : 90%+ (logique métier critique)
    -> Controller layer: 80%+ (validation, codes HTTP)
    -> Repository layer: 70%+ (requêtes complexes)
    -> Utils/helpers   : 100%  (code pur sans dépendances)


TERMINOLOGIE
─────────────
  SUT (System Under Test)   : La classe que l'on teste
  Dépendance                : Ce que le SUT utilise (repositories, services...)
  Mock                      : Remplacement simulé d'une dépendance
  Stub                      : Mock avec un comportement prédéfini
  Spy                       : Wrapper sur un objet réel qui permet l'espionnage
  Assert/Assertion          : Vérification du résultat attendu
  Fixture                   : Données de test prédéfinies
  Given/When/Then           : Structure AAA (Arrange/Act/Assert)


STRUCTURE D'UN TEST — PATTERN AAA
────────────────────────────────────

  @Test
  void nomDuTest_contexte_comportementAttendu() {

      // GIVEN (Arrange) — Préparer les données et les mocks
      var taskRequest = new CreateTaskRequest("Implémenter login", ...);
      when(projectRepository.findByUuid(any())).thenReturn(Optional.of(projet));

      // WHEN (Act) — Exécuter l'opération testée
      var result = taskService.createTask(taskRequest, userPrincipal);

      // THEN (Assert) — Vérifier le résultat
      assertThat(result.getTitle()).isEqualTo("Implémenter login");
      verify(taskRepository, times(1)).save(any(Task.class));
  }

  Nommage des tests :
    methodName_context_expectedBehavior
    createTask_withValidRequest_returnsTaskResponse
    createTask_withNonExistentProject_throwsProjectNotFoundException
    updateStatus_fromDoneToTodo_throwsTaskStatusTransitionException


────────────────────────────────────────────────────────────────────────────────
34.2  JUNIT 5 — ANNOTATIONS ET ASSERTIONS ESSENTIELLES
────────────────────────────────────────────────────────────────────────────────

DÉPENDANCES (déjà incluses dans spring-boot-starter-test)
──────────────────────────────────────────────────────────

Dans pom.xml :

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <!-- Inclut : JUnit 5, Mockito, AssertJ, Hamcrest,
                  Spring Test, JSONPath, Testcontainers (via BOM) -->
</dependency>

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>


ANNOTATIONS JUNIT 5
────────────────────

  @Test               -> Marque une méthode comme test
  @DisplayName("...") -> Nom lisible dans les rapports
  @BeforeEach         -> Exécuté avant chaque @Test
  @AfterEach          -> Exécuté après chaque @Test
  @BeforeAll          -> Exécuté une fois avant tous les @Test (méthode static)
  @AfterAll           -> Exécuté une fois après tous les @Test (méthode static)
  @Nested             -> Classe interne pour regrouper les tests par scénario
  @ParameterizedTest  -> Test exécuté plusieurs fois avec des données différentes
  @ValueSource        -> Source de données pour @ParameterizedTest
  @MethodSource       -> Source de données via une méthode statique
  @CsvSource          -> Source de données CSV inline
  @Disabled("raison") -> Désactiver temporairement un test
  @Tag("integration") -> Tagger les tests pour les filtrer (Maven Surefire)
  @Timeout(5)         -> Échec si le test dure plus de 5 secondes

ASSERTIONS ASSERTJ (recommandé vs JUnit assertions)
─────────────────────────────────────────────────────

  // Strings
  assertThat(result.getTitle()).isEqualTo("Implémenter login");
  assertThat(result.getTitle()).startsWith("Implémenter");
  assertThat(result.getTitle()).isNotBlank();

  // Numbers
  assertThat(result.getId()).isPositive();
  assertThat(result.getProgress()).isBetween(0, 100);

  // Collections
  assertThat(result.getTags()).hasSize(2);
  assertThat(result.getTags()).contains("backend", "urgent");
  assertThat(result.getTags()).isNotEmpty();
  assertThat(result.getTags()).extracting("name").containsExactly("backend", "urgent");

  // Objects
  assertThat(result).isNotNull();
  assertThat(result).isInstanceOf(TaskResponse.class);
  assertThat(result).extracting("title", "status")
                    .containsExactly("Implémenter login", TaskStatus.TODO);

  // Exceptions
  assertThatThrownBy(() -> taskService.createTask(invalidRequest, principal))
      .isInstanceOf(ProjectNotFoundException.class)
      .hasMessageContaining("Projet introuvable");

  assertThatCode(() -> taskService.getTask(validUuid, principal))
      .doesNotThrowAnyException();

  // Optionals
  assertThat(result).isPresent();
  assertThat(result).hasValue(expectedTask);

  // Dates
  assertThat(result.getCreatedAt()).isBeforeOrEqualTo(Instant.now());
  assertThat(result.getDueDate()).isAfter(LocalDate.now());


────────────────────────────────────────────────────────────────────────────────
34.3  MOCKITO — MOCKS, STUBS ET VÉRIFICATION
────────────────────────────────────────────────────────────────────────────────

DEUX APPROCHES POUR CRÉER DES MOCKS
──────────────────────────────────────

  Approche 1 — Annotations (@ExtendWith + @Mock)
  ───────────────────────────────────────────────
  @ExtendWith(MockitoExtension.class)
  class TaskServiceTest {

      @Mock
      private TaskRepository taskRepository;

      @Mock
      private ProjectRepository projectRepository;

      @InjectMocks
      private TaskServiceImpl taskService;
  }

  Approche 2 — Programmatique
  ────────────────────────────
  TaskRepository taskRepository = mock(TaskRepository.class);
  TaskServiceImpl taskService = new TaskServiceImpl(taskRepository, ...);


STUBBING — DÉFINIR LE COMPORTEMENT DES MOCKS
─────────────────────────────────────────────

  // Retourner une valeur
  when(taskRepository.findByUuid(taskUuid)).thenReturn(Optional.of(task));
  when(taskRepository.save(any(Task.class))).thenAnswer(i -> i.getArgument(0));

  // Lever une exception
  when(taskRepository.findByUuid(unknownUuid)).thenReturn(Optional.empty());

  // Retourner différentes valeurs à chaque appel
  when(taskRepository.count())
      .thenReturn(0L)   // 1er appel
      .thenReturn(1L);  // 2ème appel et suivants

  // Void methods
  doNothing().when(taskRepository).delete(any(Task.class));
  doThrow(new RuntimeException("DB Error")).when(taskRepository).delete(any(Task.class));


MATCHERS ARGUMENT
─────────────────

  any()                   -> N'importe quelle valeur (null inclus)
  any(Task.class)         -> N'importe quel Task
  anyString()             -> N'importe quelle String non-null
  eq("valeur")            -> Valeur exacte
  isNull() / isNotNull()  -> Null / non-null
  argThat(t -> t.getId() > 0)  -> Prédicat personnalisé

  ATTENTION : Dans un when(), soit tous les args sont matchers, soit aucun.


VÉRIFICATION DES INTERACTIONS
──────────────────────────────

  verify(taskRepository, times(1)).save(any(Task.class));
  verify(taskRepository, never()).save(any(Task.class));

  // Vérifier l'ordre des appels
  InOrder inOrder = inOrder(projectRepository, taskRepository);
  inOrder.verify(projectRepository).findByUuid(projectUuid);
  inOrder.verify(taskRepository).save(any(Task.class));

  // ArgumentCaptor — inspecter l'objet passé à une méthode
  ArgumentCaptor<Task> taskCaptor = ArgumentCaptor.forClass(Task.class);
  verify(taskRepository).save(taskCaptor.capture());
  Task savedTask = taskCaptor.getValue();
  assertThat(savedTask.getTitle()).isEqualTo("Implémenter login");
  assertThat(savedTask.getProject()).isEqualTo(project);

  verifyNoMoreInteractions(taskRepository);


────────────────────────────────────────────────────────────────────────────────
34.4  TESTER LA COUCHE SERVICE
────────────────────────────────────────────────────────────────────────────────

FILE: src/test/java/com/taskflow/backend/fixture/TaskFlowFixtures.java
────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.fixture;

import com.taskflow.backend.model.*;
import com.taskflow.backend.model.enums.*;
import com.taskflow.backend.security.UserPrincipal;
import java.time.LocalDate;
import java.util.UUID;

public final class TaskFlowFixtures {

    public static final UUID USER_UUID    = UUID.fromString("00000000-0000-0000-0000-000000000001");
    public static final UUID PROJECT_UUID = UUID.fromString("00000000-0000-0000-0000-000000000002");
    public static final UUID TASK_UUID    = UUID.fromString("00000000-0000-0000-0000-000000000003");

    private TaskFlowFixtures() {}

    public static User buildUser() {
        return User.builder()
            .id(1L)
            .uuid(USER_UUID)
            .email("alice@example.com")
            .fullName("Alice Martin")
            .passwordHash("$2a$12$hashedpassword")
            .role(UserRole.USER)
            .status(UserStatus.ACTIVE)
            .build();
    }

    public static UserPrincipal buildUserPrincipal() {
        return UserPrincipal.fromUser(buildUser());
    }

    public static Project buildProject() {
        User owner = buildUser();
        return Project.builder()
            .id(1L)
            .uuid(PROJECT_UUID)
            .name("TaskFlow Backend")
            .description("Projet de test")
            .owner(owner)
            .status(ProjectStatus.ACTIVE)
            .build();
    }

    public static Task buildTask() {
        Project project = buildProject();
        User assignee  = buildUser();
        return Task.builder()
            .id(1L)
            .uuid(TASK_UUID)
            .title("Implémenter login")
            .description("Feature d'authentification JWT")
            .status(TaskStatus.TODO)
            .priority(TaskPriority.HIGH)
            .dueDate(LocalDate.now().plusDays(7))
            .project(project)
            .assignee(assignee)
            .build();
    }

    public static CreateTaskRequest buildCreateTaskRequest() {
        return CreateTaskRequest.builder()
            .title("Implémenter login")
            .description("Feature d'authentification JWT")
            .priority(TaskPriority.HIGH)
            .dueDate(LocalDate.now().plusDays(7))
            .projectUuid(PROJECT_UUID)
            .assigneeUuid(USER_UUID)
            .build();
    }
}


FILE: src/test/java/com/taskflow/backend/service/TaskServiceImplTest.java
──────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.service;

import com.taskflow.backend.dto.request.CreateTaskRequest;
import com.taskflow.backend.dto.request.UpdateTaskStatusRequest;
import com.taskflow.backend.dto.response.TaskResponse;
import com.taskflow.backend.exception.business.TaskStatusTransitionException;
import com.taskflow.backend.exception.resource.ProjectNotFoundException;
import com.taskflow.backend.exception.resource.TaskNotFoundException;
import com.taskflow.backend.fixture.TaskFlowFixtures;
import com.taskflow.backend.mapper.TaskMapper;
import com.taskflow.backend.model.Task;
import com.taskflow.backend.model.enums.TaskStatus;
import com.taskflow.backend.repository.ProjectRepository;
import com.taskflow.backend.repository.TaskRepository;
import com.taskflow.backend.repository.UserRepository;
import com.taskflow.backend.security.UserPrincipal;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Optional;
import java.util.UUID;

import static com.taskflow.backend.fixture.TaskFlowFixtures.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
@DisplayName("TaskServiceImpl — Tests unitaires")
class TaskServiceImplTest {

    @Mock private TaskRepository    taskRepository;
    @Mock private ProjectRepository projectRepository;
    @Mock private UserRepository    userRepository;
    @Mock private TaskMapper        taskMapper;

    @InjectMocks private TaskServiceImpl taskService;

    private UserPrincipal principal;

    @BeforeEach
    void setUp() {
        principal = buildUserPrincipal();
    }


    @Nested
    @DisplayName("createTask()")
    class CreateTask {

        @Test
        @DisplayName("OK Crée une tâche quand le projet et l'assigné existent")
        void createTask_withValidRequest_returnsTaskResponse() {
            // GIVEN
            var request    = buildCreateTaskRequest();
            var project    = buildProject();
            var assignee   = buildUser();
            var expected   = mock(TaskResponse.class);

            when(projectRepository.findByUuid(PROJECT_UUID))
                .thenReturn(Optional.of(project));
            when(userRepository.findByUuid(USER_UUID))
                .thenReturn(Optional.of(assignee));
            when(taskRepository.save(any(Task.class)))
                .thenAnswer(invocation -> invocation.getArgument(0));
            when(taskMapper.toResponse(any(Task.class)))
                .thenReturn(expected);

            // WHEN
            TaskResponse result = taskService.createTask(request, principal);

            // THEN
            assertThat(result).isEqualTo(expected);

            ArgumentCaptor<Task> captor = ArgumentCaptor.forClass(Task.class);
            verify(taskRepository).save(captor.capture());
            Task saved = captor.getValue();

            assertThat(saved.getTitle()).isEqualTo(request.getTitle());
            assertThat(saved.getProject()).isEqualTo(project);
            assertThat(saved.getAssignee()).isEqualTo(assignee);
            assertThat(saved.getStatus()).isEqualTo(TaskStatus.TODO);
        }

        @Test
        @DisplayName("KO Lève ProjectNotFoundException si le projet n'existe pas")
        void createTask_withNonExistentProject_throwsProjectNotFoundException() {
            // GIVEN
            var request = buildCreateTaskRequest();
            when(projectRepository.findByUuid(any(UUID.class)))
                .thenReturn(Optional.empty());

            // WHEN / THEN
            assertThatThrownBy(() -> taskService.createTask(request, principal))
                .isInstanceOf(ProjectNotFoundException.class)
                .hasMessageContaining("Projet introuvable");

            verify(taskRepository, never()).save(any());
        }

        @Test
        @DisplayName("OK Crée une tâche non assignée si assigneeUuid est null")
        void createTask_withNoAssignee_createsUnassignedTask() {
            // GIVEN
            var request = buildCreateTaskRequest().toBuilder()
                .assigneeUuid(null)
                .build();
            var project = buildProject();

            when(projectRepository.findByUuid(PROJECT_UUID))
                .thenReturn(Optional.of(project));
            when(taskRepository.save(any(Task.class)))
                .thenAnswer(i -> i.getArgument(0));
            when(taskMapper.toResponse(any())).thenReturn(mock(TaskResponse.class));

            // WHEN
            taskService.createTask(request, principal);

            // THEN
            ArgumentCaptor<Task> captor = ArgumentCaptor.forClass(Task.class);
            verify(taskRepository).save(captor.capture());
            assertThat(captor.getValue().getAssignee()).isNull();
            verify(userRepository, never()).findByUuid(any());
        }
    }


    @Nested
    @DisplayName("updateTaskStatus()")
    class UpdateTaskStatus {

        @Test
        @DisplayName("OK Passe une tâche de TODO à IN_PROGRESS")
        void updateStatus_fromTodoToInProgress_succeeds() {
            var task    = buildTask();
            var request = new UpdateTaskStatusRequest(TaskStatus.IN_PROGRESS);

            when(taskRepository.findByUuid(TASK_UUID))
                .thenReturn(Optional.of(task));
            when(taskRepository.save(any(Task.class)))
                .thenAnswer(i -> i.getArgument(0));
            when(taskMapper.toResponse(any())).thenReturn(mock(TaskResponse.class));

            taskService.updateTaskStatus(TASK_UUID, request, principal);

            ArgumentCaptor<Task> captor = ArgumentCaptor.forClass(Task.class);
            verify(taskRepository).save(captor.capture());
            assertThat(captor.getValue().getStatus()).isEqualTo(TaskStatus.IN_PROGRESS);
        }

        @Test
        @DisplayName("KO Lève TaskStatusTransitionException pour transition invalide")
        void updateStatus_fromDoneToTodo_throwsTaskStatusTransitionException() {
            var task = buildTask().toBuilder().status(TaskStatus.DONE).build();
            var request = new UpdateTaskStatusRequest(TaskStatus.TODO);

            when(taskRepository.findByUuid(TASK_UUID))
                .thenReturn(Optional.of(task));

            assertThatThrownBy(
                () -> taskService.updateTaskStatus(TASK_UUID, request, principal))
                .isInstanceOf(TaskStatusTransitionException.class)
                .hasMessageContaining("DONE")
                .hasMessageContaining("TODO");

            verify(taskRepository, never()).save(any());
        }

        @ParameterizedTest(name = "TODO -> {0} est invalide")
        @EnumSource(value = TaskStatus.class,
                    names = {"DONE", "ARCHIVED"},
                    mode  = EnumSource.Mode.INCLUDE)
        @DisplayName("KO Transitions invalides depuis TODO")
        void updateStatus_invalidTransitionsFromTodo_throwsException(TaskStatus invalidTarget) {
            var task    = buildTask();
            var request = new UpdateTaskStatusRequest(invalidTarget);
            when(taskRepository.findByUuid(TASK_UUID))
                .thenReturn(Optional.of(task));

            assertThatThrownBy(
                () -> taskService.updateTaskStatus(TASK_UUID, request, principal))
                .isInstanceOf(TaskStatusTransitionException.class);
        }
    }


    @Nested
    @DisplayName("deleteTask()")
    class DeleteTask {

        @Test
        @DisplayName("OK Soft delete — marque la tâche comme supprimée")
        void deleteTask_existingTask_performsSoftDelete() {
            var task = buildTask();
            when(taskRepository.findByUuid(TASK_UUID))
                .thenReturn(Optional.of(task));

            taskService.deleteTask(TASK_UUID, principal);

            verify(taskRepository).save(argThat(t -> t.isDeleted()));
            verify(taskRepository, never()).delete(any());
        }

        @Test
        @DisplayName("KO Lève TaskNotFoundException si tâche absente")
        void deleteTask_nonExistentTask_throwsNotFoundException() {
            when(taskRepository.findByUuid(any())).thenReturn(Optional.empty());

            assertThatThrownBy(() -> taskService.deleteTask(TASK_UUID, principal))
                .isInstanceOf(TaskNotFoundException.class);
        }
    }
}


────────────────────────────────────────────────────────────────────────────────
34.5  TESTER LA COUCHE CONTROLLER AVEC @WebMvcTest
────────────────────────────────────────────────────────────────────────────────

FILE: src/test/java/com/taskflow/backend/controller/TaskControllerTest.java
─────────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.taskflow.backend.dto.request.CreateTaskRequest;
import com.taskflow.backend.dto.response.TaskResponse;
import com.taskflow.backend.exception.resource.TaskNotFoundException;
import com.taskflow.backend.model.enums.TaskPriority;
import com.taskflow.backend.security.JwtService;
import com.taskflow.backend.service.TaskService;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;

import java.time.LocalDate;

import static com.taskflow.backend.fixture.TaskFlowFixtures.*;
import static org.hamcrest.Matchers.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

/**
 * @WebMvcTest charge uniquement les beans Spring MVC.
 * Avantage : 5-10x plus rapide que @SpringBootTest.
 * Les services et repositories sont des @MockBean.
 */
@WebMvcTest(TaskController.class)
@DisplayName("TaskController — Tests @WebMvcTest")
class TaskControllerTest {

    @Autowired MockMvc        mockMvc;
    @Autowired ObjectMapper   objectMapper;

    @MockBean TaskService taskService;
    @MockBean JwtService  jwtService;


    @Nested
    @DisplayName("POST /api/v1/tasks")
    class CreateTask {

        @Test
        @WithMockUser(username = "alice@example.com", roles = "USER")
        @DisplayName("OK Retourne 201 Created avec la tâche créée")
        void createTask_withValidRequest_returns201() throws Exception {
            var request = CreateTaskRequest.builder()
                .title("Implémenter login")
                .priority(TaskPriority.HIGH)
                .dueDate(LocalDate.now().plusDays(7))
                .projectUuid(PROJECT_UUID)
                .build();

            var response = TaskResponse.builder()
                .uuid(TASK_UUID)
                .title("Implémenter login")
                .build();

            when(taskService.createTask(any(), any())).thenReturn(response);

            mockMvc.perform(post("/api/v1/tasks")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(request))
                    .with(csrf()))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.data.uuid").value(TASK_UUID.toString()))
                .andExpect(jsonPath("$.data.title").value("Implémenter login"))
                .andExpect(jsonPath("$.timestamp").exists());
        }

        @Test
        @WithMockUser
        @DisplayName("KO Retourne 400 si le titre est absent")
        void createTask_withBlankTitle_returns400() throws Exception {
            var request = CreateTaskRequest.builder()
                .title("")
                .projectUuid(PROJECT_UUID)
                .build();

            mockMvc.perform(post("/api/v1/tasks")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(request))
                    .with(csrf()))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.success").value(false))
                .andExpect(jsonPath("$.errors", hasItem(containsString("title"))));
        }

        @Test
        @DisplayName("KO Retourne 401 si non authentifié")
        void createTask_withoutAuthentication_returns401() throws Exception {
            var request = CreateTaskRequest.builder()
                .title("Tâche test")
                .projectUuid(PROJECT_UUID)
                .build();

            mockMvc.perform(post("/api/v1/tasks")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isUnauthorized());
        }
    }


    @Nested
    @DisplayName("GET /api/v1/tasks/{uuid}")
    class GetTask {

        @Test
        @WithMockUser
        @DisplayName("OK Retourne 200 avec la tâche si elle existe")
        void getTask_withExistingUuid_returns200() throws Exception {
            var response = TaskResponse.builder()
                .uuid(TASK_UUID)
                .title("Implémenter login")
                .build();
            when(taskService.getTaskByUuid(TASK_UUID, any())).thenReturn(response);

            mockMvc.perform(get("/api/v1/tasks/{uuid}", TASK_UUID))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.data.uuid").value(TASK_UUID.toString()));
        }

        @Test
        @WithMockUser
        @DisplayName("KO Retourne 404 si la tâche n'existe pas")
        void getTask_withNonExistentUuid_returns404() throws Exception {
            when(taskService.getTaskByUuid(any(), any()))
                .thenThrow(new TaskNotFoundException(TASK_UUID));

            mockMvc.perform(get("/api/v1/tasks/{uuid}", TASK_UUID))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.errors[0]").value("TASK_NOT_FOUND"));
        }

        @Test
        @WithMockUser
        @DisplayName("KO Retourne 400 si UUID invalide")
        void getTask_withInvalidUuidFormat_returns400() throws Exception {
            mockMvc.perform(get("/api/v1/tasks/not-a-valid-uuid"))
                .andExpect(status().isBadRequest());
        }
    }
}


────────────────────────────────────────────────────────────────────────────────
34.6  TESTER GlobalExceptionHandler
────────────────────────────────────────────────────────────────────────────────

FILE: src/test/java/com/taskflow/backend/exception/GlobalExceptionHandlerTest.java
─────────────────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.exception;

import com.taskflow.backend.controller.TaskController;
import com.taskflow.backend.exception.business.TaskStatusTransitionException;
import com.taskflow.backend.exception.resource.TaskNotFoundException;
import com.taskflow.backend.model.enums.TaskStatus;
import com.taskflow.backend.security.JwtService;
import com.taskflow.backend.service.TaskService;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;

import static com.taskflow.backend.fixture.TaskFlowFixtures.*;
import static org.hamcrest.Matchers.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(TaskController.class)
@DisplayName("GlobalExceptionHandler — Tests")
class GlobalExceptionHandlerTest {

    @Autowired MockMvc mockMvc;
    @MockBean  TaskService taskService;
    @MockBean  JwtService jwtService;

    @Test
    @WithMockUser
    @DisplayName("404 — TaskNotFoundException -> TASK_NOT_FOUND")
    void whenTaskNotFound_returns404WithErrorCode() throws Exception {
        when(taskService.getTaskByUuid(any(), any()))
            .thenThrow(new TaskNotFoundException(TASK_UUID));

        mockMvc.perform(get("/api/v1/tasks/{uuid}", TASK_UUID))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.success").value(false))
            .andExpect(jsonPath("$.errors[0]").value("TASK_NOT_FOUND"))
            .andExpect(jsonPath("$.correlationId").exists())
            .andExpect(jsonPath("$.timestamp").exists());
    }

    @Test
    @WithMockUser
    @DisplayName("422 — TaskStatusTransitionException -> 422 avec message descriptif")
    void whenStatusTransitionInvalid_returns422() throws Exception {
        when(taskService.getTaskByUuid(any(), any()))
            .thenThrow(new TaskStatusTransitionException(TaskStatus.DONE, TaskStatus.TODO));

        mockMvc.perform(get("/api/v1/tasks/{uuid}", TASK_UUID))
            .andExpect(status().isUnprocessableEntity())
            .andExpect(jsonPath("$.success").value(false))
            .andExpect(jsonPath("$.message", containsString("DONE")))
            .andExpect(jsonPath("$.errors[0]").value("TASK_INVALID_STATUS_TRANSITION"));
    }

    @Test
    @WithMockUser
    @DisplayName("500 — RuntimeException -> 500 sans détails internes")
    void whenUnexpectedException_returns500WithoutDetails() throws Exception {
        when(taskService.getTaskByUuid(any(), any()))
            .thenThrow(new RuntimeException("Database connection failed at port 5432"));

        mockMvc.perform(get("/api/v1/tasks/{uuid}", TASK_UUID))
            .andExpect(status().isInternalServerError())
            .andExpect(jsonPath("$.success").value(false))
            // Le message NE doit PAS contenir les détails internes
            .andExpect(jsonPath("$.message", not(containsString("5432"))))
            .andExpect(jsonPath("$.message", not(containsString("Database connection"))))
            .andExpect(jsonPath("$.correlationId").exists());
    }
}


────────────────────────────────────────────────────────────────────────────────
34.7  BONNES PRATIQUES — ERREURS FRÉQUENTES — EXERCICES
────────────────────────────────────────────────────────────────────────────────

BONNES PRATIQUES
────────────────
1. Un test = un comportement unique et précis.
2. Tests indépendants : chaque test crée ses propres données, pas de partage d'état.
3. Nommer les tests : methodName_condition_expectedResult.
4. Préférer AssertJ à JUnit assertions pour la lisibilité.
5. @BeforeEach pour les fixtures communes.
6. Tester les cas limites (null, liste vide, UUID invalide, transitions impossibles).

ERREURS FRÉQUENTES
──────────────────
- Mocker le SUT lui-même (sans intérêt — mocker uniquement les dépendances)
- Tests qui passent toujours (when() sans verify() ou assertThat())
- Dépendance entre tests via champs statiques ou BD partagée
- Tester des getters/setters Lombok (pas de valeur ajoutée)
- @SpringBootTest pour des tests unitaires (5-10x plus lent que @ExtendWith)
- Oublier @WithMockUser en @WebMvcTest (routes retournent 401)

EXERCICES
─────────

Niveau Débutant :
  1. Écrire un test pour AuthServiceImpl.login() qui vérifie qu'une exception
     AccountLockedException est levée si failedLoginAttempts >= 5.
  2. Tester ProjectController GET /api/v1/projects avec @WebMvcTest et vérifier
     que la réponse contient une liste paginée (PagedResponse).
  3. Avec @ParameterizedTest et @CsvSource, tester que TaskMapper.toSummary()
     tronque correctement les titres de plus de 50 caractères.

Niveau Intermédiaire :
  4. Écrire des tests pour CorrelationIdFilter :
     — Génère un X-Correlation-ID si absent du header entrant
     — Le transmet dans le header de réponse
     — Sanitise les sauts de ligne (Log Injection)
  5. Créer des tests couvrant TOUS les handlers de GlobalExceptionHandler.
  6. Utiliser ArgumentCaptor pour vérifier que createTask() sauvegarde
     une tâche avec le bon createdBy (UUID de l'utilisateur connecté).

Niveau Avancé :
  7. Implémenter une suite paramétrée (@MethodSource) couvrant TOUTES les
     transitions de statut (valides et invalides) depuis TaskStatus.getAllowedTransitions().
  8. Créer un CustomMockMvcResultMatchers ajoutant .andExpect(apiResponseSuccess()).
  9. Configurer PIT (Pitest) et atteindre un score de mutation > 80% sur TaskServiceImpl.


================================================================================
  CHAPITRE 35 : TESTS D'INTÉGRATION AVEC TESTCONTAINERS
================================================================================

────────────────────────────────────────────────────────────────────────────────
35.1  POURQUOI TESTCONTAINERS ?
────────────────────────────────────────────────────────────────────────────────

COMPARAISON DES OPTIONS
────────────────────────

  H2 (base mémoire)
    OK  : Rapide, pas de Docker
    NON : Dialects SQL différents (PostgreSQL vs H2)
    NON : Fonctionnalités PG manquantes (UUID::gen, JSONB, full-text)
    NON : Faux sentiment de sécurité

  PostgreSQL local
    OK  : Vrai PostgreSQL
    NON : Configuration manuelle par développeur
    NON : Pollution de données entre tests
    NON : CI/CD difficile sans configuration

  Testcontainers (RECOMMANDÉ)
    OK  : Vrai PostgreSQL (image Docker officielle)
    OK  : Container démarré/arrêté automatiquement
    OK  : Isolation totale — DB vierge par suite
    OK  : Fonctionne en local et CI/CD (avec Docker)
    OK  : Supporte PG, Redis, Kafka, RabbitMQ, +60 images


────────────────────────────────────────────────────────────────────────────────
35.2  CONFIGURATION DE BASE TESTCONTAINERS
────────────────────────────────────────────────────────────────────────────────

FILE: src/test/java/com/taskflow/backend/config/TestcontainersConfig.java
──────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.config;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;

/**
 * Spring Boot 3.1+ : @ServiceConnection gère automatiquement
 * l'injection des propriétés spring.datasource.* depuis le container.
 * Plus besoin de @DynamicPropertySource !
 */
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>(
            DockerImageName.parse("postgres:16-alpine")
        )
        .withDatabaseName("taskflow_test")
        .withUsername("taskflow")
        .withPassword("taskflow_secret")
        // Optimisations pour les tests (PAS pour la prod !)
        .withCommand(
            "postgres",
            "-c", "fsync=off",
            "-c", "synchronous_commit=off",
            "-c", "full_page_writes=off"
        );
    }
}


FILE: src/test/resources/application-test.properties
──────────────────────────────────────────────────────

spring.profiles.active=test
spring.flyway.enabled=true
spring.flyway.clean-on-validation-error=true
spring.jpa.hibernate.ddl-auto=validate
taskflow.jwt.secret=test-secret-key-with-minimum-32-characters-for-tests
taskflow.jwt.access-token.expiration=900000
taskflow.jwt.refresh-token.expiration=604800000
logging.level.root=ERROR
logging.level.com.taskflow.backend=WARN
logging.level.org.hibernate.SQL=DEBUG


────────────────────────────────────────────────────────────────────────────────
35.3  @DataJpaTest — TESTER LES REPOSITORIES
────────────────────────────────────────────────────────────────────────────────

THÉORIE
────────
@DataJpaTest charge UNIQUEMENT les beans JPA (@Entity, @Repository, EntityManager).
Avec @AutoConfigureTestDatabase(replace = Replace.NONE) + Testcontainers :
  -> Vrai PostgreSQL au lieu de H2
  -> Chaque @Test est rollback automatiquement (isolation parfaite)


FILE: src/test/java/com/taskflow/backend/repository/TaskRepositoryTest.java
─────────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.repository;

import com.taskflow.backend.config.TestcontainersConfig;
import com.taskflow.backend.model.*;
import com.taskflow.backend.model.enums.*;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.context.annotation.Import;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.*;

@DataJpaTest
@Import(TestcontainersConfig.class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@DisplayName("TaskRepository — Tests d'intégration")
class TaskRepositoryTest {

    @Autowired TaskRepository    taskRepository;
    @Autowired ProjectRepository projectRepository;
    @Autowired UserRepository    userRepository;
    @Autowired TestEntityManager em;

    private User    user;
    private Project project;

    @BeforeEach
    void setUp() {
        user = em.persist(User.builder()
            .email("alice@example.com")
            .fullName("Alice Martin")
            .passwordHash("$2a$12$hash")
            .role(UserRole.USER)
            .status(UserStatus.ACTIVE)
            .build());

        project = em.persist(Project.builder()
            .name("TaskFlow Backend")
            .owner(user)
            .status(ProjectStatus.ACTIVE)
            .build());

        em.flush();
    }

    @Test
    @DisplayName("OK findByUuid retourne la tâche quand elle existe")
    void findByUuid_existingTask_returnsTask() {
        var task = em.persist(Task.builder()
            .title("Implémenter login")
            .status(TaskStatus.TODO)
            .priority(TaskPriority.HIGH)
            .project(project)
            .build());
        em.flush();

        Optional<Task> result = taskRepository.findByUuid(task.getUuid());

        assertThat(result).isPresent();
        assertThat(result.get().getTitle()).isEqualTo("Implémenter login");
        assertThat(result.get().getProject().getName()).isEqualTo("TaskFlow Backend");
    }

    @Test
    @DisplayName("OK findByUuid retourne vide pour un UUID inexistant")
    void findByUuid_nonExistentUuid_returnsEmpty() {
        Optional<Task> result = taskRepository.findByUuid(java.util.UUID.randomUUID());
        assertThat(result).isEmpty();
    }

    @Test
    @DisplayName("OK Filtre les tâches par liste de statuts")
    void findByProjectAndStatusIn_returnsFilteredTasks() {
        em.persist(buildTask("TODO task",   TaskStatus.TODO));
        em.persist(buildTask("IN_PROGRESS", TaskStatus.IN_PROGRESS));
        em.persist(buildTask("DONE task",   TaskStatus.DONE));
        em.flush();

        List<Task> active = taskRepository.findByProjectAndStatusIn(
            project, List.of(TaskStatus.TODO, TaskStatus.IN_PROGRESS));

        assertThat(active).hasSize(2);
        assertThat(active).extracting(Task::getStatus)
            .containsExactlyInAnyOrder(TaskStatus.TODO, TaskStatus.IN_PROGRESS);
    }

    @Test
    @DisplayName("OK Soft delete — tâche supprimée exclue des requêtes")
    void softDelete_deletedTask_isExcludedFromQueries() {
        var task = em.persist(buildTask("Task à supprimer", TaskStatus.TODO));
        em.flush();
        var taskUuid = task.getUuid();

        // Soft delete
        task.setDeleted(true);
        task.setDeletedAt(java.time.Instant.now());
        em.persist(task);
        em.flush();
        em.clear(); // Vider le cache L1 pour forcer une vraie requête SQL

        // La tâche n'apparaît plus via le repository (@SQLRestriction)
        assertThat(taskRepository.findByUuid(taskUuid)).isEmpty();

        // Mais existe encore en base (via EntityManager direct)
        Task stillExists = em.find(Task.class, task.getId());
        assertThat(stillExists).isNotNull();
        assertThat(stillExists.isDeleted()).isTrue();
    }

    @Test
    @DisplayName("OK Pagination — retourne la bonne page et le bon tri")
    void findByProject_withPagination_returnsCorrectPage() {
        for (int i = 1; i <= 5; i++) {
            em.persist(buildTask("Task " + i, TaskStatus.TODO));
        }
        em.flush();

        PageRequest pageable = PageRequest.of(0, 2, Sort.by("title").ascending());
        Page<Task> page = taskRepository.findByProject(project, pageable);

        assertThat(page.getTotalElements()).isEqualTo(5);
        assertThat(page.getTotalPages()).isEqualTo(3);
        assertThat(page.getContent()).hasSize(2);
        assertThat(page.getContent().get(0).getTitle()).isEqualTo("Task 1");
    }

    private Task buildTask(String title, TaskStatus status) {
        return Task.builder()
            .title(title)
            .status(status)
            .priority(TaskPriority.MEDIUM)
            .project(project)
            .build();
    }
}


────────────────────────────────────────────────────────────────────────────────
35.4  @SpringBootTest — TESTS END-TO-END
────────────────────────────────────────────────────────────────────────────────

FILE: src/test/java/com/taskflow/backend/integration/AuthIntegrationTest.java
───────────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.integration;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.taskflow.backend.config.TestcontainersConfig;
import com.taskflow.backend.dto.request.LoginRequest;
import com.taskflow.backend.dto.request.RegisterRequest;
import com.taskflow.backend.repository.UserRepository;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.annotation.Transactional;

import static org.assertj.core.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

/**
 * Tests end-to-end : HTTP Request -> Filter -> Security -> Controller -> Service -> Repository -> DB
 * @Transactional sur la classe : chaque test est rollback automatiquement.
 */
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
@Import(TestcontainersConfig.class)
@ActiveProfiles("test")
@Transactional
@DisplayName("Auth — Tests d'intégration end-to-end")
class AuthIntegrationTest {

    @Autowired MockMvc        mockMvc;
    @Autowired ObjectMapper   objectMapper;
    @Autowired UserRepository userRepository;

    @Test
    @DisplayName("OK Inscription réussie -> 201 + utilisateur en base")
    void register_withValidData_creates201AndPersistsUser() throws Exception {
        var request = RegisterRequest.builder()
            .email("bob@example.com")
            .password("SecurePass123!")
            .fullName("Bob Dupont")
            .build();

        mockMvc.perform(post("/api/v1/auth/register")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.success").value(true))
            .andExpect(jsonPath("$.data.accessToken").isNotEmpty())
            .andExpect(jsonPath("$.data.tokenType").value("Bearer"));

        var saved = userRepository.findByEmail("bob@example.com");
        assertThat(saved).isPresent();
        assertThat(saved.get().getFullName()).isEqualTo("Bob Dupont");
        // Mot de passe DOIT être hashé (jamais en clair)
        assertThat(saved.get().getPasswordHash()).doesNotContain("SecurePass123!");
        assertThat(saved.get().getPasswordHash()).startsWith("$2a$");
    }

    @Test
    @DisplayName("KO Email déjà utilisé -> 409 Conflict")
    void register_withDuplicateEmail_returns409() throws Exception {
        var request = RegisterRequest.builder()
            .email("alice@example.com")
            .password("SecurePass123!")
            .fullName("Alice Martin")
            .build();

        // Premier enregistrement
        mockMvc.perform(post("/api/v1/auth/register")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated());

        // Deuxième enregistrement avec le même email
        mockMvc.perform(post("/api/v1/auth/register")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isConflict())
            .andExpect(jsonPath("$.errors[0]").value("CONFLICT_EMAIL_EXISTS"));
    }

    @Test
    @DisplayName("OK Flux complet : inscription -> connexion -> accès protégé")
    void fullAuthFlow_registerLoginAccessProtectedRoute_succeeds() throws Exception {
        // 1. INSCRIPTION
        var registerRequest = RegisterRequest.builder()
            .email("charlie@example.com")
            .password("SecurePass123!")
            .fullName("Charlie Brown")
            .build();

        MvcResult registerResult = mockMvc.perform(post("/api/v1/auth/register")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(registerRequest)))
            .andExpect(status().isCreated())
            .andReturn();

        String accessToken = objectMapper
            .readTree(registerResult.getResponse().getContentAsString())
            .at("/data/accessToken").asText();
        assertThat(accessToken).isNotBlank();

        // 2. CONNEXION
        var loginRequest = LoginRequest.builder()
            .email("charlie@example.com")
            .password("SecurePass123!")
            .build();

        MvcResult loginResult = mockMvc.perform(post("/api/v1/auth/login")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(loginRequest)))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.data.accessToken").isNotEmpty())
            .andReturn();

        String loginToken = objectMapper
            .readTree(loginResult.getResponse().getContentAsString())
            .at("/data/accessToken").asText();

        // 3. ACCÈS ROUTE PROTÉGÉE
        mockMvc.perform(get("/api/v1/users/me")
                .header("Authorization", "Bearer " + loginToken))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.data.email").value("charlie@example.com"));
    }
}


────────────────────────────────────────────────────────────────────────────────
35.5  TESTER LA SÉCURITÉ — ANNOTATION PERSONNALISÉE
────────────────────────────────────────────────────────────────────────────────

FILE: src/test/java/com/taskflow/backend/security/WithMockTaskFlowUser.java
─────────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.security;

import org.springframework.security.test.context.support.WithSecurityContext;
import java.lang.annotation.*;

/**
 * Injecte un UserPrincipal TaskFlow complet (avec UUID) dans le SecurityContext.
 *
 * Usage :
 *   @WithMockTaskFlowUser(email = "admin@example.com", role = "ADMIN")
 */
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockTaskFlowUserSecurityContextFactory.class)
public @interface WithMockTaskFlowUser {
    String email()  default "alice@example.com";
    String role()   default "USER";
    String uuid()   default "00000000-0000-0000-0000-000000000001";
}


FILE: src/test/java/com/taskflow/backend/security/WithMockTaskFlowUserSecurityContextFactory.java
──────────────────────────────────────────────────────────────────────────────────────────────────

package com.taskflow.backend.security;

import com.taskflow.backend.model.enums.UserRole;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.test.context.support.WithSecurityContextFactory;

import java.util.UUID;

public class WithMockTaskFlowUserSecurityContextFactory
        implements WithSecurityContextFactory<WithMockTaskFlowUser> {

    @Override
    public SecurityContext createSecurityContext(WithMockTaskFlowUser annotation) {
        var principal = UserPrincipal.builder()
            .uuid(UUID.fromString(annotation.uuid()))
            .email(annotation.email())
            .role(UserRole.valueOf(annotation.role()))
            .build();

        var auth = new UsernamePasswordAuthenticationToken(
            principal, null, principal.getAuthorities());

        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(auth);
        return context;
    }
}


EXEMPLES D'UTILISATION
───────────────────────

  @Test
  @WithMockTaskFlowUser(email = "admin@example.com", role = "ADMIN")
  void getUsers_asAdmin_returns200() throws Exception {
      mockMvc.perform(get("/api/v1/admin/users"))
          .andExpect(status().isOk());
  }

  @Test
  @WithMockTaskFlowUser(role = "USER")
  void getUsers_asUser_returns403() throws Exception {
      mockMvc.perform(get("/api/v1/admin/users"))
          .andExpect(status().isForbidden());
  }

  @Test
  @WithAnonymousUser
  void getUsers_asAnonymous_returns401() throws Exception {
      mockMvc.perform(get("/api/v1/admin/users"))
          .andExpect(status().isUnauthorized());
  }


────────────────────────────────────────────────────────────────────────────────
35.6  BONNES PRATIQUES — ERREURS FRÉQUENTES — EXERCICES
────────────────────────────────────────────────────────────────────────────────

BONNES PRATIQUES
────────────────
1. Séparer tests unitaires et d'intégration :
   — **Test.java : Maven Surefire (rapide, CI toujours)
   — **IT.java   : Maven Failsafe (plus lent, optionnel en dev)

2. Container Singleton : partager un PostgreSQLContainer entre toutes les
   classes de test via @Import(TestcontainersConfig.class).

3. @Transactional sur @SpringBootTest pour le rollback automatique.
   Exception : tester le rollback lui-même nécessite @Rollback(false).

4. TestEntityManager.flush() + clear() pour forcer de vraies requêtes SQL
   (vide le cache L1 Hibernate, évite les faux positifs).

5. @ActiveProfiles("test") sur tous les tests d'intégration pour éviter
   d'utiliser la configuration de production.

ERREURS FRÉQUENTES
──────────────────
- @SpringBootTest pour tout : 10x plus lent que @WebMvcTest ou @DataJpaTest
- Pas de @ActiveProfiles("test") : test utilise la config prod (vraie DB !)
- Oublier @AutoConfigureTestDatabase(replace=NONE) : Spring remplace par H2
- Tests sensibles à l'ordre d'exécution (dépendance entre tests)
- Oublier em.flush() après em.persist() : données pas encore en base

EXERCICES
─────────

Niveau Débutant :
  1. Écrire un test @DataJpaTest pour UserRepository.findByEmail() :
     — Retourne Optional.of(user) si l'email existe
     — Retourne Optional.empty() si l'email n'existe pas
     — Retourne Optional.empty() si l'utilisateur est DISABLED
  2. Test d'intégration : POST /api/v1/auth/login avec mauvais mot de passe
     retourne 401 avec AUTH_INVALID_CREDENTIALS.
  3. Vérifier que la contrainte unique sur users.email est en place
     (INSERT deux users avec le même email -> DataIntegrityViolationException).

Niveau Intermédiaire :
  4. Tester le flux refresh token : login -> récupérer cookie HttpOnly
     -> POST /api/v1/auth/refresh -> nouveau accessToken.
  5. Tester le soft delete en cascade : supprimer un projet -> toutes les
     tâches associées sont soft-deleted.
  6. Tester les TaskSpecifications : 10 tâches avec différents statuts/priorités,
     vérifier que les filtres retournent exactement les bonnes tâches.

Niveau Avancé :
  7. Mesures de performance avec JMH pour TaskRepository.findByProject()
     avec 1 000, 10 000 et 100 000 tâches.
  8. Créer un ContractTest avec Spring Cloud Contract pour POST /api/v1/tasks.
  9. AbstractIntegrationTest avec réseau Docker pour tester PostgreSQL + Redis
     en parallèle, vérifier que les opérations de cache sont invalidées.


================================================================================
  RÉCAPITULATIF DES FICHIERS CRÉÉS DANS CETTE PARTIE
================================================================================

  src/test/java/com/taskflow/backend/
  ├── fixture/
  │   └── TaskFlowFixtures.java
  ├── config/
  │   └── TestcontainersConfig.java
  ├── service/
  │   └── TaskServiceImplTest.java
  ├── controller/
  │   └── TaskControllerTest.java
  ├── exception/
  │   └── GlobalExceptionHandlerTest.java
  ├── repository/
  │   └── TaskRepositoryTest.java
  ├── integration/
  │   └── AuthIntegrationTest.java
  └── security/
      ├── WithMockTaskFlowUser.java
      └── WithMockTaskFlowUserSecurityContextFactory.java

  src/test/resources/
  └── application-test.properties


================================================================================
  FIN DE LA PARTIE 10
  Partie suivante -> Partie 11 : Cache et Performance
  (Caffeine Cache, @Cacheable, @CacheEvict, Redis, stratégies de cache)
================================================================================



================================================================================
  SPRING BOOT MASTER GUIDE — NIVEAU ENTREPRISE
  PARTIE 11 : CACHE ET PERFORMANCE
  Chapitres 36–37
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 36 — CACHE EN MÉMOIRE AVEC CAFFEINE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Introduction
────────────
Un cache est une mémoire rapide qui stocke temporairement les résultats de
calculs ou de requêtes coûteuses. L'idée : si la même donnée est demandée
plusieurs fois, on la calcule une fois et on la mémorise.

Pourquoi cacher ?
  • Les requêtes BDD sont coûteuses (réseau, disque I/O, CPU parsing SQL)
  • Certaines données changent rarement (config, profils utilisateur, tags…)
  • Le cache peut réduire le temps de réponse de 100ms -> 1ms
  • Il réduit la charge sur la base de données

Quand NE PAS cacher ?
  • Données qui changent fréquemment (stock en temps réel, messages...)
  • Données dont la fraîcheur est critique (transactions bancaires)
  • Données volumineuses qui rempliraient la RAM
  • Données qui varient selon l'utilisateur (à cacher avec précaution)

Dans TaskFlow, nous utilisons Caffeine (cache local JVM) pour :
  • Les profils utilisateur (`/users/{uuid}`)
  • Les détails de projet (`/projects/{uuid}`)
  • Les statistiques de projet (recalcul coûteux)
  • Les tags (rarement modifiés)

────────────────────────────────────────────────────────────────────────────────
36.1 Dépendances Maven
────────────────────────────────────────────────────────────────────────────────

<!-- pom.xml — Caffeine via Spring Boot Cache -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <!-- Version gérée par Spring Boot BOM -->
</dependency>

────────────────────────────────────────────────────────────────────────────────
36.2 Configuration du cache Caffeine
────────────────────────────────────────────────────────────────────────────────

// src/main/java/com/taskflow/backend/config/CacheConfig.java

package com.taskflow.backend.config;

import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Arrays;
import java.util.concurrent.TimeUnit;

/**
 * Configuration du cache Caffeine pour TaskFlow.
 *
 * Nous créons chaque cache avec des paramètres adaptés à son usage :
 *   - maximumSize : limite le nombre d'entrées (protection RAM)
 *   - expireAfterWrite : TTL depuis l'écriture (Time To Live)
 *   - expireAfterAccess : TTL depuis le dernier accès
 *   - recordStats : active les statistiques (hit rate, miss rate...)
 *
 * @EnableCaching active la détection des annotations @Cacheable, @CacheEvict, etc.
 */
@Slf4j
@Configuration
@EnableCaching
public class CacheConfig {

    // ── Noms des caches (constantes pour éviter les fautes de frappe) ──────────

    public static final String CACHE_USERS         = "users";
    public static final String CACHE_PROJECTS      = "projects";
    public static final String CACHE_PROJECT_STATS = "projectStats";
    public static final String CACHE_TAGS          = "tags";
    public static final String CACHE_TASKS         = "tasks";

    @Bean
    public CacheManager cacheManager() {
        SimpleCacheManager cacheManager = new SimpleCacheManager();

        cacheManager.setCaches(Arrays.asList(

            // Cache des utilisateurs
            // Profils peu modifiés -> TTL long (30 min), taille raisonnable
            buildCache(CACHE_USERS,
                Caffeine.newBuilder()
                    .maximumSize(1_000)          // Max 1000 profils en RAM
                    .expireAfterWrite(30, TimeUnit.MINUTES)
                    .recordStats()               // Pour /actuator/cachemanager
            ),

            // Cache des projets
            // Modifiés plus souvent -> TTL plus court (10 min)
            buildCache(CACHE_PROJECTS,
                Caffeine.newBuilder()
                    .maximumSize(500)
                    .expireAfterWrite(10, TimeUnit.MINUTES)
                    .recordStats()
            ),

            // Cache des statistiques de projet
            // Calcul coûteux (COUNT, SUM...) -> TTL court (5 min) mais acceptable
            buildCache(CACHE_PROJECT_STATS,
                Caffeine.newBuilder()
                    .maximumSize(500)
                    .expireAfterWrite(5, TimeUnit.MINUTES)
                    .recordStats()
            ),

            // Cache des tags
            // Tags très rarement modifiés -> TTL très long (1 heure)
            buildCache(CACHE_TAGS,
                Caffeine.newBuilder()
                    .maximumSize(2_000)
                    .expireAfterWrite(60, TimeUnit.MINUTES)
                    .expireAfterAccess(30, TimeUnit.MINUTES) // Aussi expire si non utilisé
                    .recordStats()
            ),

            // Cache des tâches individuelles
            // Données plus dynamiques -> TTL court (2 min)
            buildCache(CACHE_TASKS,
                Caffeine.newBuilder()
                    .maximumSize(5_000)
                    .expireAfterWrite(2, TimeUnit.MINUTES)
                    .recordStats()
            )
        ));

        log.info("Cache Caffeine configuré avec {} régions", 5);
        return cacheManager;
    }

    private CaffeineCache buildCache(String name, Caffeine<Object, Object> caffeine) {
        return new CaffeineCache(name, caffeine.build());
    }
}

────────────────────────────────────────────────────────────────────────────────
36.3 Annotations de cache Spring
────────────────────────────────────────────────────────────────────────────────

Spring propose 4 annotations principales :

@Cacheable      -> Vérifie le cache avant d'exécuter la méthode.
                  Si le résultat est en cache, retourne-le directement.
                  Sinon, exécute la méthode et stocke le résultat.

@CachePut       -> Exécute TOUJOURS la méthode et met à jour le cache.
                  Utile pour les updates.

@CacheEvict     -> Supprime une ou plusieurs entrées du cache.
                  Utilisé après les modifications ou suppressions.

@Caching        -> Combine plusieurs annotations de cache en une seule.

MÉCANIQUE DU KEY :
  Spring cache par clé. La clé par défaut est l'ensemble des paramètres.
  On peut la personnaliser avec SpEL :
    @Cacheable(value="users", key="#uuid")           -> clé = paramètre uuid
    @Cacheable(value="users", key="#user.email")     -> clé = champ de l'objet
    @Cacheable(value="stats", key="#projectId + '-' + #year")  -> clé composée

────────────────────────────────────────────────────────────────────────────────
36.4 Application du cache dans les Services TaskFlow
────────────────────────────────────────────────────────────────────────────────

// src/main/java/com/taskflow/backend/service/impl/UserServiceImpl.java (extraits)

package com.taskflow.backend.service.impl;

import com.taskflow.backend.config.CacheConfig;
import org.springframework.cache.annotation.*;

@Service
@RequiredArgsConstructor
@CacheConfig(cacheNames = CacheConfig.CACHE_USERS)
// @CacheConfig applique le nom de cache par défaut à toutes les annotations
// de la classe -> évite de répéter value="users" partout
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;
    private final UserMapper userMapper;
    private final PasswordEncoder passwordEncoder;

    /**
     * Récupère un utilisateur par UUID.
     *
     * @Cacheable : avant l'exécution, Spring vérifie le cache "users" avec la clé #uuid.
     *   - Cache HIT  -> retourne la valeur cachée, méthode non exécutée
     *   - Cache MISS -> exécute la méthode, stocke le résultat, retourne-le
     *
     * condition = "!#uuid.isEmpty()" -> ne pas cacher les UUIDs vides
     * unless = "#result == null" -> ne pas cacher les résultats null (sécurité)
     */
    @Override
    @Cacheable(key = "#uuid", condition = "!#uuid.isEmpty()", unless = "#result == null")
    public UserResponse getUserByUuid(String uuid) {
        log.debug("Cache MISS pour user:{} — requête BDD", uuid);
        return userRepository.findByUuid(uuid)
            .map(userMapper::toResponse)
            .orElseThrow(() -> ResourceNotFoundException.user(uuid));
    }

    /**
     * Met à jour un utilisateur.
     *
     * @CachePut : exécute TOUJOURS la méthode ET met à jour le cache.
     * La clé doit correspondre à celle de @Cacheable pour que le cache
     * soit correctement mis à jour.
     */
    @Override
    @Transactional
    @CachePut(key = "#uuid")
    public UserResponse updateUser(String uuid, UpdateUserRequest request) {
        User user = userRepository.findByUuid(uuid)
            .orElseThrow(() -> ResourceNotFoundException.user(uuid));

        // Appliquer les modifications
        if (request.getFirstName() != null) user.setFirstName(request.getFirstName());
        if (request.getLastName()  != null) user.setLastName(request.getLastName());
        // Le mot de passe change -> invalider dans les caches de sécurité si besoin
        if (request.getNewPassword() != null) {
            user.setPassword(passwordEncoder.encode(request.getNewPassword()));
        }

        // Le résultat de cette méthode remplace l'entrée dans le cache
        return userMapper.toResponse(user);
    }

    /**
     * Supprime un utilisateur.
     *
     * @CacheEvict : supprime l'entrée du cache pour cet UUID.
     * allEntries = true supprimerait TOUTES les entrées du cache (non recommandé ici).
     * beforeInvocation = false (défaut) : le cache est vidé APRÈS l'exécution réussie.
     */
    @Override
    @Transactional
    @CacheEvict(key = "#uuid")
    public void deleteUser(String uuid) {
        User user = userRepository.findByUuid(uuid)
            .orElseThrow(() -> ResourceNotFoundException.user(uuid));
        userRepository.delete(user);
    }
}

// ── ProjectServiceImpl avec @Caching ──────────────────────────────────────────

@Service
@RequiredArgsConstructor
@CacheConfig(cacheNames = CacheConfig.CACHE_PROJECTS)
public class ProjectServiceImpl implements ProjectService {

    @Override
    @Cacheable(key = "#uuid")
    public ProjectResponse getProjectByUuid(String uuid) {
        return projectRepository.findByUuid(uuid)
            .map(projectMapper::toResponse)
            .orElseThrow(() -> ResourceNotFoundException.project(uuid));
    }

    /**
     * Quand un projet est modifié, on invalide deux caches :
     *   - Le projet lui-même dans CACHE_PROJECTS
     *   - Les statistiques du projet dans CACHE_PROJECT_STATS
     *
     * @Caching permet de combiner plusieurs annotations.
     */
    @Override
    @Transactional
    @Caching(
        put = {
            @CachePut(cacheNames = CacheConfig.CACHE_PROJECTS, key = "#uuid")
        },
        evict = {
            @CacheEvict(cacheNames = CacheConfig.CACHE_PROJECT_STATS, key = "#uuid")
        }
    )
    public ProjectResponse updateProject(String uuid, UpdateProjectRequest request) {
        Project project = projectRepository.findByUuid(uuid)
            .orElseThrow(() -> ResourceNotFoundException.project(uuid));

        if (request.getName() != null) project.setName(request.getName());
        if (request.getDescription() != null) project.setDescription(request.getDescription());

        return projectMapper.toResponse(project);
    }

    /**
     * Statistiques de projet — calcul coûteux, bien adapté au cache.
     *
     * La clé contient l'UUID du projet. Le cache expire après 5 min
     * (configuré dans CacheConfig).
     */
    @Override
    @Cacheable(cacheNames = CacheConfig.CACHE_PROJECT_STATS, key = "#projectUuid")
    public ProjectStatsResponse getProjectStats(String projectUuid) {
        log.debug("Calcul des statistiques pour le projet {}", projectUuid);
        // Requêtes coûteuses :
        long totalTasks    = taskRepository.countByProjectUuid(projectUuid);
        long doneTasks     = taskRepository.countByProjectUuidAndStatus(projectUuid, TaskStatus.DONE);
        long overdueTasks  = taskRepository.countOverdueByProjectUuid(projectUuid);
        long blockedTasks  = taskRepository.countByProjectUuidAndStatus(projectUuid, TaskStatus.BLOCKED);

        return ProjectStatsResponse.builder()
            .totalTasks(totalTasks)
            .doneTasks(doneTasks)
            .overdueTasks(overdueTasks)
            .blockedTasks(blockedTasks)
            .completionRate(totalTasks > 0 ? (double) doneTasks / totalTasks * 100 : 0)
            .build();
    }
}

────────────────────────────────────────────────────────────────────────────────
36.5 Invalidation du cache lors des modifications de tâches
────────────────────────────────────────────────────────────────────────────────

Quand une tâche est créée/modifiée/supprimée, les statistiques du projet
deviennent obsolètes. Il faut invalider le cache des stats.

// Dans TaskServiceImpl

@Override
@Transactional
@Caching(
    evict = {
        // Invalider le cache de la tâche elle-même
        @CacheEvict(cacheNames = CacheConfig.CACHE_TASKS, key = "#uuid", condition = "#uuid != null"),
        // Invalider les stats du projet (elles ont changé !)
        @CacheEvict(cacheNames = CacheConfig.CACHE_PROJECT_STATS, key = "#result.projectUuid")
    }
)
public TaskResponse updateTask(String uuid, UpdateTaskRequest request) {
    Task task = taskRepository.findByUuid(uuid)
        .orElseThrow(() -> ResourceNotFoundException.task(uuid));
    // ... modifications ...
    return taskMapper.toResponse(task);
    // Le résultat porte le projectUuid -> @CacheEvict peut l'utiliser
}

@Override
@Transactional
@Caching(evict = {
    @CacheEvict(cacheNames = CacheConfig.CACHE_TASKS, key = "#uuid"),
    @CacheEvict(cacheNames = CacheConfig.CACHE_PROJECT_STATS, key = "#projectUuid")
})
public void deleteTask(String uuid, String projectUuid, Long userId) {
    // Note : projectUuid est passé en paramètre pour pouvoir l'utiliser dans la clé
    Task task = taskRepository.findByUuid(uuid)
        .orElseThrow(() -> ResourceNotFoundException.task(uuid));
    // ...
    taskRepository.softDeleteByUuid(uuid);
}

────────────────────────────────────────────────────────────────────────────────
36.6 Cache programmatique avec CacheManager
────────────────────────────────────────────────────────────────────────────────

Parfois les annotations ne suffisent pas (logique conditionnelle complexe).
On peut utiliser le CacheManager directement.

@Service
@RequiredArgsConstructor
public class TagServiceImpl implements TagService {

    private final TagRepository tagRepository;
    private final TagMapper tagMapper;
    private final CacheManager cacheManager;  // Injection du CacheManager

    /**
     * Récupère les tags d'un projet.
     * On invalide manuellement le cache quand un tag est ajouté/supprimé.
     */
    public List<TagResponse> getProjectTags(String projectUuid) {
        Cache cache = cacheManager.getCache(CacheConfig.CACHE_TAGS);
        String cacheKey = "project:" + projectUuid;

        // Vérifier le cache manuellement
        Cache.ValueWrapper cached = cache.get(cacheKey);
        if (cached != null) {
            log.debug("Cache HIT pour tags du projet {}", projectUuid);
            return (List<TagResponse>) cached.get();
        }

        // Cache MISS -> charger depuis BDD
        log.debug("Cache MISS pour tags du projet {}", projectUuid);
        List<Tag> tags = tagRepository.findByProjectUuid(projectUuid);
        List<TagResponse> responses = tagMapper.toResponseList(tags);

        // Stocker dans le cache
        cache.put(cacheKey, responses);
        return responses;
    }

    public TagResponse createTag(String projectUuid, CreateTagRequest request) {
        // ... création ...
        Tag saved = tagRepository.save(tag);

        // Invalider manuellement le cache des tags du projet
        Cache cache = cacheManager.getCache(CacheConfig.CACHE_TAGS);
        cache.evict("project:" + projectUuid);

        return tagMapper.toResponse(saved);
    }
}

────────────────────────────────────────────────────────────────────────────────
36.7 Monitoring du cache avec Spring Actuator
────────────────────────────────────────────────────────────────────────────────

Caffeine expose ses statistiques via Spring Actuator.

# application.properties — exposer l'endpoint caches
management.endpoints.web.exposure.include=health,info,metrics,caches
management.endpoint.caches.enabled=true

// CacheConfig.java — activer les stats Micrometer
// Ajouter dans la config Spring Boot :

@Bean
public CacheMetricsRegistrar cacheMetricsRegistrar(
        MeterRegistry meterRegistry,
        CacheManager cacheManager) {
    return new CacheMetricsRegistrar(meterRegistry, List.of(cacheManager));
}

// Endpoints disponibles :
// GET /actuator/caches              -> liste tous les caches
// GET /actuator/caches/{cacheName}  -> détails d'un cache
// DELETE /actuator/caches/{cacheName} -> vider un cache (admin)

// Métriques Micrometer disponibles (via /actuator/metrics) :
// cache.gets{name="users",result="hit"}  -> nombre de hits
// cache.gets{name="users",result="miss"} -> nombre de misses
// cache.size{name="users"}               -> taille actuelle
// cache.puts{name="users"}               -> nombre d'insertions
// cache.evictions{name="users"}          -> nombre d'évictions

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 37 — CACHE DISTRIBUÉ AVEC REDIS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Introduction
────────────
Caffeine est un cache en mémoire JVM. Il est parfait pour une instance unique,
mais dans un environnement multi-instances (Kubernetes, load balancer), chaque
instance a son propre cache. Si une instance modifie la donnée, les autres ont
toujours l'ancienne valeur en cache.

Solution : Redis — un cache distribué partagé par toutes les instances.

  Instance 1 ─────────┐
  Instance 2 ──────── ┼─-> Redis (cache partagé) ──-> PostgreSQL
  Instance 3 ─────────┘

Quand utiliser Caffeine vs Redis ?
  • Caffeine : développement local, applications mono-instance, données très
    fréquemment lues (hot data)
  • Redis : production multi-instances, sessions utilisateur, rate limiting,
    blacklist de tokens JWT

Dans TaskFlow, nous utilisons Redis pour :
  • La blacklist des tokens JWT (invalidation immédiate sur toutes les instances)
  • Le rate limiting (compteurs partagés)
  • Les sessions (optionnel)

────────────────────────────────────────────────────────────────────────────────
37.1 Configuration Redis
────────────────────────────────────────────────────────────────────────────────

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- Pour la sérialisation JSON dans Redis -->
<!-- Déjà présent via spring-boot-starter-web : jackson-databind -->

# application.properties — Redis
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.password=${REDIS_PASSWORD:}
spring.data.redis.database=0
spring.data.redis.timeout=2000ms
spring.data.redis.lettuce.pool.max-active=20
spring.data.redis.lettuce.pool.max-idle=10
spring.data.redis.lettuce.pool.min-idle=5
spring.data.redis.lettuce.pool.max-wait=1000ms

# docker-compose.yml — ajouter Redis
# redis:
#   image: redis:7-alpine
#   ports:
#     - "6379:6379"
#   command: redis-server --requirepass ${REDIS_PASSWORD:-}
#   volumes:
#     - redis_data:/data
#   healthcheck:
#     test: ["CMD", "redis-cli", "ping"]
#     interval: 10s
#     timeout: 5s
#     retries: 5

────────────────────────────────────────────────────────────────────────────────
37.2 Configuration RedisTemplate
────────────────────────────────────────────────────────────────────────────────

// src/main/java/com/taskflow/backend/config/RedisConfig.java

package com.taskflow.backend.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.*;

/**
 * Configuration de RedisTemplate pour TaskFlow.
 *
 * Par défaut, Spring sérialise les clés et valeurs en Java Serialization
 * (binaire, illisible dans redis-cli). On configure JSON pour la lisibilité
 * et la portabilité.
 */
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(
            RedisConnectionFactory connectionFactory) {

        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        // Sérialiser les clés en String lisible
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());

        // Sérialiser les valeurs en JSON
        ObjectMapper objectMapper = new ObjectMapper()
            .registerModule(new JavaTimeModule());

        GenericJackson2JsonRedisSerializer jsonSerializer =
            new GenericJackson2JsonRedisSerializer(objectMapper);

        template.setValueSerializer(jsonSerializer);
        template.setHashValueSerializer(jsonSerializer);

        template.afterPropertiesSet();
        return template;
    }
}

────────────────────────────────────────────────────────────────────────────────
37.3 Token Blacklist avec Redis
────────────────────────────────────────────────────────────────────────────────

Les access tokens JWT sont stateless — on ne peut pas les "révoquer" côté
serveur sans une blacklist. Redis est parfait pour ça : on y stocke les
tokens révoqués avec un TTL égal au temps restant avant expiration.

// src/main/java/com/taskflow/backend/service/impl/TokenBlacklistServiceImpl.java

package com.taskflow.backend.service.impl;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.time.Instant;

/**
 * Gestion de la blacklist des tokens JWT via Redis.
 *
 * Fonctionnement :
 *   1. Lors d'un logout, on ajoute le token dans Redis avec un TTL
 *      égal au temps restant avant expiration du token.
 *   2. Dans JwtAuthenticationFilter, avant de valider le token,
 *      on vérifie qu'il n'est pas blacklisté.
 *   3. Quand le TTL expire, Redis supprime automatiquement l'entrée.
 *      -> Aucune maintenance manuelle nécessaire !
 *
 * Format des clés Redis : "blacklist:token:{jti ou hash du token}"
 */
@Slf4j
@Service
@RequiredArgsConstructor
public class TokenBlacklistServiceImpl {

    private final RedisTemplate<String, Object> redisTemplate;

    private static final String BLACKLIST_PREFIX = "blacklist:token:";
    private static final String VALUE_REVOKED    = "REVOKED";

    /**
     * Ajoute un token à la blacklist.
     *
     * @param token      Le token JWT complet
     * @param expiresAt  Quand le token expire (pour calculer le TTL Redis)
     */
    public void blacklistToken(String token, Instant expiresAt) {
        // Utiliser un hash du token comme clé (éviter les clés trop longues)
        String key = BLACKLIST_PREFIX + hashToken(token);

        // TTL = temps restant avant expiration (pas besoin de garder plus longtemps)
        Duration ttl = Duration.between(Instant.now(), expiresAt);

        if (ttl.isNegative() || ttl.isZero()) {
            // Token déjà expiré, pas besoin de blacklister
            log.debug("Token déjà expiré, blacklist inutile");
            return;
        }

        redisTemplate.opsForValue().set(key, VALUE_REVOKED, ttl);
        log.info("Token blacklisté pour {}s", ttl.getSeconds());
    }

    /**
     * Vérifie si un token est dans la blacklist.
     */
    public boolean isBlacklisted(String token) {
        String key = BLACKLIST_PREFIX + hashToken(token);
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }

    /**
     * Hash du token pour une clé Redis courte.
     * On utilise les 16 derniers caractères de l'encodage Base64 du token
     * (suffisamment unique pour notre usage).
     *
     * En production, utilisez un vrai hash : SHA-256 par exemple.
     */
    private String hashToken(String token) {
        // Simple : les 32 derniers chars du token (partie signature)
        return token.length() > 32 ? token.substring(token.length() - 32) : token;
    }
}

// ── Intégration dans JwtAuthenticationFilter ─────────────────────────────────

// Dans JwtAuthenticationFilter.doFilterInternal(), AVANT la validation JWT :

@Override
protected void doFilterInternal(HttpServletRequest request,
                                 HttpServletResponse response,
                                 FilterChain chain) throws ServletException, IOException {

    String token = extractTokenFromHeader(request);

    if (token != null) {
        // VÉRIFICATION BLACKLIST D'ABORD
        if (tokenBlacklistService.isBlacklisted(token)) {
            log.warn("Tentative d'utilisation d'un token révoqué");
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            // ... écrire le JSON d'erreur
            return;
        }

        // Ensuite validation normale
        // ...
    }

    chain.doFilter(request, response);
}

// ── Endpoint de logout ─────────────────────────────────────────────────────────

// Dans AuthController :

@PostMapping("/logout")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void logout(
        @RequestHeader("Authorization") String authHeader,
        HttpServletRequest request) {

    String token = authHeader.substring(7); // Enlever "Bearer "
    Instant expiresAt = jwtService.extractExpiration(token);
    tokenBlacklistService.blacklistToken(token, expiresAt);

    // Optionnel : aussi révoquer le refresh token en BDD
    // ...

    log.info("Utilisateur déconnecté");
}

────────────────────────────────────────────────────────────────────────────────
37.4 Rate Limiting avec Redis
────────────────────────────────────────────────────────────────────────────────

Le rate limiting empêche les abus (brute force, DDoS, scraping).
Redis est idéal : compteurs atomiques partagés entre instances.

// src/main/java/com/taskflow/backend/service/impl/RateLimitServiceImpl.java

@Service
@RequiredArgsConstructor
public class RateLimitServiceImpl {

    private final RedisTemplate<String, Object> redisTemplate;

    /**
     * Vérifie si une IP dépasse la limite de requêtes.
     *
     * Algorithme : Compteur glissant par fenêtre de temps
     *   - Clé Redis : "ratelimit:{ip}:{fenêtre_en_minutes}"
     *   - Incrémente le compteur
     *   - Définit le TTL si c'est la première requête de la fenêtre
     *   - Si compteur > maxRequests -> limite dépassée
     *
     * @param ip          Adresse IP du client
     * @param maxRequests Nombre max de requêtes par fenêtre
     * @param windowMinutes Taille de la fenêtre temporelle en minutes
     * @return true si la limite n'est pas dépassée, false sinon
     */
    public boolean isAllowed(String ip, int maxRequests, int windowMinutes) {
        // Clé unique par IP et par fenêtre de temps
        String key = "ratelimit:" + ip + ":" + currentWindow(windowMinutes);

        // Incrémenter atomiquement (INCR est atomique dans Redis)
        Long count = redisTemplate.opsForValue().increment(key);

        if (count == null) return true; // Erreur Redis -> laisser passer (fail-open)

        if (count == 1) {
            // Première requête de la fenêtre -> définir l'expiration
            redisTemplate.expire(key, Duration.ofMinutes(windowMinutes));
        }

        return count <= maxRequests;
    }

    /**
     * Retourne le nombre de requêtes restantes pour cette IP.
     */
    public long getRemainingRequests(String ip, int maxRequests, int windowMinutes) {
        String key = "ratelimit:" + ip + ":" + currentWindow(windowMinutes);
        Object count = redisTemplate.opsForValue().get(key);
        if (count == null) return maxRequests;
        long used = Long.parseLong(count.toString());
        return Math.max(0, maxRequests - used);
    }

    private String currentWindow(int windowMinutes) {
        // Fenêtre temporelle : tranche de N minutes
        long windowSlot = Instant.now().getEpochSecond() / (windowMinutes * 60);
        return String.valueOf(windowSlot);
    }
}

// ── Filtre de rate limiting ───────────────────────────────────────────────────

@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
@RequiredArgsConstructor
public class RateLimitFilter extends OncePerRequestFilter {

    private final RateLimitServiceImpl rateLimitService;

    // 100 requêtes par minute par IP (ajustez selon votre besoin)
    private static final int MAX_REQUESTS = 100;
    private static final int WINDOW_MINUTES = 1;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {

        String ip = getClientIp(request);

        if (!rateLimitService.isAllowed(ip, MAX_REQUESTS, WINDOW_MINUTES)) {
            // Renvoyer 429 Too Many Requests
            response.setStatus(429);
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);

            // Ajouter les headers standard de rate limiting
            long remaining = rateLimitService.getRemainingRequests(ip, MAX_REQUESTS, WINDOW_MINUTES);
            response.addHeader("X-RateLimit-Limit", String.valueOf(MAX_REQUESTS));
            response.addHeader("X-RateLimit-Remaining", String.valueOf(remaining));
            response.addHeader("X-RateLimit-Window", WINDOW_MINUTES + "m");
            response.addHeader("Retry-After", String.valueOf(WINDOW_MINUTES * 60));

            response.getWriter().write("""
                {
                  "success": false,
                  "errorCode": "RATE_LIMIT_EXCEEDED",
                  "message": "Trop de requêtes. Veuillez patienter.",
                  "status": 429
                }
                """);
            return;
        }

        chain.doFilter(request, response);
    }

    /**
     * Extrait l'IP réelle du client (gère les proxies et load balancers).
     * X-Forwarded-For contient la chaîne des IPs (client, proxy1, proxy2...)
     */
    private String getClientIp(HttpServletRequest request) {
        String forwardedFor = request.getHeader("X-Forwarded-For");
        if (forwardedFor != null && !forwardedFor.isEmpty()) {
            return forwardedFor.split(",")[0].trim(); // Première IP = client original
        }
        String realIp = request.getHeader("X-Real-IP");
        if (realIp != null) return realIp;
        return request.getRemoteAddr();
    }
}

────────────────────────────────────────────────────────────────────────────────
37.5 Spring Cache avec Redis (remplacement de Caffeine en prod)
────────────────────────────────────────────────────────────────────────────────

En production multi-instances, on peut remplacer Caffeine par Redis comme
backend du CacheManager Spring. Les annotations @Cacheable, @CacheEvict, etc.
fonctionnent de la même façon — seul le stockage change.

// Profil de prod : utiliser Redis comme cache Spring
// src/main/java/com/taskflow/backend/config/RedisCacheConfig.java

@Configuration
@EnableCaching
@Profile("prod")  // Seulement en production
public class RedisCacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {

        // Configuration par défaut (pour les caches non spécifiés)
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration
            .defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeKeysWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    new StringRedisSerializer()
                )
            )
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair.fromSerializer(
                    new GenericJackson2JsonRedisSerializer()
                )
            )
            .disableCachingNullValues();

        // Configuration par cache
        Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();

        cacheConfigs.put(CacheConfig.CACHE_USERS,
            defaultConfig.entryTtl(Duration.ofMinutes(30)));

        cacheConfigs.put(CacheConfig.CACHE_PROJECTS,
            defaultConfig.entryTtl(Duration.ofMinutes(10)));

        cacheConfigs.put(CacheConfig.CACHE_PROJECT_STATS,
            defaultConfig.entryTtl(Duration.ofMinutes(5)));

        cacheConfigs.put(CacheConfig.CACHE_TAGS,
            defaultConfig.entryTtl(Duration.ofMinutes(60)));

        cacheConfigs.put(CacheConfig.CACHE_TASKS,
            defaultConfig.entryTtl(Duration.ofMinutes(2)));

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(cacheConfigs)
            .build();
    }
}

────────────────────────────────────────────────────────────────────────────────
37.6 Optimisations JPA pour la performance
────────────────────────────────────────────────────────────────────────────────

Le cache seul ne suffit pas. Les requêtes JPA doivent aussi être optimisées.

── Problème N+1 ────────────────────────────────────────────────────────────────

Le problème N+1 est la source de performance la plus fréquente avec JPA.
Il survient quand on charge N entités, puis pour chaque entité, JPA exécute
1 requête supplémentaire pour charger une association.

Exemple : charger 50 tâches avec leurs assignés :
  - 1 requête SELECT * FROM tasks LIMIT 50
  - 50 requêtes SELECT * FROM users WHERE id = ? (une par tâche)
  = 51 requêtes ! -> catastrophique en production

Solution 1 : @EntityGraph (chargement des associations en une requête JOIN)

// Dans TaskRepository :
@EntityGraph(attributePaths = {"assignee", "creator", "project", "tags"})
@Query("SELECT t FROM Task t WHERE t.project.uuid = :projectUuid")
List<Task> findByProjectUuidWithDetails(@Param("projectUuid") String projectUuid);

Solution 2 : @Query avec JOIN FETCH

@Query("SELECT t FROM Task t " +
       "JOIN FETCH t.assignee a " +
       "JOIN FETCH t.creator c " +
       "LEFT JOIN FETCH t.tags " +
       "WHERE t.project.uuid = :projectUuid " +
       "AND t.deletedAt IS NULL")
List<Task> findByProjectUuidFetchAll(@Param("projectUuid") String projectUuid);

Solution 3 : @BatchSize (pour les collections - évite le N+1 sur les listes)

@Entity
public class Task {
    @OneToMany(mappedBy = "task", fetch = FetchType.LAZY)
    @BatchSize(size = 20)  // Charge par lots de 20 au lieu de 1 par 1
    private List<Comment> comments;
}

── Projection (ne charger que les colonnes nécessaires) ─────────────────────────

Au lieu de charger l'entité Task complète (20+ colonnes), on peut charger
seulement ce dont on a besoin via une projection.

// Interface-based projection
public interface TaskSummary {
    String getUuid();
    String getTitle();
    String getStatus();
    String getPriority();
}

// Dans le repository
List<TaskSummary> findSummariesByProjectUuid(String projectUuid);
// -> SELECT uuid, title, status, priority FROM tasks WHERE project_uuid = ?
// -> Beaucoup moins de données transférées depuis PostgreSQL

── Pagination obligatoire ───────────────────────────────────────────────────────

NE JAMAIS faire findAll() sur une table potentiellement grande.
TOUJOURS paginer les listes d'entités.

// [X] Dangereux
List<Task> allTasks = taskRepository.findAll(); // Charge TOUTES les tâches !

// [OK] Correct
Page<Task> tasks = taskRepository.findByProjectUuid(
    projectUuid,
    PageRequest.of(0, 20, Sort.by("createdAt").descending())
);

── Index base de données ────────────────────────────────────────────────────────

Les index BDD sont aussi importants que le cache. Assurez-vous d'avoir des
index sur les colonnes utilisées dans les WHERE, ORDER BY, JOIN.

-- Migration Flyway : ajouter les index manquants
-- V2__add_performance_indexes.sql

-- Index pour les recherches par projet
CREATE INDEX idx_tasks_project_id ON tasks(project_id) WHERE deleted_at IS NULL;

-- Index pour les recherches par assigné
CREATE INDEX idx_tasks_assignee_id ON tasks(assignee_id) WHERE deleted_at IS NULL;

-- Index pour les recherches par statut dans un projet
CREATE INDEX idx_tasks_project_status ON tasks(project_id, status) WHERE deleted_at IS NULL;

-- Index pour les tâches en retard
CREATE INDEX idx_tasks_due_date ON tasks(due_date) WHERE status NOT IN ('DONE', 'CANCELLED');

-- Index pour la recherche textuelle (si pas de Elasticsearch)
CREATE INDEX idx_tasks_title_gin ON tasks USING gin(to_tsvector('french', title));

────────────────────────────────────────────────────────────────────────────────
37.7 Bonnes pratiques cache
────────────────────────────────────────────────────────────────────────────────

[OK] Cache les données lues fréquemment et modifiées rarement
   -> Profils utilisateur, configurations, référentiels (pays, catégories...)

[OK] Définir un TTL pour chaque cache
   -> Un cache sans TTL = fuite mémoire potentielle

[OK] Invalider le cache APRÈS l'écriture réussie (beforeInvocation=false)
   -> Si l'écriture échoue, le cache reste valide

[OK] Monitorer le hit rate (taux de succès du cache)
   -> Un hit rate < 80% signifie que le cache est mal configuré
   -> Analyser avec /actuator/metrics

[OK] Cacher des DTOs, pas des entités JPA
   -> Les entités contiennent des SessionFactory references
   -> Elles peuvent être en état "detached" et causer des LazyInitializationException

[OK] Documenter le TTL choisi et pourquoi
   -> Un TTL de 5 min pour les stats : "Acceptable car recalcul coûteux, fraîcheur
     de 5 min suffisante pour les dashboards"

[X] ÉVITER : Cacher des données sensibles (tokens, mots de passe)
[X] ÉVITER : Cacher avec des clés trop génériques (ex: cacher toute la liste
   des tâches d'un projet -> une seule modification invalide tout le cache)
[X] ÉVITER : @Cacheable sur une méthode transactionnelle (le cache peut
   retourner une valeur AVANT que la transaction soit committée)
[X] ÉVITER : Appels de méthodes @Cacheable depuis la même classe (le proxy
   Spring n'est pas appelé -> le cache est ignoré !)

PIÈGE CLASSIQUE : Self-invocation
──────────────────────────────────

// [X] Le cache NE fonctionnera PAS ici
@Service
public class UserServiceImpl {

    @Cacheable("users")
    public UserResponse getUserByUuid(String uuid) { ... }

    public void someOtherMethod() {
        getUserByUuid("abc"); // Appel INTERNE -> pas de proxy -> pas de cache !
    }
}

// [OK] Solution : injecter le service lui-même ou utiliser ApplicationContext
@Service
public class UserServiceImpl {

    @Autowired
    @Lazy  // Évite la dépendance circulaire
    private UserService self;  // Proxy Spring

    public void someOtherMethod() {
        self.getUserByUuid("abc"); // Appel via proxy -> cache fonctionnel
    }
}

────────────────────────────────────────────────────────────────────────────────
EXERCICES — CHAPITRE 36-37
────────────────────────────────────────────────────────────────────────────────

NIVEAU FACILE
─────────────

Exercice 1 : Configurez un cache "userRoles" dans CacheConfig pour mémoriser
les rôles d'un utilisateur pendant 15 minutes. Appliquez @Cacheable sur
getUserRoles(Long userId) dans UserServiceImpl. Ajoutez @CacheEvict sur
updateUserRole() pour invalider le cache quand le rôle change.

Exercice 2 : Écrivez un test unitaire qui vérifie que getUserByUuid() appelle
le repository seulement au premier appel (cache miss) et non au deuxième
(cache hit). Utilisez @SpringBootTest avec spring.cache.type=caffeine.

Exercice 3 : Ajoutez à l'endpoint GET /projects/{uuid}/stats un header
X-Cache: HIT ou X-Cache: MISS selon si la réponse vient du cache ou du calcul.
Astuce : injectez le CacheManager et vérifiez avant l'appel si la clé existe.

NIVEAU INTERMÉDIAIRE
────────────────────

Exercice 4 : Implémentez un endpoint d'administration DELETE
/api/v1/admin/cache/{cacheName} qui permet à un admin de vider un cache
spécifique. Sécurisez-le avec @PreAuthorize("hasRole('ADMIN')"). Retournez
un body indiquant le nombre d'entrées supprimées.

Exercice 5 : Optimisez la requête getProjectTasks() pour résoudre le problème
N+1. Utilisez @EntityGraph pour charger en une requête les tâches avec leurs
assignés, créateurs et tags. Vérifiez avec spring.jpa.show-sql=true que le
nombre de requêtes passe de N+1 à 1.

Exercice 6 : Implémentez une stratégie de cache "write-through" pour les
projets : quand un projet est créé (POST /projects), il est immédiatement
ajouté au cache (pas seulement mis en cache à la première lecture). Utilisez
@CachePut sur la méthode createProject().

NIVEAU AVANCÉ
─────────────

Exercice 7 : Cache multi-niveaux (L1/L2). Implémentez un système à deux
niveaux : Caffeine (L1, très rapide, local) devant Redis (L2, partagé).
Créez TwoLevelCacheManager qui cherche d'abord dans Caffeine, puis dans Redis,
puis dans la BDD. Les écritures vont vers les deux niveaux.

Exercice 8 : Implémentez un "cache warming" (préchauffage). Créez un
ApplicationRunner qui, au démarrage de l'application, précharge en cache
les 10 projets les plus actifs (les plus modifiés récemment). Cela évite
que les premières requêtes après un déploiement soient lentes (cold start).

Exercice 9 : Benchmark de performance. Utilisez JMeter ou k6 pour mesurer
les performances AVANT et APRÈS l'implémentation du cache. Créez un scénario
avec 100 utilisateurs concurrents, 10 requêtes chacun sur GET /projects/{uuid}.
Mesurez le p50, p95, p99 et le throughput (req/s). Documentez les résultats.

================================================================================
  FIN DE LA PARTIE 11
  Prochaine partie -> Microservices avec Spring Cloud (Eureka, Feign, Gateway)
================================================================================



================================================================================
  SPRING BOOT MASTER GUIDE — NIVEAU ENTREPRISE
  PARTIE 12 : MICROSERVICES AVEC SPRING CLOUD
  Chapitres 38–39
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 38 — ARCHITECTURE MICROSERVICES ET SPRING CLOUD
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Introduction
────────────
TaskFlow est actuellement un monolithe : tout le code est dans une seule
application. Pour évoluer vers une architecture microservices, on découpe
l'application en services indépendants, chacun responsable d'un domaine métier.

Monolithe (actuel) vs Microservices :

  Monolithe :
  ┌─────────────────────────────────────┐
  │ TaskFlow Backend                    │
  │  ┌──────────┐ ┌──────────────────┐  │
  │  │ User Svc │ │ Task & Project   │  │
  │  │          │ │ Service          │  │
  │  └──────────┘ └──────────────────┘  │
  │         ^v           ^v               │
  │         PostgreSQL (shared)         │
  └─────────────────────────────────────┘

  Microservices :
  ┌──────────────┐   ┌─────────────────┐   ┌──────────────────┐
  │ User Service │   │ Project Service │   │ Task Service     │
  │  Port 8081   │   │  Port 8082      │   │  Port 8083       │
  │  DB: users   │   │  DB: projects   │   │  DB: tasks       │
  └──────┬───────┘   └────────┬────────┘   └────────┬─────────┘
         │                   │                      │
         └──────────┬────────┘──────────────────────┘
                    │
           ┌────────[BLACK_DOWN-POINTING_TRIANGLE]────────┐
           │   API Gateway   │
           │   Port 8080     │
           └────────┬────────┘
                    │
               Client HTTP

Avantages des microservices :
  • Scalabilité indépendante (scaler uniquement Task Service si surchargé)
  • Déploiements indépendants (déployer User Service sans toucher Task Service)
  • Isolation des pannes (une panne de Notification Service n'affecte pas les tâches)
  • Technologies hétérogènes (un service en Go, un autre en Java)

Inconvénients :
  • Complexité opérationnelle accrue (N bases de données, N déploiements)
  • Communication réseau (vs appels de méthode en mémoire -> latence)
  • Cohérence éventuelle (transactions distribuées difficiles)
  • Debugging plus complexe (traces à travers plusieurs services)

────────────────────────────────────────────────────────────────────────────────
38.1 Architecture microservices TaskFlow
────────────────────────────────────────────────────────────────────────────────

Voici l'architecture que nous allons construire :

  ┌─────────────────────────────────────────────────────────────────────┐
  │                        CLIENTS                                      │
  │              Web App, Mobile, CLI                                   │
  └────────────────────────────┬────────────────────────────────────────┘
                               │ HTTPS
  ┌────────────────────────────[BLACK_DOWN-POINTING_TRIANGLE]────────────────────────────────────────┐
  │                     API GATEWAY (Spring Cloud Gateway)              │
  │                          Port 8080                                  │
  │    Routing, Auth Filter, Rate Limiting, Circuit Breaker             │
  └──────┬────────────────┬────────────────┬──────────────┬────────────┘
         │                │                │              │
  ┌──────[BLACK_DOWN-POINTING_TRIANGLE]──────┐  ┌───────[BLACK_DOWN-POINTING_TRIANGLE]──────┐  ┌────[BLACK_DOWN-POINTING_TRIANGLE]──────┐  ┌───[BLACK_DOWN-POINTING_TRIANGLE]──────────┐
  │ User Service│  │Task & Project│  │Notif Svc  │  │Auth Service  │
  │  :8081      │  │  Service :8082│  │  :8083    │  │  :8084       │
  │  users DB   │  │  tasks DB     │  │  (async)  │  │  auth DB     │
  └──────┬──────┘  └───────┬──────┘  └────┬──────┘  └──────────────┘
         │                 │              │
  ┌──────[BLACK_DOWN-POINTING_TRIANGLE]──────────────────[BLACK_DOWN-POINTING_TRIANGLE]──────────────[BLACK_DOWN-POINTING_TRIANGLE]──────────────────────────┐
  │              SERVICE REGISTRY (Eureka Server)                      │
  │                        Port 8761                                    │
  │    Chaque service s'enregistre -> Gateway découvre les services     │
  └────────────────────────────────────────────────────────────────────┘

Composants Spring Cloud utilisés :
  • Spring Cloud Gateway      : Point d'entrée unique, routing
  • Spring Cloud Netflix Eureka : Service discovery (registre de services)
  • Spring Cloud OpenFeign     : Client HTTP déclaratif pour les appels inter-services
  • Spring Cloud Circuit Breaker (Resilience4j) : Gestion des pannes

────────────────────────────────────────────────────────────────────────────────
38.2 Eureka Server — Service Registry
────────────────────────────────────────────────────────────────────────────────

Eureka est un annuaire de services. Chaque microservice s'y enregistre au
démarrage avec son nom et son adresse. Les autres services peuvent le consulter
pour découvrir où appeler leurs dépendances.

── Création du projet Eureka Server ────────────────────────────────────────────

Dépendances (spring-initializr) :
  - Eureka Server
  - Spring Boot Actuator

// taskflow-registry/src/main/java/.../EurekaServerApplication.java

@SpringBootApplication
@EnableEurekaServer  // Active le serveur Eureka
public class EurekaServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(EurekaServerApplication.class, args);
    }
}

# taskflow-registry/src/main/resources/application.yml

server:
  port: 8761

spring:
  application:
    name: taskflow-registry

eureka:
  instance:
    hostname: localhost
  client:
    # Ce serveur Eureka ne s'enregistre pas lui-même
    register-with-eureka: false
    fetch-registry: false
    service-url:
      defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/

  server:
    # Désactiver le mode "self-preservation" en dev
    # (en prod, le laisser activé pour éviter de désenregistrer des services vivants)
    enable-self-preservation: false

# L'interface Eureka est disponible sur http://localhost:8761

────────────────────────────────────────────────────────────────────────────────
38.3 Configuration des microservices comme clients Eureka
────────────────────────────────────────────────────────────────────────────────

Chaque microservice doit s'enregistrer dans Eureka.

Dépendances supplémentaires pour chaque service :
  - Eureka Discovery Client
  - Spring Cloud LoadBalancer (pour l'équilibrage de charge)

// Dans chaque microservice

@SpringBootApplication
@EnableDiscoveryClient  // S'enregistre dans Eureka
public class UserServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(UserServiceApplication.class, args);
    }
}

# application.yml de chaque microservice

spring:
  application:
    name: taskflow-user-service  # Nom utilisé dans Eureka

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/
  instance:
    # Préférer l'adresse IP plutôt que le hostname (plus fiable en Docker)
    prefer-ip-address: true
    # Heartbeat toutes les 5 secondes (défaut : 30s)
    lease-renewal-interval-in-seconds: 5
    # Délai avant de considérer le service mort (défaut : 90s)
    lease-expiration-duration-in-seconds: 10

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 39 — OPENFEIGN, GATEWAY ET CIRCUIT BREAKER
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

────────────────────────────────────────────────────────────────────────────────
39.1 OpenFeign — Client HTTP déclaratif
────────────────────────────────────────────────────────────────────────────────

Feign permet de définir des clients HTTP comme des interfaces Java annotées.
Spring Cloud OpenFeign génère automatiquement l'implémentation.

Exemple : le Task Service doit appeler le User Service pour récupérer les
informations d'un utilisateur lors de l'assignation d'une tâche.

Dépendances (dans taskflow-task-service/pom.xml) :

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>

// Activer Feign dans l'application

@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients(basePackages = "com.taskflow.taskservice.client")
public class TaskServiceApplication { ... }

// ── Définition du client Feign ────────────────────────────────────────────────

// src/main/java/com/taskflow/taskservice/client/UserServiceClient.java

package com.taskflow.taskservice.client;

import com.taskflow.taskservice.dto.UserDto;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Optional;

/**
 * Client Feign pour le User Service.
 *
 * @FeignClient(name = ...) utilise le nom du service dans Eureka.
 *   Feign + LoadBalancer résout automatiquement l'adresse via Eureka.
 *   Pas besoin de hardcoder http://localhost:8081 !
 *
 * fallback = UserServiceFallback.class : comportement si le service est down
 */
@FeignClient(
    name = "taskflow-user-service",          // Nom dans Eureka
    path = "/api/v1/users",                  // Préfixe de toutes les URLs
    fallback = UserServiceClientFallback.class  // Circuit Breaker fallback
)
public interface UserServiceClient {

    /**
     * Récupère un utilisateur par UUID.
     */
    @GetMapping("/{uuid}")
    ApiResponse<UserDto> getUserByUuid(@PathVariable String uuid);

    /**
     * Vérifie si un utilisateur existe.
     */
    @GetMapping("/{uuid}/exists")
    boolean userExists(@PathVariable String uuid);

    /**
     * Récupère plusieurs utilisateurs en une requête (batch).
     */
    @PostMapping("/batch")
    List<UserDto> getUsersByUuids(@RequestBody List<String> uuids);
}

// ── Fallback : comportement quand User Service est down ───────────────────────

@Component
public class UserServiceClientFallback implements UserServiceClient {

    @Override
    public ApiResponse<UserDto> getUserByUuid(String uuid) {
        // Retourner un utilisateur "anonyme" (dégradé gracieux)
        UserDto anonymous = UserDto.builder()
            .uuid(uuid)
            .firstName("Utilisateur")
            .lastName("Inconnu")
            .build();
        return ApiResponse.success(anonymous);
    }

    @Override
    public boolean userExists(String uuid) {
        // En cas de doute, retourner true pour ne pas bloquer (selon la criticité)
        return true;
    }

    @Override
    public List<UserDto> getUsersByUuids(List<String> uuids) {
        return List.of(); // Liste vide
    }
}

// ── Utilisation dans le Task Service ────────────────────────────────────────────

@Service
@RequiredArgsConstructor
public class TaskServiceImpl {

    private final TaskRepository taskRepository;
    private final UserServiceClient userServiceClient;  // Feign client injecté

    public TaskResponse assignTask(String taskUuid, String userUuid) {
        // Appel HTTP vers User Service (via Eureka + Load Balancer)
        // Feign gère la sérialisation/désérialisation JSON automatiquement
        boolean exists = userServiceClient.userExists(userUuid);
        if (!exists) {
            throw new BusinessRuleException(ErrorCode.USER_NOT_FOUND,
                Map.of("uuid", userUuid));
        }

        Task task = taskRepository.findByUuid(taskUuid)
            .orElseThrow(() -> ResourceNotFoundException.task(taskUuid));
        task.setAssigneeUuid(userUuid); // On stocke l'UUID, pas l'objet User

        return taskMapper.toResponse(task);
    }
}

────────────────────────────────────────────────────────────────────────────────
39.2 Spring Cloud Gateway — Point d'entrée unique
────────────────────────────────────────────────────────────────────────────────

L'API Gateway est le point d'entrée unique de toute l'architecture.
Elle gère :
  • Le routing vers les microservices
  • L'authentification JWT (centralisée)
  • Le rate limiting
  • La transformation des requêtes/réponses
  • Le circuit breaking

Dépendances (taskflow-gateway/pom.xml) :

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
</dependency>

# taskflow-gateway/src/main/resources/application.yml

server:
  port: 8080

spring:
  application:
    name: taskflow-gateway

  cloud:
    gateway:
      # Découverte automatique des services Eureka
      discovery:
        locator:
          enabled: true
          lower-case-service-id: true

      routes:

        # ── Route : User Service ───────────────────────────────────────────
        - id: user-service
          uri: lb://taskflow-user-service   # lb:// = Load Balanced via Eureka
          predicates:
            - Path=/api/v1/users/**
          filters:
            - name: CircuitBreaker
              args:
                name: userServiceCB
                fallbackUri: forward:/fallback/users
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 100
                redis-rate-limiter.burstCapacity: 200

        # ── Route : Task Service ───────────────────────────────────────────
        - id: task-service
          uri: lb://taskflow-task-service
          predicates:
            - Path=/api/v1/tasks/**, /api/v1/projects/**
          filters:
            - name: CircuitBreaker
              args:
                name: taskServiceCB
                fallbackUri: forward:/fallback/tasks
            # Ajouter header X-Request-Id pour le tracing
            - AddRequestHeader=X-Request-Id, ${random.uuid}

        # ── Route : Auth Service ───────────────────────────────────────────
        - id: auth-service
          uri: lb://taskflow-auth-service
          predicates:
            - Path=/api/v1/auth/**
          # Pas de filtre d'auth sur les routes d'authentification !

      # Configuration du circuit breaker par défaut
      default-filters:
        - name: Retry
          args:
            retries: 3
            statuses: BAD_GATEWAY, GATEWAY_TIMEOUT, SERVICE_UNAVAILABLE
            methods: GET  # Ne retry QUE les GET (opérations idempotentes)
            backoff:
              firstBackoff: 100ms
              maxBackoff: 1s
              factor: 2    # 100ms, 200ms, 400ms...

────────────────────────────────────────────────────────────────────────────────
39.3 Filtre d'authentification JWT dans la Gateway
────────────────────────────────────────────────────────────────────────────────

La validation JWT se fait dans la Gateway — les microservices peuvent faire
confiance aux requêtes qui passent la Gateway (elles ont déjà été validées).

// src/main/java/com/taskflow/gateway/filter/JwtGatewayFilter.java

@Component
@RequiredArgsConstructor
public class JwtGatewayFilter implements GlobalFilter, Ordered {

    private final JwtService jwtService;  // Partagé via une lib commune

    // Routes qui ne nécessitent pas d'authentification
    private static final List<String> PUBLIC_PATHS = List.of(
        "/api/v1/auth/login",
        "/api/v1/auth/register",
        "/api/v1/auth/refresh",
        "/actuator/health"
    );

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getPath().value();

        // Laisser passer les routes publiques
        if (PUBLIC_PATHS.stream().anyMatch(path::startsWith)) {
            return chain.filter(exchange);
        }

        // Extraire le token JWT
        String token = extractToken(exchange.getRequest());
        if (token == null) {
            return unauthorized(exchange, "Token manquant.");
        }

        // Valider le token
        try {
            if (!jwtService.isTokenValid(token)) {
                return unauthorized(exchange, "Token invalide.");
            }

            // Extraire les informations utilisateur et les passer en headers
            String userUuid  = jwtService.extractUserUuid(token);
            String userEmail = jwtService.extractEmail(token);
            String userRole  = jwtService.extractRole(token);

            // Enrichir la requête avec les infos utilisateur
            // Les microservices lisent ces headers au lieu de redécoder le JWT
            ServerHttpRequest enrichedRequest = exchange.getRequest().mutate()
                .header("X-User-Uuid",  userUuid)
                .header("X-User-Email", userEmail)
                .header("X-User-Role",  userRole)
                .build();

            return chain.filter(exchange.mutate().request(enrichedRequest).build());

        } catch (JwtException ex) {
            return unauthorized(exchange, "Token JWT invalide : " + ex.getMessage());
        }
    }

    private Mono<Void> unauthorized(ServerWebExchange exchange, String message) {
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
        exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);

        String body = """
            {"success":false,"errorCode":"UNAUTHORIZED","message":"%s","status":401}
            """.formatted(message);

        DataBuffer buffer = exchange.getResponse().bufferFactory()
            .wrap(body.getBytes(StandardCharsets.UTF_8));
        return exchange.getResponse().writeWith(Mono.just(buffer));
    }

    private String extractToken(ServerHttpRequest request) {
        String auth = request.getHeaders().getFirst("Authorization");
        if (auth != null && auth.startsWith("Bearer ")) {
            return auth.substring(7);
        }
        return null;
    }

    @Override
    public int getOrder() {
        return -100; // Priorité élevée : s'exécute en premier
    }
}

────────────────────────────────────────────────────────────────────────────────
39.4 Lecture des headers utilisateur dans les microservices
────────────────────────────────────────────────────────────────────────────────

Puisque la Gateway a validé le JWT et injecté les headers,
les microservices n'ont plus besoin de valider le JWT eux-mêmes.

// src/main/java/com/taskflow/taskservice/controller/TaskController.java

@RestController
@RequestMapping("/api/v1/tasks")
public class TaskController {

    @GetMapping
    public ResponseEntity<ApiResponse<Page<TaskResponse>>> getTasks(
            @RequestHeader("X-User-Uuid")  String currentUserUuid,
            @RequestHeader("X-User-Email") String currentUserEmail,
            @RequestHeader("X-User-Role")  String currentUserRole,
            @ModelAttribute TaskFilterRequest filter,
            Pageable pageable) {

        // On fait confiance aux headers injectés par la Gateway
        // Plus besoin de décoder un JWT !
        return ResponseEntity.ok(
            ApiResponse.success(taskService.getTasksForUser(
                currentUserUuid, filter, pageable
            ))
        );
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ApiResponse<TaskResponse> createTask(
            @RequestHeader("X-User-Uuid") String currentUserUuid,
            @Valid @RequestBody CreateTaskRequest request) {

        return ApiResponse.success(
            taskService.createTask(request, currentUserUuid)
        );
    }
}

────────────────────────────────────────────────────────────────────────────────
39.5 Resilience4j — Circuit Breaker
────────────────────────────────────────────────────────────────────────────────

Un Circuit Breaker est un pattern qui protège votre application des
défaillances en cascade. Si le User Service est lent ou en panne,
le Circuit Breaker "ouvre" le circuit et retourne immédiatement une réponse
de fallback sans attendre un timeout.

États du Circuit Breaker :

  CLOSED  -> Normal, les appels passent
    v (N% d'erreurs dans une fenêtre)
  OPEN    -> Circuit ouvert, appels bloqués -> fallback immédiat
    v (après un délai d'attente)
  HALF-OPEN -> Quelques appels test passent
    v (si succès)          v (si échec)
  CLOSED                  OPEN

# Configuration Resilience4j dans application.yml

resilience4j:
  circuitbreaker:
    configs:
      default:
        # Ouvrir le circuit si 50% des appels échouent
        failureRateThreshold: 50
        # Minimum d'appels pour calculer le taux (évite d'ouvrir sur 1/1 = 100%)
        minimumNumberOfCalls: 10
        # Fenêtre glissante de 10 appels pour calculer le taux d'échec
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 10
        # Attendre 30s avant de passer en HALF-OPEN
        waitDurationInOpenState: 30s
        # En HALF-OPEN, laisser passer 5 appels test
        permittedNumberOfCallsInHalfOpenState: 5
        # Types d'exceptions qui comptent comme échecs
        recordExceptions:
          - org.springframework.web.client.RestClientException
          - java.io.IOException
          - feign.FeignException

    instances:
      userServiceCB:
        base-config: default
        # Alerter quand le circuit s'ouvre
        registerHealthIndicator: true

  # Timeout pour les appels via Feign
  timelimiter:
    configs:
      default:
        timeoutDuration: 3s  # Max 3 secondes d'attente
    instances:
      userServiceCB:
        base-config: default

  # Retry automatique
  retry:
    configs:
      default:
        maxAttempts: 3
        waitDuration: 500ms
        retryExceptions:
          - feign.RetryableException
    instances:
      userServiceRetry:
        base-config: default

────────────────────────────────────────────────────────────────────────────────
39.6 Endpoint Fallback dans la Gateway
────────────────────────────────────────────────────────────────────────────────

Quand le Circuit Breaker ouvre, la Gateway redirige vers les endpoints fallback.

// src/main/java/com/taskflow/gateway/controller/FallbackController.java

@RestController
@RequestMapping("/fallback")
public class FallbackController {

    @GetMapping("/users")
    public ResponseEntity<ErrorResponse> usersServiceFallback() {
        log.warn("Circuit Breaker: User Service indisponible");
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
            .body(ErrorResponse.builder()
                .errorCode("SERVICE_UNAVAILABLE")
                .message("Le service utilisateur est temporairement indisponible. " +
                         "Veuillez réessayer dans quelques instants.")
                .status(503)
                .timestamp(Instant.now())
                .build()
            );
    }

    @GetMapping("/tasks")
    public ResponseEntity<ErrorResponse> tasksServiceFallback() {
        return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
            .body(ErrorResponse.builder()
                .errorCode("SERVICE_UNAVAILABLE")
                .message("Le service de tâches est temporairement indisponible.")
                .status(503)
                .timestamp(Instant.now())
                .build()
            );
    }
}

────────────────────────────────────────────────────────────────────────────────
39.7 Communication asynchrone vs synchrone
────────────────────────────────────────────────────────────────────────────────

Les appels Feign sont synchrones : le service appelant attend la réponse.
Pour certaines opérations (notifications, emails, analytics), il vaut mieux
une communication asynchrone via un message broker (Kafka, RabbitMQ).

Exemple : quand une tâche est assignée, envoyer une notification :

// Synchrone (avec Feign) — bloque jusqu'à la réponse
notificationClient.sendAssignmentNotification(taskUuid, assigneeUuid);
// Si Notification Service est lent -> toute la requête est lente

// Asynchrone (avec Kafka) — non bloquant
kafkaTemplate.send("task.assigned", new TaskAssignedEvent(taskUuid, assigneeUuid));
// Retour immédiat -> Notification Service traitera l'événement plus tard

Règle générale :
  • SYNCHRONE : quand vous avez besoin de la réponse pour continuer
    (ex: vérifier si l'utilisateur existe avant d'assigner)
  • ASYNCHRONE : quand l'opération peut être faite en arrière-plan
    (ex: envoyer un email, mettre à jour des analytics)

────────────────────────────────────────────────────────────────────────────────
39.8 docker-compose.yml complet pour les microservices
────────────────────────────────────────────────────────────────────────────────

version: '3.9'

services:

  # ── Infrastructure ─────────────────────────────────────────────────────────

  postgres-users:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: taskflow_users
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5433:5432"
    healthcheck:
      test: pg_isready -U postgres
      interval: 5s
      timeout: 3s
      retries: 5

  postgres-tasks:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: taskflow_tasks
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5434:5432"
    healthcheck:
      test: pg_isready -U postgres
      interval: 5s
      timeout: 3s
      retries: 5

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

  # ── Services Spring Cloud ──────────────────────────────────────────────────

  eureka-server:
    build:
      context: ./taskflow-registry
    ports:
      - "8761:8761"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8761/actuator/health"]
      interval: 10s
      timeout: 5s
      retries: 10

  api-gateway:
    build:
      context: ./taskflow-gateway
    ports:
      - "8080:8080"
    environment:
      EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://eureka-server:8761/eureka/
      SPRING_DATA_REDIS_HOST: redis
    depends_on:
      eureka-server:
        condition: service_healthy

  user-service:
    build:
      context: ./taskflow-user-service
    ports:
      - "8081:8081"
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres-users:5432/taskflow_users
      EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://eureka-server:8761/eureka/
    depends_on:
      eureka-server:
        condition: service_healthy
      postgres-users:
        condition: service_healthy

  task-service:
    build:
      context: ./taskflow-task-service
    ports:
      - "8082:8082"
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres-tasks:5432/taskflow_tasks
      EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://eureka-server:8761/eureka/
      USER_SERVICE_URL: http://taskflow-user-service  # Fallback si Eureka indisponible
    depends_on:
      eureka-server:
        condition: service_healthy
      postgres-tasks:
        condition: service_healthy

────────────────────────────────────────────────────────────────────────────────
39.9 Bonnes pratiques microservices
────────────────────────────────────────────────────────────────────────────────

[OK] Toujours définir un fallback pour les clients Feign
   -> Ne jamais laisser une panne d'un service cascader vers d'autres

[OK] Utiliser des timeouts sur tous les appels externes
   -> feign.client.config.default.connectTimeout=2000
   -> feign.client.config.default.readTimeout=5000

[OK] Ne partager pas les bases de données entre microservices
   -> Chaque service a sa propre base -> couplage zéro au niveau données

[OK] Versionner les contrats d'API entre services
   -> Un changement dans UserService peut casser TaskService
   -> Utiliser le versioning d'API : /api/v1/ et /api/v2/

[OK] Implémenter la corrélation des logs (tracing distribué)
   -> Un ID unique par requête qui traverse tous les services
   -> Spring Cloud Sleuth + Zipkin ou OpenTelemetry

[OK] Monitorer chaque service indépendamment
   -> Health checks (/actuator/health) dans chaque service
   -> Métriques Micrometer -> Prometheus/Grafana

[X] ÉVITER : Les appels circulaires entre services
   -> Service A -> Service B -> Service A -> boucle infinie potentielle
   -> Solution : Event-driven architecture (Kafka/RabbitMQ)

[X] ÉVITER : Les transactions distribuées (2PC)
   -> Très complexes et peu fiables en microservices
   -> Alternative : Saga pattern (transactions compensatoires)

[X] ÉVITER : De démarrer avec des microservices
   -> Commencez par un monolithe modulaire
   -> Découplez en microservices quand la complexité et la taille l'exigent
   -> "Microservices Premium" : le coût opérationnel est réel

────────────────────────────────────────────────────────────────────────────────
EXERCICES — CHAPITRE 38-39
────────────────────────────────────────────────────────────────────────────────

NIVEAU FACILE
─────────────

Exercice 1 : Créez un Eureka Server minimal. Démarrez-le et vérifiez
l'interface web sur http://localhost:8761. Enregistrez-y le monolithe TaskFlow
existant en ajoutant spring-cloud-starter-netflix-eureka-client et la
configuration eureka.client.serviceUrl.defaultZone.

Exercice 2 : Créez un FeignClient pour un service HTTP public (ex: JSONPlaceholder
à https://jsonplaceholder.typicode.com). Définissez un client Feign pour
GET /users/{id} et appelez-le depuis un contrôleur Spring Boot.

Exercice 3 : Configurez un simple fallback Feign qui retourne une valeur par
défaut quand le service cible est indisponible. Testez-le en arrêtant le
service cible et en vérifiant que l'application appelante ne plante pas.

NIVEAU INTERMÉDIAIRE
────────────────────

Exercice 4 : Implémentez la propagation des headers. Quand une requête entre
dans la Gateway avec un X-Correlation-ID, ce header doit être propagé à
TOUS les microservices appelés. Si le header est absent, la Gateway en génère
un. Vérifiez que chaque service loggue le correlationId reçu.

Exercice 5 : Configurez le Circuit Breaker Resilience4j sur un FeignClient.
Simulez une panne en lançant des erreurs depuis le service cible (mock). 
Vérifiez que : (a) après 5 erreurs consécutives, le circuit s'ouvre,
(b) le fallback est appelé, (c) après 30s, le circuit passe en HALF-OPEN,
(d) un appel réussi referme le circuit.

Exercice 6 : Implémentez le load balancing. Démarrez deux instances du
User Service (ports 8081 et 8091). Vérifiez dans les logs que la Gateway
distribue les requêtes entre les deux instances (round-robin par défaut
avec Spring Cloud LoadBalancer).

NIVEAU AVANCÉ
─────────────

Exercice 7 : Tracing distribué avec OpenTelemetry. Ajoutez spring-boot-starter-actuator
et micrometer-tracing-bridge-otel dans tous les services. Configurez un
collecteur Zipkin (dans docker-compose). Envoyez des requêtes et visualisez
les traces distribuées dans l'interface Zipkin. Vérifiez que le traceId est
le même à travers tous les services.

Exercice 8 : Saga Pattern pour la création d'un projet. La création d'un projet
implique : (1) créer le projet dans Project Service, (2) créer les permissions
dans User Service, (3) initialiser le board dans Task Service. Si l'étape 3
échoue, les étapes 1 et 2 doivent être annulées (transactions compensatoires).
Implémentez ce Saga choreography-based avec des événements Kafka.

Exercice 9 : Service Mesh avec Istio (simulation). Configurez un cluster
Minikube local avec Istio. Déployez les microservices TaskFlow. Configurez
une règle de traffic splitting : 90% vers la v1 de Task Service, 10% vers la v2
(canary deployment). Observez les métriques de trafic dans Kiali.

================================================================================
  FIN DE LA PARTIE 12
  Prochaine partie -> Kafka et RabbitMQ (messaging asynchrone)
================================================================================



================================================================================
   SPRING BOOT MASTER GUIDE — NIVEAU ENTREPRISE
   PARTIE 13 : MESSAGING ASYNCHRONE — KAFKA & RABBITMQ
   Chapitres 40-41
================================================================================

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 40 — APACHE KAFKA AVEC SPRING BOOT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

40.1 INTRODUCTION — POURQUOI LE MESSAGING ASYNCHRONE ?
───────────────────────────────────────────────────────

Dans une architecture synchrone classique :
  ① L'utilisateur crée une tâche
  ② Le service envoie une notification email -> attend 500ms
  ③ Le service met à jour les métriques -> attend 200ms
  ④ La réponse arrive en 800ms au lieu de 50ms

PROBLÈMES :
  - La latence s'accumule (couplage temporel)
  - Si le service email est en panne, la création de tâche échoue aussi
  - Impossible de scaler les services indépendamment

SOLUTION : Messaging asynchrone (découplage)
  ① L'utilisateur crée une tâche
  ② Le service publie un événement "TASK_CREATED" -> immédiat
  ③ La réponse arrive en 50ms
  ④ En parallèle : service email consomme l'événement et envoie l'email
  ⑤ En parallèle : service métriques consomme l'événement

SCHÉMA DE FLUX KAFKA :

  TaskService           Kafka Broker           Notification Service
      │                     │                         │
      │  publish(event)      │                         │
      │──────────────────[BLACK_RIGHT-POINTING_TRIANGLE]  │                         │
      │                     │   consume(event)         │
      │                     │──────────────────────[BLACK_RIGHT-POINTING_TRIANGLE]   │
      │                     │                  send email
      │                     │                         │
  Réponse 200ms             │              (asynchrone)

KAFKA VS RABBITMQ :

  KAFKA                        RABBITMQ
  ─────────────────────────    ─────────────────────────
  Log distribué et durable     File de messages classique
  Conservation des messages    Messages supprimés après consommation
  Haute performance (M/msg/s)  Performance modérée
  Replay possible              Pas de replay natif
  Pour les flux de données     Pour les tâches ponctuelles
  Idéal : événements, audit    Idéal : tâches, RPC asynchrone

────────────────────────────────────────────────────────────────────────────────
40.2 CONCEPTS KAFKA ESSENTIELS
────────────────────────────────────────────────────────────────────────────────

TOPIC : Canal de messages (comme un sujet/catégorie)
  -> "task-events", "user-events", "notification-events"

PARTITION : Subdivision d'un topic pour le parallélisme
  -> Topic "task-events" -> 3 partitions -> 3 consumers en parallèle

OFFSET : Position d'un message dans une partition
  -> Permet de rejouer les messages depuis n'importe quel point

PRODUCER : Service qui publie des messages
CONSUMER : Service qui lit des messages
CONSUMER GROUP : Groupe de consumers qui collaborent
  -> Chaque partition est traitée par UN seul consumer du groupe

BROKER : Serveur Kafka qui stocke les messages
ZOOKEEPER / KRaft : Gestion du cluster (KRaft = mode moderne sans Zookeeper)

SCHÉMA :

  Producer          Kafka Broker
  ─────────         ───────────────────────────────────
  TaskService  ->    Topic: task-events
                    ├─ Partition 0: [msg0, msg1, msg2, ...]
                    ├─ Partition 1: [msg3, msg4, msg5, ...]
                    └─ Partition 2: [msg6, msg7, msg8, ...]

                    Consumer Group: "notification-service"
                    ├─ Consumer 0 -> Partition 0
                    ├─ Consumer 1 -> Partition 1
                    └─ Consumer 2 -> Partition 2

────────────────────────────────────────────────────────────────────────────────
40.3 DÉPENDANCES ET CONFIGURATION
────────────────────────────────────────────────────────────────────────────────

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
    <!-- Version gérée par spring-boot-starter-parent -->
</dependency>

──────────────────────────────────────

# application.properties — Configuration Kafka

# ─── Connexion au broker ─────────────────────────────────────────────────────
spring.kafka.bootstrap-servers=localhost:9092
# En production (cluster) :
# spring.kafka.bootstrap-servers=kafka1:9092,kafka2:9092,kafka3:9092

# ─── Producer ────────────────────────────────────────────────────────────────
# Sérialisation : String pour la clé, JSON pour la valeur
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer

# Durabilité : attendre que tous les réplicas aient reçu le message
spring.kafka.producer.acks=all

# Retry automatique en cas d'erreur réseau (max 3 fois)
spring.kafka.producer.retries=3

# Batching : regrouper les messages pour réduire le nombre de requêtes réseau
spring.kafka.producer.batch-size=16384
spring.kafka.producer.linger-ms=5

# Compression des messages (snappy = bon compromis vitesse/compression)
spring.kafka.producer.compression-type=snappy

# ─── Consumer ────────────────────────────────────────────────────────────────
spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer
spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer

# Groupe de consommateurs
spring.kafka.consumer.group-id=taskflow-backend

# Où commencer si le consumer n'a pas d'offset sauvegardé
# earliest = depuis le début | latest = seulement les nouveaux messages
spring.kafka.consumer.auto-offset-reset=earliest

# Désactiver le commit automatique (on committe manuellement après traitement)
spring.kafka.consumer.enable-auto-commit=false

# Types de confiance pour la désérialisation JSON (sécurité)
spring.kafka.consumer.properties.spring.json.trusted.packages=com.taskflow.backend.event

# ─── Listener ─────────────────────────────────────────────────────────────────
# Mode manuel : commit après traitement réussi
spring.kafka.listener.ack-mode=manual_immediate

────────────────────────────────────────────────────────────────────────────────
40.4 MODÈLES D'ÉVÉNEMENTS (EVENT OBJECTS)
────────────────────────────────────────────────────────────────────────────────

// src/main/java/com/taskflow/backend/event/TaskEvent.java

package com.taskflow.backend.event;

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.taskflow.backend.model.enums.TaskStatus;
import lombok.Builder;
import lombok.Getter;
import lombok.extern.jackson.Jacksonized;

import java.time.Instant;
import java.util.UUID;

/**
 * Événement publié lors de tout changement sur une tâche.
 *
 * @JsonTypeInfo : inclut le type Java dans le JSON pour la désérialisation
 *   Exemple : {"@class": "com.taskflow.backend.event.TaskEvent", ...}
 */
@Getter
@Builder
@Jacksonized
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) // nécessaire pour la désérialisation polymorphique
public class TaskEvent {

    public enum EventType {
        TASK_CREATED,
        TASK_UPDATED,
        TASK_STATUS_CHANGED,
        TASK_ASSIGNED,
        TASK_DELETED,
        TASK_COMMENT_ADDED,
        TASK_DUE_DATE_APPROACHING
    }

    // Identifiant unique de l'événement (pour la déduplication)
    @Builder.Default
    private final String eventId = UUID.randomUUID().toString();

    // Type d'événement
    private final EventType type;

    // Identifiant de la tâche concernée
    private final UUID taskUuid;

    // Identifiant du projet
    private final UUID projectUuid;

    // Utilisateur qui a déclenché l'événement
    private final UUID actorUuid;

    // Données spécifiques à l'événement
    private final String taskTitle;
    private final TaskStatus previousStatus;
    private final TaskStatus newStatus;
    private final UUID assigneeUuid;

    // Timestamp de l'événement (UTC)
    @Builder.Default
    private final Instant occurredAt = Instant.now();

    // Version du schéma (pour la compatibilité future)
    @Builder.Default
    private final int schemaVersion = 1;
}

──────────────────────────────────────

// src/main/java/com/taskflow/backend/event/UserEvent.java

package com.taskflow.backend.event;

import lombok.Builder;
import lombok.Getter;
import lombok.extern.jackson.Jacksonized;

import java.time.Instant;
import java.util.UUID;

@Getter
@Builder
@Jacksonized
public class UserEvent {

    public enum EventType {
        USER_REGISTERED,
        USER_PASSWORD_RESET_REQUESTED,
        USER_EMAIL_VERIFIED,
        USER_ACCOUNT_LOCKED
    }

    @Builder.Default
    private final String eventId = UUID.randomUUID().toString();

    private final EventType type;
    private final UUID userUuid;
    private final String userEmail;
    private final String firstName;

    @Builder.Default
    private final Instant occurredAt = Instant.now();
}

────────────────────────────────────────────────────────────────────────────────
40.5 CONFIGURATION KAFKA (TOPICS + PRODUCER + CONSUMER)
────────────────────────────────────────────────────────────────────────────────

// src/main/java/com/taskflow/backend/config/KafkaConfig.java

package com.taskflow.backend.config;

import com.taskflow.backend.event.TaskEvent;
import com.taskflow.backend.event.UserEvent;
import org.apache.kafka.clients.admin.NewTopic;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.config.TopicBuilder;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.core.ProducerFactory;
import org.springframework.kafka.support.serializer.JsonSerializer;

import java.util.Map;

/**
 * Configuration des topics Kafka et des producers/consumers.
 *
 * Les topics sont créés automatiquement au démarrage si ils n'existent pas.
 */
@Configuration
public class KafkaConfig {

    // ─── Noms des topics ──────────────────────────────────────────────────
    public static final String TOPIC_TASK_EVENTS  = "taskflow.task-events";
    public static final String TOPIC_USER_EVENTS  = "taskflow.user-events";
    public static final String TOPIC_NOTIF_EVENTS = "taskflow.notification-requests";

    // ─── Création des topics ──────────────────────────────────────────────

    /**
     * Topic pour les événements de tâches.
     * 3 partitions = 3 consumers en parallèle maximum
     * 2 réplicas = tolérance à la panne d'un broker
     */
    @Bean
    public NewTopic taskEventsTopic() {
        return TopicBuilder.name(TOPIC_TASK_EVENTS)
            .partitions(3)
            .replicas(1) // 1 en dev/test, 2-3 en production
            .config("retention.ms", "604800000") // 7 jours de rétention
            .config("compression.type", "snappy")
            .build();
    }

    @Bean
    public NewTopic userEventsTopic() {
        return TopicBuilder.name(TOPIC_USER_EVENTS)
            .partitions(2)
            .replicas(1)
            .build();
    }

    @Bean
    public NewTopic notificationEventsTopic() {
        return TopicBuilder.name(TOPIC_NOTIF_EVENTS)
            .partitions(3)
            .replicas(1)
            .build();
    }
}

────────────────────────────────────────────────────────────────────────────────
40.6 PRODUCER : PUBLIER DES ÉVÉNEMENTS
────────────────────────────────────────────────────────────────────────────────

// src/main/java/com/taskflow/backend/messaging/TaskEventPublisher.java

package com.taskflow.backend.messaging;

import com.taskflow.backend.config.KafkaConfig;
import com.taskflow.backend.event.TaskEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.SendResult;
import org.springframework.stereotype.Component;

import java.util.UUID;
import java.util.concurrent.CompletableFuture;

/**
 * Composant responsable de la publication des événements de tâches dans Kafka.
 *
 * Pattern : le service métier ne connaît pas Kafka directement.
 * Il appelle ce publisher qui gère tous les détails Kafka.
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class TaskEventPublisher {

    // KafkaTemplate<K, V> : K = type de la clé, V = type de la valeur
    private final KafkaTemplate<String, TaskEvent> kafkaTemplate;

    /**
     * Publie un événement de création de tâche.
     *
     * Clé du message = UUID du projet
     *   -> Toutes les tâches d'un même projet vont dans la même partition
     *   -> Garantit l'ordre des événements par projet
     */
    public void publishTaskCreated(
        UUID taskUuid,
        UUID projectUuid,
        String taskTitle,
        UUID actorUuid
    ) {
        TaskEvent event = TaskEvent.builder()
            .type(TaskEvent.EventType.TASK_CREATED)
            .taskUuid(taskUuid)
            .projectUuid(projectUuid)
            .taskTitle(taskTitle)
            .actorUuid(actorUuid)
            .build();

        publishEvent(event, projectUuid.toString());
    }

    /**
     * Publie un changement de statut de tâche.
     */
    public void publishStatusChanged(
        UUID taskUuid,
        UUID projectUuid,
        com.taskflow.backend.model.enums.TaskStatus from,
        com.taskflow.backend.model.enums.TaskStatus to,
        UUID actorUuid
    ) {
        TaskEvent event = TaskEvent.builder()
            .type(TaskEvent.EventType.TASK_STATUS_CHANGED)
            .taskUuid(taskUuid)
            .projectUuid(projectUuid)
            .previousStatus(from)
            .newStatus(to)
            .actorUuid(actorUuid)
            .build();

        publishEvent(event, projectUuid.toString());
    }

    /**
     * Méthode générique d'envoi d'événement.
     *
     * @param event    L'événement à publier
     * @param key      Clé du message (détermine la partition)
     */
    private void publishEvent(TaskEvent event, String key) {
        // send() retourne un CompletableFuture<SendResult>
        CompletableFuture<SendResult<String, TaskEvent>> future =
            kafkaTemplate.send(KafkaConfig.TOPIC_TASK_EVENTS, key, event);

        // Callback de succès/échec (non-bloquant)
        future.whenComplete((result, exception) -> {
            if (exception != null) {
                // Logguer l'erreur mais NE PAS faire échouer la transaction principale
                // L'événement sera re-tenté par le mécanisme de retry
                log.error(
                    "Échec de publication de l'événement {} pour la tâche {}: {}",
                    event.getType(), event.getTaskUuid(), exception.getMessage()
                );
                // En production : sauvegarder l'événement en DB (outbox pattern)
                saveFailedEventToOutbox(event);
            } else {
                log.debug(
                    "Événement {} publié sur partition {} offset {}",
                    event.getType(),
                    result.getRecordMetadata().partition(),
                    result.getRecordMetadata().offset()
                );
            }
        });
    }

    private void saveFailedEventToOutbox(TaskEvent event) {
        // Voir section 40.9 : Transactional Outbox Pattern
        log.warn("TODO: Sauvegarder l'événement {} dans l'outbox", event.getEventId());
    }
}

──────────────────────────────────────

// Intégration dans TaskServiceImpl :

@Service
@RequiredArgsConstructor
@Transactional
public class TaskServiceImpl implements TaskService {

    private final TaskRepository taskRepository;
    private final TaskEventPublisher eventPublisher; // injection du publisher

    @Override
    public TaskResponse createTask(CreateTaskRequest request, UUID creatorUuid) {
        // ... logique métier ...
        Task savedTask = taskRepository.save(task);

        // Publier l'événement APRÈS la sauvegarde en DB
        // (dans la même transaction Spring, mais Kafka n'est pas transactionnel par défaut)
        eventPublisher.publishTaskCreated(
            savedTask.getUuid(),
            savedTask.getProject().getUuid(),
            savedTask.getTitle(),
            creatorUuid
        );

        return taskMapper.toResponse(savedTask);
    }
}

────────────────────────────────────────────────────────────────────────────────
40.7 CONSUMER : CONSOMMER DES ÉVÉNEMENTS
────────────────────────────────────────────────────────────────────────────────

// src/main/java/com/taskflow/backend/messaging/TaskEventConsumer.java

package com.taskflow.backend.messaging;

import com.taskflow.backend.config.KafkaConfig;
import com.taskflow.backend.event.TaskEvent;
import com.taskflow.backend.service.NotificationService;
import com.taskflow.backend.service.AuditService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.support.Acknowledgment;
import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Component;

/**
 * Consumer Kafka pour les événements de tâches.
 *
 * @KafkaListener : Spring démarre automatiquement un thread de consommation.
 *
 * NOTE : Dans une architecture microservices, ce consumer serait dans un
 * service séparé (notification-service, audit-service...).
 * Ici, pour simplifier, il est dans le même service.
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class TaskEventConsumer {

    private final NotificationService notificationService;
    private final AuditService auditService;

    /**
     * Consomme les événements du topic task-events.
     *
     * @KafkaListener :
     *   topics       = nom(s) des topics à écouter
     *   groupId      = groupe de consumers (partage la charge)
     *   concurrency  = nombre de threads de consommation (≤ nombre de partitions)
     */
    @KafkaListener(
        topics = KafkaConfig.TOPIC_TASK_EVENTS,
        groupId = "taskflow-backend",
        concurrency = "3" // 3 threads = 1 par partition
    )
    public void consumeTaskEvent(
        ConsumerRecord<String, TaskEvent> record,
        Acknowledgment acknowledgment // pour le commit manuel
    ) {
        TaskEvent event = record.value();

        log.info(
            "Réception événement {} | tâche={} | partition={} | offset={}",
            event.getType(), event.getTaskUuid(),
            record.partition(), record.offset()
        );

        try {
            // Router vers le handler approprié selon le type d'événement
            switch (event.getType()) {
                case TASK_CREATED       -> handleTaskCreated(event);
                case TASK_STATUS_CHANGED -> handleStatusChanged(event);
                case TASK_ASSIGNED      -> handleTaskAssigned(event);
                case TASK_DELETED       -> handleTaskDeleted(event);
                default -> log.debug("Événement {} ignoré", event.getType());
            }

            // Commit de l'offset seulement si le traitement a réussi
            acknowledgment.acknowledge();

        } catch (Exception e) {
            log.error(
                "Erreur lors du traitement de l'événement {} (offset={}): {}",
                event.getEventId(), record.offset(), e.getMessage(), e
            );
            // NE PAS committer -> Kafka va re-livrer le message
            // Après N retries, le message va dans le Dead Letter Topic (DLT)
            throw e; // re-lever pour déclencher le mécanisme de retry
        }
    }

    // ─── Handlers spécialisés ─────────────────────────────────────────────

    private void handleTaskCreated(TaskEvent event) {
        log.debug("Traitement TASK_CREATED pour {}", event.getTaskUuid());

        // 1. Envoyer une notification aux membres du projet
        notificationService.notifyProjectMembers(
            event.getProjectUuid(),
            "Nouvelle tâche créée : " + event.getTaskTitle(),
            event.getActorUuid()
        );

        // 2. Enregistrer dans l'audit log
        auditService.recordTaskCreation(event);
    }

    private void handleStatusChanged(TaskEvent event) {
        log.debug(
            "Traitement STATUS_CHANGED : {} -> {}",
            event.getPreviousStatus(), event.getNewStatus()
        );

        // Notifier l'assigné du changement
        if (event.getAssigneeUuid() != null) {
            notificationService.notifyUser(
                event.getAssigneeUuid(),
                String.format(
                    "Le statut de la tâche a changé : %s -> %s",
                    event.getPreviousStatus(), event.getNewStatus()
                )
            );
        }

        auditService.recordStatusChange(event);
    }

    private void handleTaskAssigned(TaskEvent event) {
        // Notifier la personne assignée
        if (event.getAssigneeUuid() != null) {
            notificationService.notifyUser(
                event.getAssigneeUuid(),
                "Une tâche vous a été assignée : " + event.getTaskTitle()
            );
        }
    }

    private void handleTaskDeleted(TaskEvent event) {
        auditService.recordTaskDeletion(event);
    }
}

────────────────────────────────────────────────────────────────────────────────
40.8 GESTION DES ERREURS KAFKA : DEAD LETTER TOPIC (DLT)
────────────────────────────────────────────────────────────────────────────────

// Si un message échoue après N retries, il va dans un Dead Letter Topic.
// Cela évite de bloquer le processing du reste des messages.

// src/main/java/com/taskflow/backend/config/KafkaErrorConfig.java

package com.taskflow.backend.config;

import com.taskflow.backend.event.TaskEvent;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.listener.CommonErrorHandler;
import org.springframework.kafka.listener.DeadLetterPublishingRecoverer;
import org.springframework.kafka.listener.DefaultErrorHandler;
import org.springframework.util.backoff.FixedBackOff;

/**
 * Configuration de la gestion d'erreurs Kafka avec Dead Letter Topic.
 *
 * Flow :
 *   Message -> Consumer -> ERREUR
 *   -> Retry 1 (après 2s)
 *   -> Retry 2 (après 2s)
 *   -> Retry 3 (après 2s)
 *   -> Dead Letter Topic (taskflow.task-events.DLT)
 */
@Slf4j
@Configuration
public class KafkaErrorConfig {

    @Bean
    public CommonErrorHandler kafkaErrorHandler(
        KafkaTemplate<String, Object> kafkaTemplate
    ) {
        // Récupérateur qui publie les messages en erreur dans le DLT
        DeadLetterPublishingRecoverer recoverer = new DeadLetterPublishingRecoverer(
            kafkaTemplate,
            (record, exception) -> {
                // Nom du DLT = nom du topic + ".DLT"
                log.error(
                    "Message envoyé au DLT après {} tentatives. Topic={}, Key={}: {}",
                    3, record.topic(), record.key(), exception.getMessage()
                );
                // Spring crée automatiquement le topic DLT
                return new org.apache.kafka.common.TopicPartition(
                    record.topic() + ".DLT", record.partition()
                );
            }
        );

        // 3 tentatives espacées de 2 secondes
        DefaultErrorHandler errorHandler = new DefaultErrorHandler(
            recoverer,
            new FixedBackOff(2000L, 3) // intervalle 2s, max 3 tentatives
        );

        // Ne pas re-tenter pour les erreurs de désérialisation (ça ne servira à rien)
        errorHandler.addNotRetryableExceptions(
            org.springframework.kafka.support.serializer.DeserializationException.class
        );

        return errorHandler;
    }
}

──────────────────────────────────────

// Consumer du Dead Letter Topic (pour monitoring/alertes) :

@KafkaListener(
    topics = KafkaConfig.TOPIC_TASK_EVENTS + ".DLT",
    groupId = "taskflow-dlt-handler"
)
public void consumeDeadLetter(ConsumerRecord<String, byte[]> record) {
    log.error(
        "Message en Dead Letter Topic : topic={}, partition={}, offset={}, key={}",
        record.topic(), record.partition(), record.offset(), record.key()
    );
    // Alerter l'équipe (Slack, email, PagerDuty...)
    // Sauvegarder pour réanalyse manuelle
    alertingService.sendDeadLetterAlert(record);
}

────────────────────────────────────────────────────────────────────────────────
40.9 TRANSACTIONAL OUTBOX PATTERN
────────────────────────────────────────────────────────────────────────────────

PROBLÈME : Que se passe-t-il si la tâche est sauvegardée en DB mais l'événement
Kafka échoue à cause d'une coupure réseau ?

  @Transactional
  public TaskResponse createTask(...) {
      Task saved = taskRepository.save(task);  // <- commit DB
      eventPublisher.publishTaskCreated(...);   // <- ECHEC KAFKA !
      // -> Tâche créée en DB mais événement perdu
  }

SOLUTION : Transactional Outbox Pattern

  1. Sauvegarder l'événement dans une table "outbox" dans la MÊME transaction DB
  2. Un processus séparé lit l'outbox et publie dans Kafka
  3. Si Kafka échoue, l'événement reste en outbox et sera re-tenté
  4. Garantie "at-least-once delivery"

// src/main/java/com/taskflow/backend/model/OutboxEvent.java

@Entity
@Table(name = "outbox_events")
@Getter
@Builder
public class OutboxEvent {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    @Column(name = "aggregate_type", nullable = false)
    private String aggregateType; // "Task", "User", etc.

    @Column(name = "aggregate_id", nullable = false)
    private String aggregateId;

    @Column(name = "event_type", nullable = false)
    private String eventType;

    @Column(name = "payload", nullable = false, columnDefinition = "text")
    private String payload; // JSON de l'événement

    @Column(name = "created_at", nullable = false)
    @CreationTimestamp
    private LocalDateTime createdAt;

    @Column(name = "sent_at")
    private LocalDateTime sentAt; // null = pas encore envoyé

    @Column(name = "failed")
    @Builder.Default
    private boolean failed = false;
}

──────────────────────────────────────

// Outbox relay (service qui publie vers Kafka) :

@Component
@RequiredArgsConstructor
@Slf4j
public class OutboxRelay {

    private final OutboxEventRepository outboxRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;
    private final ObjectMapper objectMapper;

    /**
     * Toutes les 5 secondes, lire et publier les événements non envoyés.
     */
    @Scheduled(fixedDelay = 5000)
    @Transactional
    public void processOutbox() {
        List<OutboxEvent> pending = outboxRepository
            .findBySentAtIsNullAndFailedFalse(Pageable.ofSize(100));

        for (OutboxEvent outboxEvent : pending) {
            try {
                kafkaTemplate.send(
                    "taskflow." + outboxEvent.getAggregateType().toLowerCase() + "-events",
                    outboxEvent.getAggregateId(),
                    outboxEvent.getPayload()
                ).get(); // Attendre la confirmation

                outboxEvent.setSentAt(LocalDateTime.now());
                outboxRepository.save(outboxEvent);

            } catch (Exception e) {
                log.error("Échec envoi outbox event {}: {}", outboxEvent.getId(), e.getMessage());
                if (outboxEvent.getRetryCount() >= 5) {
                    outboxEvent.setFailed(true);
                    outboxRepository.save(outboxEvent);
                }
            }
        }
    }
}


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 41 — RABBITMQ AVEC SPRING AMQP
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

41.1 CONCEPTS RABBITMQ
──────────────────────

RabbitMQ implémente le protocole AMQP (Advanced Message Queuing Protocol).

COMPOSANTS :

  EXCHANGE : Point d'entrée des messages — route vers les queues
    Types :
    - direct   : routing exact par routing key
    - topic    : routing par pattern (*.created, user.#)
    - fanout   : broadcast vers toutes les queues liées
    - headers  : routing par en-têtes du message

  QUEUE : File de messages attendant d'être consommés
    - Durable : survit aux redémarrages RabbitMQ
    - Exclusive : une seule connexion
    - Auto-delete : supprimée quand plus de consumers

  BINDING : Lien entre un exchange et une queue avec une routing key

SCHÉMA :

  Producer
      │
      [BLACK_DOWN-POINTING_TRIANGLE]
  Exchange (taskflow.events) ──── Binding (*.task.*) ──[BLACK_RIGHT-POINTING_TRIANGLE] Queue (task-events-queue)
                             ──── Binding (*.user.*) ──[BLACK_RIGHT-POINTING_TRIANGLE] Queue (user-events-queue)
                             ──── Binding (#)         ──[BLACK_RIGHT-POINTING_TRIANGLE] Queue (audit-queue)
      │
      [BLACK_DOWN-POINTING_TRIANGLE] (routing key: "com.taskflow.task.created")
  Queue (task-events-queue)
      │
      [BLACK_DOWN-POINTING_TRIANGLE]
  Consumer (Notification Service)

────────────────────────────────────────────────────────────────────────────────
41.2 DÉPENDANCES ET CONFIGURATION
────────────────────────────────────────────────────────────────────────────────

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

──────────────────────────────────────

# application.properties — RabbitMQ

spring.rabbitmq.host=localhost
spring.rabbitmq.port=5672
spring.rabbitmq.username=taskflow
spring.rabbitmq.password=taskflow_secret
spring.rabbitmq.virtual-host=/taskflow

# Activer les confirmations de publication
spring.rabbitmq.publisher-confirm-type=correlated
spring.rabbitmq.publisher-returns=true

# Connexion SSL (production)
# spring.rabbitmq.ssl.enabled=true
# spring.rabbitmq.ssl.key-store=classpath:keystore.p12

────────────────────────────────────────────────────────────────────────────────
41.3 CONFIGURATION DES EXCHANGES, QUEUES ET BINDINGS
────────────────────────────────────────────────────────────────────────────────

// src/main/java/com/taskflow/backend/config/RabbitMQConfig.java

package com.taskflow.backend.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * Configuration RabbitMQ pour TaskFlow.
 *
 * Architecture :
 *   Exchange : taskflow.events (topic exchange)
 *   Queues   :
 *     - taskflow.task-events     (routing: task.#)
 *     - taskflow.user-events     (routing: user.#)
 *     - taskflow.notification    (routing: #.notification)
 *     - taskflow.dead-letter     (Dead Letter Queue)
 */
@Configuration
public class RabbitMQConfig {

    // ─── Noms des exchanges ────────────────────────────────────────────────
    public static final String EXCHANGE_EVENTS     = "taskflow.events";
    public static final String EXCHANGE_DEAD_LETTER = "taskflow.dead-letter";

    // ─── Noms des queues ───────────────────────────────────────────────────
    public static final String QUEUE_TASK_EVENTS     = "taskflow.task-events";
    public static final String QUEUE_USER_EVENTS     = "taskflow.user-events";
    public static final String QUEUE_NOTIFICATIONS   = "taskflow.notifications";
    public static final String QUEUE_DEAD_LETTER     = "taskflow.dead-letter";

    // ─── Routing keys ──────────────────────────────────────────────────────
    public static final String ROUTING_TASK  = "task.#";
    public static final String ROUTING_USER  = "user.#";
    public static final String ROUTING_NOTIF = "#.notification";

    // ─── Exchanges ─────────────────────────────────────────────────────────

    @Bean
    public TopicExchange eventsExchange() {
        return ExchangeBuilder.topicExchange(EXCHANGE_EVENTS)
            .durable(true) // survit aux redémarrages RabbitMQ
            .build();
    }

    @Bean
    public FanoutExchange deadLetterExchange() {
        return ExchangeBuilder.fanoutExchange(EXCHANGE_DEAD_LETTER)
            .durable(true)
            .build();
    }

    // ─── Queues ────────────────────────────────────────────────────────────

    @Bean
    public Queue taskEventsQueue() {
        return QueueBuilder.durable(QUEUE_TASK_EVENTS)
            // Messages non traités -> Dead Letter Exchange
            .deadLetterExchange(EXCHANGE_DEAD_LETTER)
            .deadLetterRoutingKey(QUEUE_DEAD_LETTER)
            // TTL du message : 24h
            .ttl(86_400_000)
            .build();
    }

    @Bean
    public Queue userEventsQueue() {
        return QueueBuilder.durable(QUEUE_USER_EVENTS)
            .deadLetterExchange(EXCHANGE_DEAD_LETTER)
            .build();
    }

    @Bean
    public Queue notificationsQueue() {
        return QueueBuilder.durable(QUEUE_NOTIFICATIONS)
            .deadLetterExchange(EXCHANGE_DEAD_LETTER)
            // Max 1000 messages dans la queue
            .maxLength(1000L)
            .build();
    }

    @Bean
    public Queue deadLetterQueue() {
        return QueueBuilder.durable(QUEUE_DEAD_LETTER).build();
    }

    // ─── Bindings ──────────────────────────────────────────────────────────

    @Bean
    public Binding taskEventsBinding(Queue taskEventsQueue, TopicExchange eventsExchange) {
        return BindingBuilder
            .bind(taskEventsQueue)
            .to(eventsExchange)
            .with(ROUTING_TASK); // "task.#" = toutes les routing keys commençant par "task."
    }

    @Bean
    public Binding userEventsBinding(Queue userEventsQueue, TopicExchange eventsExchange) {
        return BindingBuilder
            .bind(userEventsQueue)
            .to(eventsExchange)
            .with(ROUTING_USER);
    }

    @Bean
    public Binding notificationsBinding(Queue notificationsQueue, TopicExchange eventsExchange) {
        return BindingBuilder
            .bind(notificationsQueue)
            .to(eventsExchange)
            .with(ROUTING_NOTIF);
    }

    @Bean
    public Binding deadLetterBinding(Queue deadLetterQueue, FanoutExchange deadLetterExchange) {
        return BindingBuilder.bind(deadLetterQueue).to(deadLetterExchange);
    }

    // ─── Convertisseur JSON ────────────────────────────────────────────────

    /**
     * Convertit automatiquement les objets Java en JSON dans les messages AMQP.
     * Sans ce bean, Spring enverrait des octets sérialisés Java (non portable).
     */
    @Bean
    public MessageConverter jsonMessageConverter(ObjectMapper objectMapper) {
        return new Jackson2JsonMessageConverter(objectMapper);
    }

    /**
     * Configure RabbitTemplate pour utiliser le convertisseur JSON.
     */
    @Bean
    public RabbitTemplate rabbitTemplate(
        ConnectionFactory connectionFactory,
        MessageConverter messageConverter
    ) {
        RabbitTemplate template = new RabbitTemplate(connectionFactory);
        template.setMessageConverter(messageConverter);
        template.setMandatory(true); // Erreur si le message n'est pas routé
        return template;
    }
}

────────────────────────────────────────────────────────────────────────────────
41.4 PUBLISHER RABBITMQ
────────────────────────────────────────────────────────────────────────────────

// src/main/java/com/taskflow/backend/messaging/TaskEventRabbitPublisher.java

package com.taskflow.backend.messaging;

import com.taskflow.backend.config.RabbitMQConfig;
import com.taskflow.backend.event.TaskEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;

/**
 * Publie des événements de tâches via RabbitMQ.
 *
 * Routing keys utilisées :
 *   "task.created"       -> queue task-events
 *   "task.status.changed" -> queue task-events
 *   "task.deleted"       -> queue task-events
 *   "task.created.notification" -> queue notifications (aussi)
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class TaskEventRabbitPublisher {

    private final RabbitTemplate rabbitTemplate;

    public void publishTaskCreated(TaskEvent event) {
        // Routing key "task.created" -> matchée par "task.#" ET "#.notification" si on ajoute ".notification"
        String routingKey = "task.created";

        log.debug("Publication événement {} avec routing key '{}'", event.getType(), routingKey);

        // convertAndSend = sérialise l'objet en JSON via Jackson2JsonMessageConverter
        rabbitTemplate.convertAndSend(
            RabbitMQConfig.EXCHANGE_EVENTS,
            routingKey,
            event
        );
    }

    public void publishStatusChanged(TaskEvent event) {
        rabbitTemplate.convertAndSend(
            RabbitMQConfig.EXCHANGE_EVENTS,
            "task.status.changed",
            event
        );
    }

    public void publishUserRegistered(com.taskflow.backend.event.UserEvent event) {
        // Routing key "user.registered" -> matchée par "user.#"
        rabbitTemplate.convertAndSend(
            RabbitMQConfig.EXCHANGE_EVENTS,
            "user.registered.notification", // matchée aussi par "#.notification"
            event
        );
    }
}

────────────────────────────────────────────────────────────────────────────────
41.5 CONSUMER RABBITMQ
────────────────────────────────────────────────────────────────────────────────

// src/main/java/com/taskflow/backend/messaging/TaskEventRabbitConsumer.java

package com.taskflow.backend.messaging;

import com.taskflow.backend.config.RabbitMQConfig;
import com.taskflow.backend.event.TaskEvent;
import com.taskflow.backend.event.UserEvent;
import com.taskflow.backend.service.NotificationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.support.AmqpHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Component;

import com.rabbitmq.client.Channel;
import java.io.IOException;

/**
 * Consumer RabbitMQ pour les événements de tâches et notifications.
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class TaskEventRabbitConsumer {

    private final NotificationService notificationService;

    /**
     * Écoute la queue task-events.
     *
     * @RabbitListener :
     *   queues      = nom de la queue
     *   ackMode     = MANUAL (commit après traitement réussi)
     *   concurrency = nombre de consumers (threads)
     */
    @RabbitListener(
        queues = RabbitMQConfig.QUEUE_TASK_EVENTS,
        ackMode = "MANUAL",
        concurrency = "2-5" // entre 2 et 5 threads selon la charge
    )
    public void handleTaskEvent(
        TaskEvent event,
        Channel channel,                            // canal AMQP pour l'ack
        @Header(AmqpHeaders.DELIVERY_TAG) long tag  // identifiant du message
    ) throws IOException {
        try {
            log.info("Traitement événement {} pour tâche {}", event.getType(), event.getTaskUuid());

            switch (event.getType()) {
                case TASK_CREATED -> handleCreated(event);
                case TASK_STATUS_CHANGED -> handleStatusChanged(event);
                case TASK_ASSIGNED -> handleAssigned(event);
                default -> log.debug("Événement {} non géré", event.getType());
            }

            // Acquittement positif : message supprimé de la queue
            channel.basicAck(tag, false);

        } catch (Exception e) {
            log.error("Erreur lors du traitement: {}", e.getMessage(), e);

            // Acquittement négatif :
            //   requeue = false -> le message va dans la Dead Letter Queue
            //   requeue = true  -> le message est re-placé en tête de queue (attention aux boucles !)
            channel.basicNack(tag, false, false);
        }
    }

    /**
     * Écoute la queue notifications.
     * Ici acknowledgement automatique (plus simple pour les notifications).
     */
    @RabbitListener(queues = RabbitMQConfig.QUEUE_NOTIFICATIONS)
    public void handleNotification(Object event) {
        // Le type exact dépend du message reçu
        if (event instanceof TaskEvent taskEvent) {
            sendTaskNotification(taskEvent);
        } else if (event instanceof UserEvent userEvent) {
            sendUserNotification(userEvent);
        }
    }

    private void handleCreated(TaskEvent event) {
        notificationService.notifyProjectMembers(
            event.getProjectUuid(),
            "Nouvelle tâche créée : " + event.getTaskTitle(),
            event.getActorUuid()
        );
    }

    private void handleStatusChanged(TaskEvent event) {
        // Notification de changement de statut
    }

    private void handleAssigned(TaskEvent event) {
        // Notification d'assignation
    }

    private void sendTaskNotification(TaskEvent event) {
        // Envoyer des push notifications, emails, etc.
    }

    private void sendUserNotification(UserEvent event) {
        // Email de bienvenue, confirmation, etc.
    }
}

────────────────────────────────────────────────────────────────────────────────
41.6 DOCKER COMPOSE : KAFKA + RABBITMQ
────────────────────────────────────────────────────────────────────────────────

# docker-compose.yml — Services de messaging

services:
  # ─── Kafka (mode KRaft, sans Zookeeper) ───────────────────────────────────
  kafka:
    image: confluentinc/cp-kafka:7.6.0
    container_name: taskflow-kafka
    ports:
      - "9092:9092"
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
    volumes:
      - kafka_data:/var/lib/kafka/data
    healthcheck:
      test: kafka-topics --bootstrap-server localhost:9092 --list
      interval: 30s
      timeout: 10s
      retries: 3

  # ─── Kafka UI (interface web de monitoring) ──────────────────────────────
  kafka-ui:
    image: provectuslabs/kafka-ui:latest
    container_name: taskflow-kafka-ui
    ports:
      - "8090:8080"
    environment:
      KAFKA_CLUSTERS_0_NAME: taskflow
      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:9092
    depends_on:
      - kafka

  # ─── RabbitMQ ─────────────────────────────────────────────────────────────
  rabbitmq:
    image: rabbitmq:3.13-management-alpine
    container_name: taskflow-rabbitmq
    ports:
      - "5672:5672"   # port AMQP
      - "15672:15672" # interface web de management
    environment:
      RABBITMQ_DEFAULT_USER: taskflow
      RABBITMQ_DEFAULT_PASS: taskflow_secret
      RABBITMQ_DEFAULT_VHOST: /taskflow
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq
    healthcheck:
      test: rabbitmq-diagnostics -q ping
      interval: 30s
      timeout: 10s
      retries: 3

volumes:
  kafka_data:
  rabbitmq_data:

# Interfaces de management :
#   Kafka UI  : http://localhost:8090
#   RabbitMQ  : http://localhost:15672 (taskflow / taskflow_secret)

────────────────────────────────────────────────────────────────────────────────
41.7 TESTS DES CONSUMERS KAFKA ET RABBITMQ
────────────────────────────────────────────────────────────────────────────────

// Test d'intégration du consumer Kafka avec Testcontainers + EmbeddedKafka

// src/test/java/com/taskflow/backend/messaging/TaskEventConsumerTest.java

package com.taskflow.backend.messaging;

import com.taskflow.backend.event.TaskEvent;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.test.context.EmbeddedKafka;
import org.springframework.test.annotation.DirtiesContext;

import java.util.UUID;
import java.util.concurrent.TimeUnit;

import static org.assertj.core.api.Assertions.*;

/**
 * Test d'intégration du consumer Kafka avec un Kafka embarqué.
 *
 * @EmbeddedKafka : démarre un Kafka in-memory pour les tests
 *   (pas besoin de Docker)
 */
@SpringBootTest
@DirtiesContext // recrée le contexte Spring après chaque classe de test
@EmbeddedKafka(
    partitions = 1,
    brokerProperties = {"listeners=PLAINTEXT://localhost:9092"},
    topics = {"taskflow.task-events"}
)
class TaskEventConsumerTest {

    @Autowired
    private KafkaTemplate<String, TaskEvent> kafkaTemplate;

    @Autowired
    private TaskEventConsumer consumer;

    @Test
    @DisplayName("[OK] Consumer traite correctement l'événement TASK_CREATED")
    void shouldProcessTaskCreatedEvent() throws Exception {
        // Arrange
        TaskEvent event = TaskEvent.builder()
            .type(TaskEvent.EventType.TASK_CREATED)
            .taskUuid(UUID.randomUUID())
            .projectUuid(UUID.randomUUID())
            .taskTitle("Test Task")
            .actorUuid(UUID.randomUUID())
            .build();

        // Act
        kafkaTemplate.send("taskflow.task-events", event.getProjectUuid().toString(), event);

        // Assert : attendre que le consumer traite le message (max 5 secondes)
        boolean processed = consumer.getLatch().await(5, TimeUnit.SECONDS);
        assertThat(processed)
            .as("L'événement devrait être traité en moins de 5 secondes")
            .isTrue();
    }
}

────────────────────────────────────────────────────────────────────────────────
41.8 BONNES PRATIQUES MESSAGING
────────────────────────────────────────────────────────────────────────────────

BONNES PRATIQUES :

  1. IDEMPOTENCE DES CONSUMERS
     -> Un même message peut être livré plusieurs fois (at-least-once)
     -> Le consumer doit produire le même résultat si traité plusieurs fois
     -> Solution : table "processed_events" avec l'eventId comme clé unique

  2. ORDRE DES MESSAGES
     -> KAFKA : garantie d'ordre par partition uniquement
       -> Utiliser le même projectUuid comme clé = même partition
     -> RABBITMQ : pas de garantie d'ordre native
       -> Implémenter un numéro de séquence si l'ordre est critique

  3. SCHEMA REGISTRY (CONFLUENT)
     -> En production, utiliser Avro + Schema Registry
     -> Évite les erreurs de désérialisation lors des changements de schéma
     -> Compatibilité backward/forward entre versions

  4. MONITORING
     -> Consumer lag (retard du consumer par rapport au producer)
     -> Dead Letter Queue size (indicateur de problèmes)
     -> Alertes si DLQ non vide

  5. NE PAS BLOQUER LE THREAD CONSUMER
     -> Opérations longues dans un CompletableFuture.supplyAsync()
     -> Sinon le consumer ne peut pas traiter d'autres messages

────────────────────────────────────────────────────────────────────────────────
41.9 EXERCICES
────────────────────────────────────────────────────────────────────────────────

NIVEAU FACILE :

  Ex.1 — Créez un UserEventPublisher qui publie un événement USER_REGISTERED
         sur Kafka après l'inscription d'un utilisateur.
         Testez avec @EmbeddedKafka.

  Ex.2 — Dans le consumer TaskEventConsumer, ajoutez un mécanisme d'idempotence :
         sauvegardez l'eventId dans une table processed_events et ignorez
         les événements déjà traités.

  Ex.3 — Configurez le docker-compose pour lancer Kafka et vérifiez que
         l'application démarre correctement avec "docker-compose up".

NIVEAU INTERMÉDIAIRE :

  Ex.4 — Implémentez le Transactional Outbox Pattern complet :
         a) Table outbox_events en DB
         b) Service qui sauvegarde dans l'outbox dans la même transaction
         c) @Scheduled relay qui lit l'outbox et publie dans Kafka

  Ex.5 — Créez un consumer RabbitMQ pour envoyer des emails (mock).
         Routing key "user.registered.notification" -> envoyer un email de bienvenue.
         Testez avec MockitoAnnotations (sans vrai serveur email).

  Ex.6 — Ajoutez des métriques Micrometer pour :
         - Nombre d'événements publiés par type
         - Latence de traitement des événements
         - Taille de la Dead Letter Queue

NIVEAU AVANCÉ :

  Ex.7 — Implémentez le pattern Saga Chorégraphié pour la suppression de compte :
         1. UserService publie USER_DELETION_REQUESTED
         2. ProjectService consomme et supprime les projets (publie PROJECTS_DELETED)
         3. TaskService consomme et supprime les tâches (publie TASKS_DELETED)
         4. UserService consomme TASKS_DELETED et supprime l'utilisateur
         Gérez les compensations en cas d'erreur (SAGA_COMPENSATION).

  Ex.8 — Créez un système de notifications en temps réel :
         Kafka -> Consumer -> WebSocket (Spring WebFlux) -> Browser
         Les clients front-end reçoivent les notifications instantanément.

  Ex.9 — Benchmark Kafka : créez un producteur qui envoie 100 000 messages
         et mesurez le débit (messages/seconde) avec différentes configurations
         (batch-size, linger-ms, compression-type). Analysez les résultats.

================================================================================
FIN DE LA PARTIE 13
================================================================================

RÉSUMÉ DES ACQUIS :

  Chapitre 40 — Apache Kafka :
    [OK] Concepts : topics, partitions, offsets, consumer groups
    [OK] Configuration Spring Kafka (producer + consumer + error handling)
    [OK] Modèles d'événements (TaskEvent, UserEvent)
    [OK] Producer avec callback asynchrone
    [OK] Consumer avec commit manuel et gestion d'erreurs
    [OK] Dead Letter Topic (DLT)
    [OK] Transactional Outbox Pattern

  Chapitre 41 — RabbitMQ :
    [OK] Concepts : exchanges, queues, bindings, routing keys
    [OK] Configuration Spring AMQP (exchanges, queues, bindings)
    [OK] Publisher et Consumer avec ack manuel
    [OK] Dead Letter Queue
    [OK] Docker Compose pour Kafka + RabbitMQ

  PROCHAINE PARTIE :
    Partie 14 — Docker, CI/CD et GitHub Actions

================================================================================



================================================================================
   SPRING BOOT MASTER GUIDE — NIVEAU ENTREPRISE
   PARTIE 14 : DOCKER ET CI/CD — PIPELINES GITHUB ACTIONS
   Chapitres 42–43
================================================================================

TABLE DES MATIÈRES — PARTIE 14
──────────────────────────────
  Chapitre 42 : Docker avancé — multi-stage, optimisation, compose production
  Chapitre 43 : CI/CD avec GitHub Actions — pipelines complets

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 42 — DOCKER AVANCÉ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

42.1 DOCKERFILE MULTI-STAGE OPTIMISÉ
──────────────────────────────────────

Un Dockerfile multi-stage sépare la compilation de l'exécution.
Résultat : image finale légère (~200MB vs 800MB avec JDK complet).

# ─────────────────────────────────────────────────────────────────────────────
# Dockerfile — TaskFlow Backend (production-ready)
# ─────────────────────────────────────────────────────────────────────────────

# ════════════════════════════════════════════════════════
# STAGE 1 : CACHE DES DÉPENDANCES
# But : télécharger les dépendances Maven séparément
# Résultat : couche Docker cachée tant que pom.xml ne change pas
# ════════════════════════════════════════════════════════
FROM eclipse-temurin:21-jdk-alpine AS deps

WORKDIR /workspace

# Copier UNIQUEMENT les fichiers Maven (pas le code source)
# Cette couche est mise en cache tant que pom.xml ne change pas
COPY pom.xml .
COPY .mvn .mvn
COPY mvnw .

# Télécharger les dépendances (sans compiler)
RUN ./mvnw dependency:go-offline -q

# ════════════════════════════════════════════════════════
# STAGE 2 : BUILD
# But : compiler l'application et créer le JAR
# ════════════════════════════════════════════════════════
FROM deps AS build

# Copier le code source APRÈS les dépendances
# (modification du code ne recompile que ce stage)
COPY src src

# Compiler et packager (skip tests — testés séparément en CI)
RUN ./mvnw package -DskipTests -q

# Extraire les layers du JAR Spring Boot pour optimisation Docker
# Spring Boot 3.x supporte les "layered JARs"
RUN java -Djarmode=layertools -jar target/*.jar extract

# ════════════════════════════════════════════════════════
# STAGE 3 : RUNTIME
# But : image minimale pour l'exécution
# Base : JRE (pas JDK) -> image plus légère
# ════════════════════════════════════════════════════════
FROM eclipse-temurin:21-jre-alpine AS runtime

# Métadonnées OCI (bonnes pratiques)
LABEL org.opencontainers.image.title="TaskFlow Backend"
LABEL org.opencontainers.image.description="API REST Spring Boot pour TaskFlow"
LABEL org.opencontainers.image.vendor="TaskFlow Inc."
LABEL org.opencontainers.image.version="1.0.0"

# Créer un utilisateur non-root (sécurité)
RUN addgroup -S taskflow && adduser -S taskflow -G taskflow

WORKDIR /app

# Copier les layers dans l'ordre optimal (du plus stable au plus changeant)
# -> Docker recrée uniquement les couches modifiées
COPY --from=build --chown=taskflow:taskflow /workspace/dependencies/ ./
COPY --from=build --chown=taskflow:taskflow /workspace/spring-boot-loader/ ./
COPY --from=build --chown=taskflow:taskflow /workspace/snapshot-dependencies/ ./
COPY --from=build --chown=taskflow:taskflow /workspace/application/ ./

# Basculer vers l'utilisateur non-root
USER taskflow

# Port exposé
EXPOSE 8080

# Health check Docker natif
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
    CMD wget -qO- http://localhost:8080/actuator/health || exit 1

# Variables d'environnement par défaut (peuvent être surchargées)
ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
ENV SPRING_PROFILES_ACTIVE="prod"

# Point d'entrée optimisé pour les containers
# JarLauncher utilise les layers extraites
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher"]

────────────────────────────────────────────────────────────────
42.2 DOCKER COMPOSE — ENVIRONNEMENTS MULTIPLES
────────────────────────────────────────────────────────────────

# ─────────────────────────────────────────────────────────────────────────────
# docker-compose.yml — Base commune (services infrastructure)
# ─────────────────────────────────────────────────────────────────────────────
version: '3.8'

services:

  # ─── PostgreSQL ────────────────────────────────────────────────────────────
  postgres:
    image: postgres:16-alpine
    container_name: taskflow-postgres
    environment:
      POSTGRES_DB: ${POSTGRES_DB:-taskflow}
      POSTGRES_USER: ${POSTGRES_USER:-taskflow}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-secret}
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    ports:
      - "${POSTGRES_PORT:-5432}:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-taskflow}"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  # ─── Redis (Cache + Sessions) ──────────────────────────────────────────────
  redis:
    image: redis:7-alpine
    container_name: taskflow-redis
    command: redis-server --requirepass ${REDIS_PASSWORD:-secret} --maxmemory 256mb
    ports:
      - "${REDIS_PORT:-6379}:6379"
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  # ─── Kafka ─────────────────────────────────────────────────────────────────
  kafka:
    image: confluentinc/cp-kafka:7.5.0
    container_name: taskflow-kafka
    depends_on:
      zookeeper:
        condition: service_healthy
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
      KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false"
    ports:
      - "9092:9092"
    healthcheck:
      test: ["CMD", "kafka-topics", "--bootstrap-server", "localhost:9092", "--list"]
      interval: 30s
      timeout: 10s
      retries: 5
    restart: unless-stopped

  zookeeper:
    image: confluentinc/cp-zookeeper:7.5.0
    container_name: taskflow-zookeeper
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181
    healthcheck:
      test: ["CMD", "nc", "-z", "localhost", "2181"]
      interval: 10s
      retries: 5

  # ─── RabbitMQ ──────────────────────────────────────────────────────────────
  rabbitmq:
    image: rabbitmq:3.13-management-alpine
    container_name: taskflow-rabbitmq
    environment:
      RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-taskflow}
      RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-secret}
      RABBITMQ_DEFAULT_VHOST: taskflow
    ports:
      - "5672:5672"
      - "15672:15672"  # Management UI
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "ping"]
      interval: 30s
      timeout: 10s
      retries: 5
    restart: unless-stopped

volumes:
  postgres_data:
  redis_data:
  rabbitmq_data:

# ─────────────────────────────────────────────────────────────────────────────
# docker-compose.dev.yml — Override pour développement
# Usage : docker compose -f docker-compose.yml -f docker-compose.dev.yml up
# ─────────────────────────────────────────────────────────────────────────────
services:

  # ─── Application en mode développement ────────────────────────────────────
  app:
    build:
      context: .
      target: build  # S'arrêter au stage build (pas runtime)
    volumes:
      # Hot reload avec Spring DevTools
      - ./src:/workspace/src:delegated
      - ~/.m2:/root/.m2:cached  # Cache Maven local
    environment:
      SPRING_PROFILES_ACTIVE: dev
      SPRING_DEVTOOLS_RESTART_ENABLED: "true"
    ports:
      - "8080:8080"
      - "5005:5005"  # Port debug JDWP
    command: >
      ./mvnw spring-boot:run
      -Dspring-boot.run.jvmArguments="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
    depends_on:
      postgres: { condition: service_healthy }
      redis: { condition: service_healthy }
      kafka: { condition: service_healthy }
      rabbitmq: { condition: service_healthy }

  # ─── Outils de développement ───────────────────────────────────────────────
  adminer:
    image: adminer:4
    ports:
      - "8091:8080"  # Interface web pour PostgreSQL

  kafka-ui:
    image: provectuslabs/kafka-ui:latest
    ports:
      - "8092:8080"
    environment:
      KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092

# ─────────────────────────────────────────────────────────────────────────────
# docker-compose.prod.yml — Override pour production
# ─────────────────────────────────────────────────────────────────────────────
services:

  app:
    image: ghcr.io/taskflow/backend:${IMAGE_TAG:-latest}
    environment:
      SPRING_PROFILES_ACTIVE: prod
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
      SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
      SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
      SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:29092
      SPRING_RABBITMQ_HOST: rabbitmq
      JWT_SECRET: ${JWT_SECRET}
    deploy:
      replicas: 2
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3
      resources:
        limits:
          cpus: '1.0'
          memory: 512M
        reservations:
          cpus: '0.5'
          memory: 256M
    ports:
      - "8080:8080"
    depends_on:
      postgres: { condition: service_healthy }
      redis: { condition: service_healthy }

  # ─── Nginx Reverse Proxy ───────────────────────────────────────────────────
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./docker/nginx/ssl:/etc/nginx/ssl:ro
    depends_on:
      - app

────────────────────────────────────────────────────────────────
42.3 CONFIGURATION NGINX
────────────────────────────────────────────────────────────────

# ─────────────────────────────────────────────────────────────────────────────
# docker/nginx/nginx.conf
# ─────────────────────────────────────────────────────────────────────────────
worker_processes auto;

events {
    worker_connections 1024;
}

http {
    # ─── Security headers ─────────────────────────────────────────────────
    add_header X-Frame-Options DENY;
    add_header X-Content-Type-Options nosniff;
    add_header X-XSS-Protection "1; mode=block";
    add_header Referrer-Policy "strict-origin-when-cross-origin";
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;

    # ─── Rate limiting ─────────────────────────────────────────────────────
    limit_req_zone $binary_remote_addr zone=api:10m rate=100r/m;
    limit_req_zone $binary_remote_addr zone=auth:10m rate=10r/m;

    # ─── Upstream (load balancing entre instances) ─────────────────────────
    upstream taskflow_backend {
        least_conn;
        server app:8080;
        # En production avec plusieurs replicas :
        # server app_1:8080;
        # server app_2:8080;
        keepalive 32;
    }

    # ─── HTTP -> HTTPS redirect ────────────────────────────────────────────
    server {
        listen 80;
        server_name api.taskflow.com;
        return 301 https://$host$request_uri;
    }

    # ─── HTTPS ────────────────────────────────────────────────────────────
    server {
        listen 443 ssl http2;
        server_name api.taskflow.com;

        ssl_certificate /etc/nginx/ssl/cert.pem;
        ssl_certificate_key /etc/nginx/ssl/key.pem;
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers HIGH:!aNULL:!MD5;

        # ─── API routes ────────────────────────────────────────────────────
        location /api/ {
            limit_req zone=api burst=20 nodelay;

            proxy_pass http://taskflow_backend;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            # Timeouts
            proxy_connect_timeout 5s;
            proxy_read_timeout 60s;

            # Compression
            gzip on;
            gzip_types application/json;
        }

        # ─── Auth routes — rate limité plus sévèrement ────────────────────
        location /api/v1/auth/ {
            limit_req zone=auth burst=5 nodelay;
            proxy_pass http://taskflow_backend;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }

        # ─── Actuator — accessible uniquement en interne ─────────────────
        location /actuator/ {
            allow 10.0.0.0/8;   # Réseau interne
            deny all;
            proxy_pass http://taskflow_backend;
        }
    }
}

────────────────────────────────────────────────────────────────
42.4 VARIABLES D'ENVIRONNEMENT — .env
────────────────────────────────────────────────────────────────

# ─────────────────────────────────────────────────────────────────────────────
# .env.example — Copier en .env et remplir les valeurs
# NE JAMAIS committer .env dans Git !
# ─────────────────────────────────────────────────────────────────────────────

# ─── PostgreSQL ───────────────────────────────────────────────────────────────
POSTGRES_DB=taskflow
POSTGRES_USER=taskflow
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD

# ─── Redis ────────────────────────────────────────────────────────────────────
REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD

# ─── RabbitMQ ─────────────────────────────────────────────────────────────────
RABBITMQ_USER=taskflow
RABBITMQ_PASSWORD=CHANGE_ME_RABBITMQ_PASSWORD

# ─── JWT ──────────────────────────────────────────────────────────────────────
# Générer avec : openssl rand -base64 64
JWT_SECRET=CHANGE_ME_256_BIT_SECRET_KEY_AT_MINIMUM

# ─── Application ──────────────────────────────────────────────────────────────
IMAGE_TAG=1.0.0


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CHAPITRE 43 — CI/CD AVEC GITHUB ACTIONS
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

43.1 INTRODUCTION — CI/CD
───────────────────────────

CI/CD (Continuous Integration / Continuous Delivery) automatise :
  • CI : build, tests, analyse de code à chaque push
  • CD : déploiement automatique si les tests passent

Pipeline TaskFlow complet :
  Push -> Lint + Compile -> Tests unitaires -> Tests intégration ->
  Build Docker -> Push Registry -> Deploy Staging -> Tests E2E -> Deploy Prod

SCHÉMA PIPELINE
────────────────

  [Developer] --push--> [GitHub]
                             │
                    [GitHub Actions Trigger]
                             │
              ┌──────────────┼──────────────┐
              [BLACK_DOWN-POINTING_TRIANGLE]              [BLACK_DOWN-POINTING_TRIANGLE]              [BLACK_DOWN-POINTING_TRIANGLE]
          [CI Job]       [Security]    [Quality]
          (build +        (trivy,        (SonarQube
           tests)         Snyk)          coverage)
              │
              [BLACK_DOWN-POINTING_TRIANGLE] (si main branch)
          [Build & Push Docker Image]
              │
              [BLACK_DOWN-POINTING_TRIANGLE]
          [Deploy Staging]
              │
              [BLACK_DOWN-POINTING_TRIANGLE] (tests E2E OK)
          [Deploy Production]
              │
              [BLACK_DOWN-POINTING_TRIANGLE]
          [Notify Slack]

────────────────────────────────────────────────────────────────
43.2 WORKFLOW CI — BUILD ET TESTS
────────────────────────────────────────────────────────────────

# ─────────────────────────────────────────────────────────────────────────────
# .github/workflows/ci.yml
# Déclenché sur tout push et pull request vers main/develop
# ─────────────────────────────────────────────────────────────────────────────
name: CI — Build & Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

# Annuler les runs précédents si un nouveau push arrive sur la même branche
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:

  # ═══════════════════════════════════════════════════════════════════════
  # JOB 1 : Validation et compilation
  # ═══════════════════════════════════════════════════════════════════════
  validate:
    name: Validate & Compile
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Java 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'maven'  # Cache ~/.m2 entre les runs

      - name: Validate Maven project
        run: ./mvnw validate

      - name: Compile (without tests)
        run: ./mvnw compile -q

  # ═══════════════════════════════════════════════════════════════════════
  # JOB 2 : Tests unitaires
  # ═══════════════════════════════════════════════════════════════════════
  unit-tests:
    name: Unit Tests
    runs-on: ubuntu-latest
    needs: validate

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'maven'

      - name: Run unit tests
        run: ./mvnw test -Dtest="**/*Test" -DfailIfNoTests=false

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()  # Toujours uploader, même si les tests échouent
        with:
          name: unit-test-results
          path: target/surefire-reports/

      - name: Publish test results
        uses: dorny/test-reporter@v1
        if: always()
        with:
          name: Unit Tests
          path: target/surefire-reports/*.xml
          reporter: java-junit

  # ═══════════════════════════════════════════════════════════════════════
  # JOB 3 : Tests d'intégration avec Testcontainers
  # ═══════════════════════════════════════════════════════════════════════
  integration-tests:
    name: Integration Tests
    runs-on: ubuntu-latest
    needs: validate

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'maven'

      # Testcontainers nécessite Docker — disponible sur ubuntu-latest
      - name: Run integration tests
        run: ./mvnw verify -Dtest="**/*IT" -DfailIfNoTests=false
        env:
          # Testcontainers utilise le Docker de l'environnement CI
          TESTCONTAINERS_RYUK_DISABLED: "true"  # Désactiver Ryuk en CI
          DOCKER_HOST: unix:///var/run/docker.sock

      - name: Upload integration test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: integration-test-results
          path: target/failsafe-reports/

  # ═══════════════════════════════════════════════════════════════════════
  # JOB 4 : Couverture de code avec JaCoCo
  # ═══════════════════════════════════════════════════════════════════════
  code-coverage:
    name: Code Coverage
    runs-on: ubuntu-latest
    needs: [unit-tests, integration-tests]

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'maven'

      - name: Run tests with coverage
        run: ./mvnw verify jacoco:report

      - name: Check coverage threshold (min 80%)
        run: |
          COVERAGE=$(grep -o 'Total[^%]*%' target/site/jacoco/index.html | tail -1 | grep -o '[0-9]*%' | tr -d '%')
          echo "Code coverage: ${COVERAGE}%"
          if [ "${COVERAGE}" -lt 80 ]; then
            echo "[X] Coverage ${COVERAGE}% is below minimum threshold of 80%"
            exit 1
          fi
          echo "[OK] Coverage ${COVERAGE}% meets the threshold"

      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}
          files: target/site/jacoco/jacoco.xml
          fail_ci_if_error: false

      - name: Post coverage comment on PR
        uses: madrapps/jacoco-report@v1.6.1
        if: github.event_name == 'pull_request'
        with:
          paths: target/site/jacoco/jacoco.xml
          token: ${{ secrets.GITHUB_TOKEN }}
          min-coverage-overall: 80
          min-coverage-changed-files: 70

  # ═══════════════════════════════════════════════════════════════════════
  # JOB 5 : Analyse de sécurité
  # ═══════════════════════════════════════════════════════════════════════
  security-scan:
    name: Security Scan
    runs-on: ubuntu-latest
    needs: validate

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'maven'

      # ─── OWASP Dependency Check ─────────────────────────────────────────
      - name: OWASP Dependency Check
        run: |
          ./mvnw org.owasp:dependency-check-maven:check \
            -DfailBuildOnCVSS=9 \
            -DsuppressionFile=.owasp-suppressions.xml

      - name: Upload OWASP report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: owasp-report
          path: target/dependency-check-report.html

      # ─── Scan des secrets (Trufflehog) ───────────────────────────────────
      - name: Scan for secrets
        uses: trufflesecurity/trufflehog@main
        with:
          path: ./
          base: ${{ github.event.repository.default_branch }}
          head: HEAD
          extra_args: --debug --only-verified

────────────────────────────────────────────────────────────────
43.3 WORKFLOW CD — BUILD ET DÉPLOIEMENT
────────────────────────────────────────────────────────────────

# ─────────────────────────────────────────────────────────────────────────────
# .github/workflows/cd.yml
# Déclenché uniquement sur push vers main (après merge d'une PR)
# ─────────────────────────────────────────────────────────────────────────────
name: CD — Deploy

on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'

# Permissions nécessaires pour écrire dans le registry GitHub
permissions:
  contents: read
  packages: write
  id-token: write  # Pour OIDC auth (AWS, GCP)

jobs:

  # ═══════════════════════════════════════════════════════════════════════
  # JOB 1 : Build et push de l'image Docker
  # ═══════════════════════════════════════════════════════════════════════
  build-push:
    name: Build & Push Docker Image
    runs-on: ubuntu-latest

    outputs:
      image-tag: ${{ steps.meta.outputs.tags }}
      image-digest: ${{ steps.build.outputs.digest }}

    steps:
      - uses: actions/checkout@v4

      # ─── Docker Buildx (build multi-platform) ────────────────────────────
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      # ─── Login GitHub Container Registry ─────────────────────────────────
      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # ─── Générer les tags et labels ───────────────────────────────────────
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ghcr.io/${{ github.repository }}
          tags: |
            # Tag avec SHA du commit : sha-abc1234
            type=sha,prefix=sha-,format=short
            # Tag avec version sémantique si tag Git : v1.2.3 -> 1.2.3
            type=semver,pattern={{version}}
            # Tag latest sur main
            type=raw,value=latest,enable={{is_default_branch}}
          labels: |
            org.opencontainers.image.title=TaskFlow Backend
            org.opencontainers.image.revision=${{ github.sha }}

      # ─── Build et push ────────────────────────────────────────────────────
      - name: Build and push Docker image
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          # Cache depuis le registry (pas de recompilation si rien n'a changé)
          cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
          cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
          # Build multi-platform (AMD64 + ARM64)
          platforms: linux/amd64,linux/arm64

      # ─── Scan de sécurité de l'image (Trivy) ──────────────────────────────
      - name: Scan Docker image with Trivy
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ghcr.io/${{ github.repository }}:latest
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'
          exit-code: '0'  # Ne pas faire échouer le pipeline sur les vulnérabilités (alerter seulement)

      - name: Upload Trivy scan results
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: 'trivy-results.sarif'

  # ═══════════════════════════════════════════════════════════════════════
  # JOB 2 : Déploiement en Staging
  # ═══════════════════════════════════════════════════════════════════════
  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    needs: build-push
    environment:
      name: staging
      url: https://api-staging.taskflow.com

    steps:
      - uses: actions/checkout@v4

      # ─── Déploiement via SSH ───────────────────────────────────────────────
      - name: Deploy to staging server
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.STAGING_HOST }}
          username: ${{ secrets.STAGING_USER }}
          key: ${{ secrets.STAGING_SSH_KEY }}
          script: |
            # Mettre à jour l'image
            docker pull ghcr.io/${{ github.repository }}:latest

            # Redémarrer avec la nouvelle image (zero-downtime avec 2 replicas)
            cd /opt/taskflow
            export IMAGE_TAG=latest
            docker compose -f docker-compose.yml -f docker-compose.prod.yml \
              up -d --no-deps --scale app=2 app

            # Attendre que l'application soit healthy
            sleep 30
            docker compose ps app

            echo "[OK] Staging deployment complete"

      # ─── Tests de fumée post-déploiement ─────────────────────────────────
      - name: Smoke tests on staging
        run: |
          BASE_URL="https://api-staging.taskflow.com"

          # Vérifier que le health endpoint répond
          HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/actuator/health")
          if [ "$HTTP_STATUS" != "200" ]; then
            echo "[X] Health check failed: HTTP $HTTP_STATUS"
            exit 1
          fi

          # Vérifier que l'API répond
          HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/api/v1/auth/health")
          if [ "$HTTP_STATUS" != "200" ]; then
            echo "[X] API health check failed: HTTP $HTTP_STATUS"
            exit 1
          fi

          echo "[OK] Smoke tests passed"

  # ═══════════════════════════════════════════════════════════════════════
  # JOB 3 : Déploiement en Production (avec approbation manuelle)
  # ═══════════════════════════════════════════════════════════════════════
  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: deploy-staging
    environment:
      name: production
      url: https://api.taskflow.com
    # [ATTENTION] Configurer dans GitHub Settings > Environments > production
    # -> Required reviewers : 1 senior developer doit approuver

    steps:
      - uses: actions/checkout@v4

      - name: Deploy to production
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            cd /opt/taskflow
            export IMAGE_TAG=${{ needs.build-push.outputs.image-tag }}

            # Blue-Green deployment
            # Instance "blue" actuellement active
            echo "Starting green deployment..."

            # Démarrer la nouvelle version (green)
            docker compose -f docker-compose.yml -f docker-compose.prod.yml \
              up -d --no-deps app

            # Health check sur la nouvelle instance
            sleep 60
            HEALTH=$(curl -s http://localhost:8080/actuator/health | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])")

            if [ "$HEALTH" != "UP" ]; then
              echo "[X] New deployment unhealthy, rolling back..."
              docker compose -f docker-compose.yml -f docker-compose.prod.yml \
                rollback app
              exit 1
            fi

            echo "[OK] Production deployment complete"

      # ─── Créer un tag Git pour la release ─────────────────────────────────
      - name: Create release tag
        run: |
          git config user.name github-actions
          git config user.email github-actions@github.com
          git tag "deploy-$(date +%Y%m%d-%H%M%S)"
          git push origin --tags

  # ═══════════════════════════════════════════════════════════════════════
  # JOB 4 : Notification Slack
  # ═══════════════════════════════════════════════════════════════════════
  notify:
    name: Notify Slack
    runs-on: ubuntu-latest
    needs: [deploy-production]
    if: always()  # Toujours notifier (succès ou échec)

    steps:
      - name: Notify Slack on success
        if: needs.deploy-production.result == 'success'
        uses: slackapi/slack-github-action@v1.26.0
        with:
          payload: |
            {
              "text": "[OK] *TaskFlow Backend* deployed to production",
              "attachments": [{
                "color": "good",
                "fields": [
                  {"title": "Branch", "value": "${{ github.ref_name }}", "short": true},
                  {"title": "Commit", "value": "${{ github.sha }}", "short": true},
                  {"title": "Author", "value": "${{ github.actor }}", "short": true},
                  {"title": "URL", "value": "https://api.taskflow.com", "short": true}
                ]
              }]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

      - name: Notify Slack on failure
        if: failure()
        uses: slackapi/slack-github-action@v1.26.0
        with:
          payload: |
            {
              "text": "[X] *TaskFlow Backend* deployment FAILED",
              "attachments": [{
                "color": "danger",
                "fields": [
                  {"title": "Branch", "value": "${{ github.ref_name }}", "short": true},
                  {"title": "Run", "value": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", "short": false}
                ]
              }]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

────────────────────────────────────────────────────────────────
43.4 WORKFLOW PR — VÉRIFICATIONS AUTOMATIQUES
────────────────────────────────────────────────────────────────

# ─────────────────────────────────────────────────────────────────────────────
# .github/workflows/pr-checks.yml
# ─────────────────────────────────────────────────────────────────────────────
name: PR Checks

on:
  pull_request:
    types: [opened, synchronize, reopened]

jobs:

  # ─── Vérification du titre de la PR (Conventional Commits) ───────────────
  check-pr-title:
    name: Check PR Title
    runs-on: ubuntu-latest
    steps:
      - uses: amannn/action-semantic-pull-request@v5
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          types: |
            feat
            fix
            docs
            style
            refactor
            perf
            test
            build
            ci
            chore
          requireScope: false

  # ─── Checkstyle + SpotBugs ────────────────────────────────────────────────
  code-quality:
    name: Code Quality
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: 'maven'

      - name: Run Checkstyle
        run: ./mvnw checkstyle:check

      - name: Run SpotBugs
        run: ./mvnw spotbugs:check

      - name: Run PMD
        run: ./mvnw pmd:check

────────────────────────────────────────────────────────────────
43.5 CONFIGURATION MAVEN POUR CI/CD
────────────────────────────────────────────────────────────────

<!-- ────────────────────────────────────────────────────────────────────── -->
<!-- pom.xml — Configuration plugins CI/CD                                 -->
<!-- ────────────────────────────────────────────────────────────────────── -->
<build>
    <plugins>

        <!-- ─── Spring Boot Maven Plugin ──────────────────────────────── -->
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <configuration>
                <!-- Layered JAR pour Docker multi-stage -->
                <layers>
                    <enabled>true</enabled>
                </layers>
                <!-- Exclure Lombok du JAR runtime -->
                <excludes>
                    <exclude>
                        <groupId>org.projectlombok</groupId>
                        <artifactId>lombok</artifactId>
                    </exclude>
                </excludes>
            </configuration>
        </plugin>

        <!-- ─── JaCoCo (couverture de code) ───────────────────────────── -->
        <plugin>
            <groupId>org.jacoco</groupId>
            <artifactId>jacoco-maven-plugin</artifactId>
            <version>0.8.11</version>
            <executions>
                <execution>
                    <id>prepare-agent</id>
                    <goals><goal>prepare-agent</goal></goals>
                </execution>
                <execution>
                    <id>report</id>
                    <phase>test</phase>
                    <goals><goal>report</goal></goals>
                </execution>
                <!-- Vérification du seuil de couverture -->
                <execution>
                    <id>check</id>
                    <goals><goal>check</goal></goals>
                    <configuration>
                        <rules>
                            <rule>
                                <element>BUNDLE</element>
                                <limits>
                                    <limit>
                                        <counter>LINE</counter>
                                        <value>COVEREDRATIO</value>
                                        <minimum>0.80</minimum> <!-- 80% minimum -->
                                    </limit>
                                    <limit>
                                        <counter>BRANCH</counter>
                                        <value>COVEREDRATIO</value>
                                        <minimum>0.70</minimum>
                                    </limit>
                                </limits>
                            </rule>
                        </rules>
                        <excludes>
                            <!-- Exclure les classes générées -->
                            <exclude>**/dto/**</exclude>
                            <exclude>**/entity/**</exclude>
                            <exclude>**/*Config.class</exclude>
                            <exclude>**/*Application.class</exclude>
                        </excludes>
                    </configuration>
                </execution>
            </executions>
        </plugin>

        <!-- ─── OWASP Dependency Check ────────────────────────────────── -->
        <plugin>
            <groupId>org.owasp</groupId>
            <artifactId>dependency-check-maven</artifactId>
            <version>9.0.9</version>
            <configuration>
                <failBuildOnCVSS>9</failBuildOnCVSS>
                <suppressionFile>.owasp-suppressions.xml</suppressionFile>
                <!-- NVD API key pour éviter les rate limits -->
                <nvdApiKey>${env.NVD_API_KEY}</nvdApiKey>
            </configuration>
        </plugin>

        <!-- ─── Maven Surefire (tests unitaires) ──────────────────────── -->
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <configuration>
                <!-- Exclure les tests d'intégration (Convention *IT.java) -->
                <excludes>
                    <exclude>**/*IT.java</exclude>
                    <exclude>**/*IntegrationTest.java</exclude>
                </excludes>
                <!-- Parallélisme pour accélérer les tests -->
                <parallel>methods</parallel>
                <threadCount>4</threadCount>
            </configuration>
        </plugin>

        <!-- ─── Maven Failsafe (tests d'intégration) ──────────────────── -->
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-failsafe-plugin</artifactId>
            <executions>
                <execution>
                    <goals>
                        <goal>integration-test</goal>
                        <goal>verify</goal>
                    </goals>
                </execution>
            </executions>
            <configuration>
                <includes>
                    <include>**/*IT.java</include>
                    <include>**/*IntegrationTest.java</include>
                </includes>
            </configuration>
        </plugin>

    </plugins>
</build>

────────────────────────────────────────────────────────────────
43.6 SECRETS GITHUB — CONFIGURATION
────────────────────────────────────────────────────────────────

Configurer ces secrets dans GitHub Settings -> Secrets and variables -> Actions :

SECRETS REQUIS :
┌────────────────────────┬─────────────────────────────────────────┐
│ Secret                 │ Valeur                                  │
├────────────────────────┼─────────────────────────────────────────┤
│ STAGING_HOST           │ IP ou hostname du serveur staging       │
│ STAGING_USER           │ Utilisateur SSH staging                 │
│ STAGING_SSH_KEY        │ Clé privée SSH (PEM format)             │
│ PROD_HOST              │ IP ou hostname du serveur production    │
│ PROD_USER              │ Utilisateur SSH production              │
│ PROD_SSH_KEY           │ Clé privée SSH production               │
│ SLACK_WEBHOOK_URL      │ Webhook Slack pour notifications        │
│ CODECOV_TOKEN          │ Token Codecov pour couverture           │
│ NVD_API_KEY            │ API key NIST NVD pour OWASP scan        │
└────────────────────────┴─────────────────────────────────────────┘

SECRETS AUTOMATIQUES (fournis par GitHub) :
  GITHUB_TOKEN  -> pour push vers GHCR et commentaires PR

────────────────────────────────────────────────────────────────
EXERCICES PARTIE 14
────────────────────────────────────────────────────────────────

FACILES
───────
1. Construire l'image Docker localement et vérifier sa taille :
   docker build -t taskflow:local .
   docker images taskflow:local
   -> Comparer la taille avec eclipse-temurin:21-jdk (~900MB)
   -> L'image multi-stage devrait faire ~250MB

2. Tester docker-compose.dev.yml en local :
   docker compose -f docker-compose.yml -f docker-compose.dev.yml up
   -> Vérifier que l'application démarre et que les healthchecks passent

3. Ajouter un workflow GitHub Actions simple qui s'exécute sur chaque push
   et affiche uniquement le résultat de ./mvnw compile.

INTERMÉDIAIRES
──────────────
4. Configurer le cache Maven dans GitHub Actions pour réduire le temps de build.
   Mesurer le temps de build avec et sans cache.
   (Hint : actions/setup-java@v4 avec cache: 'maven')

5. Ajouter un job "Checkstyle" dans le workflow CI qui vérifie que le code
   respecte les règles de style Google Java Style Guide.
   Configurer pom.xml et créer checkstyle.xml dans les ressources Maven.

6. Implémenter un "canary deployment" dans le workflow CD :
   - Déployer la nouvelle version sur 1 des 2 instances (50% du trafic)
   - Attendre 5 minutes et vérifier les métriques d'erreur
   - Si OK -> déployer sur la 2ème instance
   - Si KO -> rollback automatique

AVANCÉS
───────
7. Configurer GitHub Environments avec des "deployment protection rules" :
   - staging : déploiement automatique
   - production : approbation obligatoire par 2 reviewers + délai de 5 minutes
   Implémenter dans le workflow CD.

8. Créer un workflow "Release" qui :
   - S'exécute sur création de tag v*.*.* 
   - Génère le CHANGELOG depuis les Conventional Commits
   - Crée une GitHub Release avec les notes de version
   - Build et push l'image avec le tag sémantique (ex: 1.2.3)
   (Hint: release-please ou semantic-release)

9. Mettre en place un pipeline GitOps complet avec ArgoCD :
   - Deux repositories : app-code (code Spring Boot) et app-config (Helm charts)
   - Le pipeline CI met à jour le tag d'image dans app-config
   - ArgoCD détecte le changement et synchronise avec Kubernetes
   Documenter l'architecture et les étapes de configuration.

════════════════════════════════════════════════════════════════
RÉSUMÉ PARTIE 14
════════════════════════════════════════════════════════════════

CHAPITRE 42 — DOCKER AVANCÉ :
  [OK] Dockerfile multi-stage en 3 stages (deps, build, runtime)
  [OK] Layered JARs Spring Boot pour cache Docker optimal
  [OK] Utilisateur non-root (sécurité)
  [OK] JAVA_OPTS avec MaxRAMPercentage pour containers
  [OK] HEALTHCHECK Docker natif
  [OK] docker-compose.yml (base) + dev.yml + prod.yml
  [OK] Tous les services : PostgreSQL, Redis, Kafka, RabbitMQ
  [OK] Nginx reverse proxy avec SSL et rate limiting
  [OK] Fichier .env.example avec tous les secrets

CHAPITRE 43 — GITHUB ACTIONS :
  [OK] Workflow CI : validate -> unit-tests -> integration-tests -> coverage -> security
  [OK] Rapport de tests avec dorny/test-reporter
  [OK] JaCoCo avec seuil minimal de 80%
  [OK] OWASP Dependency Check
  [OK] Scan de secrets (Trufflehog)
  [OK] Workflow CD : build-push Docker -> staging -> production (avec approbation)
  [OK] Build multi-platform (AMD64 + ARM64) avec Buildx
  [OK] Cache de registry Docker (buildcache)
  [OK] Scan de l'image Docker (Trivy)
  [OK] Déploiement SSH avec smoke tests
  [OK] Notifications Slack (succès/échec)
  [OK] Workflow PR : titre conventionnel, checkstyle, spotbugs, pmd
  [OK] Configuration secrets GitHub

================================================================================
FIN PARTIE 14
Prochaine partie -> Déploiement production/cloud (AWS, GCP, Kubernetes)
================================================================================

================================================================================
GUIDE SPRING BOOT ENTREPRISE — PARTIE 15
DÉPLOIEMENT PRODUCTION & CLOUD (AWS, GCP, KUBERNETES)
Chapitres 44-45
Projet : TaskFlow Backend
================================================================================

TABLE DES MATIÈRES — PARTIE 15
────────────────────────────────
Chapitre 44 : Déploiement AWS
  44.1  Architecture AWS pour Spring Boot
  44.2  Elastic Beanstalk (déploiement rapide)
  44.3  EC2 + RDS + ElastiCache
  44.4  AWS ECS + Fargate (containers managés)
  44.5  S3 pour fichiers statiques et exports
  44.6  AWS Secrets Manager
  44.7  CloudWatch Logs + Alarms
  44.8  Load Balancer & Auto Scaling

Chapitre 45 : Kubernetes (K8s)
  45.1  Concepts fondamentaux K8s
  45.2  Manifestes YAML : Deployment, Service, Ingress
  45.3  ConfigMap & Secrets K8s
  45.4  Health checks (liveness, readiness, startup probes)
  45.5  Helm Charts pour TaskFlow
  45.6  Horizontal Pod Autoscaler (HPA)
  45.7  Déploiement sur GKE (Google Kubernetes Engine)
  45.8  Rolling Updates & Rollbacks
  45.9  Persistance avec PersistentVolumeClaims

================================================================================
CHAPITRE 44 : DÉPLOIEMENT AWS
================================================================================

44.1 ARCHITECTURE AWS POUR SPRING BOOT
────────────────────────────────────────

Schéma d'architecture AWS pour TaskFlow :

    Internet
       │
   [Route 53]  <- DNS
       │
   [CloudFront] <- CDN + WAF
       │
[Application Load Balancer]
    /          \
[EC2/ECS]  [EC2/ECS]    <- Auto Scaling Group (AZ-a, AZ-b)
    │              │
    └──────┬───────┘
           │
    [RDS PostgreSQL]    <- Multi-AZ, Encrypted
    [ElastiCache Redis] <- Cluster Mode
    [MSK Kafka]         <- Amazon Managed Streaming
       │
    [S3]                <- Fichiers, Backups, Logs
    [Secrets Manager]   <- Credentials, JWT secrets
    [CloudWatch]        <- Logs, Metrics, Alarms

SERVICES AWS UTILISÉS :
- EC2 / ECS Fargate : Compute pour l'application
- RDS PostgreSQL 16  : Base de données managée
- ElastiCache Redis  : Cache distribué
- ALB               : Load Balancer Layer 7
- Route 53          : DNS
- CloudFront        : CDN et protection DDoS
- S3                : Stockage d'objets
- Secrets Manager   : Gestion des secrets
- CloudWatch        : Logs et monitoring
- IAM               : Gestion des accès
- VPC               : Réseau privé virtuel

─────────────────────────────────────────────────────────────────────
44.2 ELASTIC BEANSTALK (DÉPLOIEMENT RAPIDE)
─────────────────────────────────────────────────────────────────────

Elastic Beanstalk est le moyen le plus simple de déployer une
application Spring Boot sur AWS — il gère automatiquement le
provisionnement des ressources, le load balancing, l'auto-scaling.

STRUCTURE DU PROJET POUR BEANSTALK :

    taskflow-backend/
    ├── .ebextensions/
    │   ├── 01_environment.config
    │   ├── 02_rds.config
    │   └── 03_jvm.config
    ├── .platform/
    │   └── nginx/
    │       └── conf.d/
    │           └── proxy.conf
    └── Procfile                    <- Commande de démarrage

FICHIER : .ebextensions/01_environment.config
─────────────────────────────────────────────

option_settings:
  aws:elasticbeanstalk:application:environment:
    SPRING_PROFILES_ACTIVE: prod
    SERVER_PORT: 5000
    JAVA_TOOL_OPTIONS: >-
      -Xmx512m
      -XX:MaxMetaspaceSize=128m
      -XX:+UseG1GC
      -Dfile.encoding=UTF-8

  aws:elasticbeanstalk:environment:proxy:staticfiles:
    /static: /var/app/current/static

  aws:elasticbeanstalk:healthreporting:system:
    SystemType: enhanced
    HealthCheckSuccessThreshold: Ok

  aws:elasticbeanstalk:environment:
    LoadBalancerType: application

  aws:elbv2:loadbalancer:
    IdleTimeout: 120
    ManagedSecurityGroup: sg-xxxx

FICHIER : .ebextensions/02_rds.config
──────────────────────────────────────

option_settings:
  aws:elasticbeanstalk:application:environment:
    # Ces valeurs sont injectées depuis Secrets Manager
    # via les variables d'environnement EB
    RDS_HOSTNAME: "{{resolve:secretsmanager:taskflow/prod/db:SecretString:host}}"
    RDS_PORT: "5432"
    RDS_DB_NAME: "taskflow_prod"
    # Note : mot de passe injecté via Secrets Manager, pas ici

FICHIER : .ebextensions/03_jvm.config
──────────────────────────────────────

files:
  "/opt/elasticbeanstalk/tasks/taillogs.d/springboot.conf":
    mode: "000644"
    owner: root
    group: root
    content: |
      /var/log/taskflow/*.log

commands:
  01_create_log_dir:
    command: "mkdir -p /var/log/taskflow && chmod 755 /var/log/taskflow"
    ignoreErrors: true

FICHIER : .platform/nginx/conf.d/proxy.conf
────────────────────────────────────────────

upstream springboot {
    server 127.0.0.1:5000;
    keepalive 32;
}

server {
    listen 80;

    gzip on;
    gzip_comp_level 4;
    gzip_types text/plain text/css application/json application/javascript;

    location / {
        proxy_pass          http://springboot;
        proxy_http_version  1.1;
        proxy_set_header    Connection        "";
        proxy_set_header    Host              $host;
        proxy_set_header    X-Real-IP         $remote_addr;
        proxy_set_header    X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header    X-Forwarded-Proto $scheme;

        # Timeouts
        proxy_connect_timeout  60s;
        proxy_send_timeout     60s;
        proxy_read_timeout     60s;

        # Buffers
        proxy_buffer_size          128k;
        proxy_buffers              4 256k;
        proxy_busy_buffers_size    256k;
    }

    location /health {
        proxy_pass http://springboot/actuator/health;
        access_log off;
    }
}

FICHIER : Procfile
──────────────────

web: java -jar target/taskflow-backend-1.0.0.jar

DÉPLOIEMENT AVEC EB CLI :
─────────────────────────

# Installation
pip install awsebcli

# Initialisation
eb init taskflow-backend \
  --platform "64bit Amazon Linux 2023 v4.x running Corretto 21" \
  --region eu-west-1

# Création de l'environnement de staging
eb create taskflow-staging \
  --instance-type t3.medium \
  --min-instances 1 \
  --max-instances 3 \
  --database.engine postgres \
  --database.version 16 \
  --database.instance db.t3.medium \
  --database.username taskflow_user \
  --elb-type application \
  --vpc.id vpc-xxxx \
  --vpc.ec2subnets subnet-xxxx,subnet-yyyy \
  --vpc.elbsubnets subnet-aaaa,subnet-bbbb \
  --vpc.elbpublic

# Déploiement
mvn clean package -DskipTests
eb deploy taskflow-staging --label "v1.2.0"

# Vérification
eb status
eb logs

─────────────────────────────────────────────────────────────────────
44.3 EC2 + RDS + ELASTICACHE (CONFIGURATION DIRECTE)
─────────────────────────────────────────────────────────────────────

TERRAFORM — INFRASTRUCTURE AS CODE :
──────────────────────────────────────

# Fichier : infrastructure/main.tf

terraform {
  required_version = ">= 1.8"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
  backend "s3" {
    bucket = "taskflow-terraform-state"
    key    = "prod/terraform.tfstate"
    region = "eu-west-1"
  }
}

provider "aws" {
  region = var.aws_region
}

# ─── VPC ───────────────────────────────────────────────────────────
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = "taskflow-vpc"
  cidr = "10.0.0.0/16"

  azs              = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]
  private_subnets  = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets   = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
  database_subnets = ["10.0.201.0/24", "10.0.202.0/24", "10.0.203.0/24"]

  enable_nat_gateway     = true
  single_nat_gateway     = false  # HA : une NAT par AZ
  enable_dns_hostnames   = true
  enable_dns_support     = true

  create_database_subnet_group       = true
  create_database_subnet_route_table = true
  database_subnet_group_name         = "taskflow-db-subnet-group"

  tags = local.common_tags
}

# ─── SECURITY GROUPS ───────────────────────────────────────────────
resource "aws_security_group" "alb" {
  name        = "taskflow-alb-sg"
  description = "Security group for Application Load Balancer"
  vpc_id      = module.vpc.vpc_id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = merge(local.common_tags, { Name = "taskflow-alb-sg" })
}

resource "aws_security_group" "app" {
  name        = "taskflow-app-sg"
  description = "Security group for TaskFlow application"
  vpc_id      = module.vpc.vpc_id

  ingress {
    from_port       = 8080
    to_port         = 8080
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = merge(local.common_tags, { Name = "taskflow-app-sg" })
}

resource "aws_security_group" "rds" {
  name        = "taskflow-rds-sg"
  description = "Security group for RDS PostgreSQL"
  vpc_id      = module.vpc.vpc_id

  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.app.id]
  }

  tags = merge(local.common_tags, { Name = "taskflow-rds-sg" })
}

resource "aws_security_group" "redis" {
  name        = "taskflow-redis-sg"
  description = "Security group for ElastiCache Redis"
  vpc_id      = module.vpc.vpc_id

  ingress {
    from_port       = 6379
    to_port         = 6379
    protocol        = "tcp"
    security_groups = [aws_security_group.app.id]
  }

  tags = merge(local.common_tags, { Name = "taskflow-redis-sg" })
}

# ─── RDS POSTGRESQL ────────────────────────────────────────────────
resource "aws_db_instance" "taskflow" {
  identifier = "taskflow-prod"
  engine     = "postgres"
  engine_version = "16.2"

  instance_class    = "db.t3.medium"
  allocated_storage = 100
  max_allocated_storage = 500  # Autoscaling storage jusqu'à 500GB
  storage_type      = "gp3"
  storage_encrypted = true

  db_name  = "taskflow_prod"
  username = "taskflow_user"
  # Le mot de passe est géré par Secrets Manager
  manage_master_user_password = true

  vpc_security_group_ids = [aws_security_group.rds.id]
  db_subnet_group_name   = module.vpc.database_subnet_group_name

  multi_az               = true  # Haute disponibilité
  publicly_accessible    = false
  deletion_protection    = true
  skip_final_snapshot    = false
  final_snapshot_identifier = "taskflow-final-snapshot"

  backup_retention_period = 7
  backup_window          = "03:00-04:00"
  maintenance_window     = "mon:04:00-mon:05:00"

  # Performance Insights
  performance_insights_enabled          = true
  performance_insights_retention_period = 7

  # Enhanced Monitoring
  monitoring_interval = 60
  monitoring_role_arn = aws_iam_role.rds_monitoring.arn

  # Parameters
  parameter_group_name = aws_db_parameter_group.taskflow.name

  tags = merge(local.common_tags, { Name = "taskflow-prod" })
}

resource "aws_db_parameter_group" "taskflow" {
  family      = "postgres16"
  name        = "taskflow-pg16"
  description = "TaskFlow PostgreSQL 16 parameter group"

  parameter {
    name  = "shared_preload_libraries"
    value = "pg_stat_statements"
  }

  parameter {
    name  = "log_min_duration_statement"
    value = "1000"  # Log les requêtes > 1s
  }

  parameter {
    name  = "max_connections"
    value = "200"
  }

  parameter {
    name  = "work_mem"
    value = "4096"  # 4MB par connection
  }
}

# ─── ELASTICACHE REDIS ─────────────────────────────────────────────
resource "aws_elasticache_replication_group" "taskflow" {
  replication_group_id = "taskflow-redis"
  description          = "TaskFlow Redis cluster"

  node_type               = "cache.t3.medium"
  num_cache_clusters      = 2  # Primary + 1 replica
  automatic_failover_enabled = true
  multi_az_enabled        = true

  engine_version          = "7.2"
  port                    = 6379
  parameter_group_name    = aws_elasticache_parameter_group.taskflow.name

  subnet_group_name    = aws_elasticache_subnet_group.taskflow.name
  security_group_ids   = [aws_security_group.redis.id]

  at_rest_encryption_enabled = true
  transit_encryption_enabled = true
  auth_token                 = random_password.redis_auth.result

  maintenance_window    = "tue:05:00-tue:06:00"
  snapshot_window       = "04:00-05:00"
  snapshot_retention_limit = 5

  tags = local.common_tags
}

# ─── APPLICATION LOAD BALANCER ─────────────────────────────────────
resource "aws_lb" "taskflow" {
  name               = "taskflow-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = module.vpc.public_subnets

  enable_deletion_protection = true
  enable_http2               = true

  access_logs {
    bucket  = aws_s3_bucket.alb_logs.id
    prefix  = "taskflow-alb"
    enabled = true
  }

  tags = local.common_tags
}

resource "aws_lb_target_group" "taskflow" {
  name     = "taskflow-tg"
  port     = 8080
  protocol = "HTTP"
  vpc_id   = module.vpc.vpc_id

  health_check {
    enabled             = true
    path                = "/actuator/health"
    interval            = 30
    timeout             = 5
    healthy_threshold   = 2
    unhealthy_threshold = 3
    matcher             = "200"
  }

  deregistration_delay = 30

  stickiness {
    type            = "lb_cookie"
    cookie_duration = 86400
    enabled         = false  # Stateless JWT, pas de stickiness nécessaire
  }
}

resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.taskflow.arn
  port              = "443"
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = aws_acm_certificate.taskflow.arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.taskflow.arn
  }
}

resource "aws_lb_listener" "http_redirect" {
  load_balancer_arn = aws_lb.taskflow.arn
  port              = "80"
  protocol          = "HTTP"

  default_action {
    type = "redirect"
    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

─────────────────────────────────────────────────────────────────────
44.4 AWS ECS + FARGATE
─────────────────────────────────────────────────────────────────────

ECS Fargate permet de déployer des containers sans gérer de serveurs.

# Fichier : infrastructure/ecs.tf

# ─── ECS CLUSTER ───────────────────────────────────────────────────
resource "aws_ecs_cluster" "taskflow" {
  name = "taskflow-cluster"

  configuration {
    execute_command_configuration {
      logging = "OVERRIDE"
      log_configuration {
        cloud_watch_log_group_name = aws_cloudwatch_log_group.ecs_exec.name
      }
    }
  }

  setting {
    name  = "containerInsights"
    value = "enabled"
  }

  tags = local.common_tags
}

resource "aws_ecs_cluster_capacity_providers" "taskflow" {
  cluster_name       = aws_ecs_cluster.taskflow.name
  capacity_providers = ["FARGATE", "FARGATE_SPOT"]

  default_capacity_provider_strategy {
    base              = 1
    weight            = 70
    capacity_provider = "FARGATE"
  }

  default_capacity_provider_strategy {
    weight            = 30
    capacity_provider = "FARGATE_SPOT"
  }
}

# ─── TASK DEFINITION ───────────────────────────────────────────────
resource "aws_ecs_task_definition" "taskflow" {
  family                   = "taskflow-api"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "512"   # 0.5 vCPU
  memory                   = "1024"  # 1 GB RAM
  execution_role_arn       = aws_iam_role.ecs_execution.arn
  task_role_arn            = aws_iam_role.ecs_task.arn

  container_definitions = jsonencode([
    {
      name  = "taskflow-api"
      image = "${aws_ecr_repository.taskflow.repository_url}:${var.image_tag}"

      portMappings = [
        {
          containerPort = 8080
          protocol      = "tcp"
        }
      ]

      environment = [
        { name = "SPRING_PROFILES_ACTIVE", value = "prod" },
        { name = "SERVER_PORT", value = "8080" }
      ]

      # Secrets depuis AWS Secrets Manager
      secrets = [
        {
          name      = "DB_URL"
          valueFrom = "${aws_secretsmanager_secret.db.arn}:url::"
        },
        {
          name      = "DB_USERNAME"
          valueFrom = "${aws_secretsmanager_secret.db.arn}:username::"
        },
        {
          name      = "DB_PASSWORD"
          valueFrom = "${aws_secretsmanager_secret.db.arn}:password::"
        },
        {
          name      = "JWT_SECRET"
          valueFrom = "${aws_secretsmanager_secret.app.arn}:jwt_secret::"
        },
        {
          name      = "REDIS_AUTH_TOKEN"
          valueFrom = "${aws_secretsmanager_secret.redis.arn}:auth_token::"
        }
      ]

      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = aws_cloudwatch_log_group.taskflow.name
          "awslogs-region"        = var.aws_region
          "awslogs-stream-prefix" = "api"
        }
      }

      healthCheck = {
        command     = ["CMD-SHELL", "curl -f http://localhost:8080/actuator/health || exit 1"]
        interval    = 30
        timeout     = 5
        retries     = 3
        startPeriod = 60
      }

      stopTimeout = 30

      # Limits mémoire
      memoryReservation = 768
    }
  ])

  tags = local.common_tags
}

# ─── ECS SERVICE ───────────────────────────────────────────────────
resource "aws_ecs_service" "taskflow" {
  name            = "taskflow-api"
  cluster         = aws_ecs_cluster.taskflow.id
  task_definition = aws_ecs_task_definition.taskflow.arn
  desired_count   = 2

  # Déploiement rolling avec health checks
  deployment_maximum_percent         = 200
  deployment_minimum_healthy_percent = 100

  deployment_circuit_breaker {
    enable   = true
    rollback = true
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.taskflow.arn
    container_name   = "taskflow-api"
    container_port   = 8080
  }

  network_configuration {
    subnets          = module.vpc.private_subnets
    security_groups  = [aws_security_group.app.id]
    assign_public_ip = false
  }

  # Service Discovery
  service_registries {
    registry_arn = aws_service_discovery_service.taskflow.arn
  }

  enable_execute_command = true  # Pour déboguer en prod

  lifecycle {
    ignore_changes = [desired_count]  # Géré par l'autoscaler
  }

  depends_on = [aws_lb_listener.https]

  tags = local.common_tags
}

# ─── AUTO SCALING ──────────────────────────────────────────────────
resource "aws_appautoscaling_target" "taskflow" {
  max_capacity       = 10
  min_capacity       = 2
  resource_id        = "service/${aws_ecs_cluster.taskflow.name}/${aws_ecs_service.taskflow.name}"
  scalable_dimension = "ecs:service:DesiredCount"
  service_namespace  = "ecs"
}

resource "aws_appautoscaling_policy" "cpu" {
  name               = "taskflow-cpu-scaling"
  policy_type        = "TargetTrackingScaling"
  resource_id        = aws_appautoscaling_target.taskflow.resource_id
  scalable_dimension = aws_appautoscaling_target.taskflow.scalable_dimension
  service_namespace  = aws_appautoscaling_target.taskflow.service_namespace

  target_tracking_scaling_policy_configuration {
    predefined_metric_specification {
      predefined_metric_type = "ECSServiceAverageCPUUtilization"
    }
    target_value       = 70.0
    scale_in_cooldown  = 300
    scale_out_cooldown = 60
  }
}

resource "aws_appautoscaling_policy" "memory" {
  name               = "taskflow-memory-scaling"
  policy_type        = "TargetTrackingScaling"
  resource_id        = aws_appautoscaling_target.taskflow.resource_id
  scalable_dimension = aws_appautoscaling_target.taskflow.scalable_dimension
  service_namespace  = aws_appautoscaling_target.taskflow.service_namespace

  target_tracking_scaling_policy_configuration {
    predefined_metric_specification {
      predefined_metric_type = "ECSServiceAverageMemoryUtilization"
    }
    target_value       = 80.0
    scale_in_cooldown  = 300
    scale_out_cooldown = 60
  }
}

─────────────────────────────────────────────────────────────────────
44.5 AWS SECRETS MANAGER — INTÉGRATION SPRING BOOT
─────────────────────────────────────────────────────────────────────

DÉPENDANCE MAVEN :
──────────────────

<!-- pom.xml -->
<dependency>
    <groupId>io.awspring.cloud</groupId>
    <artifactId>spring-cloud-aws-starter-secrets-manager</artifactId>
    <version>3.1.1</version>
</dependency>

CONFIGURATION APPLICATION.PROPERTIES (PROD) :
──────────────────────────────────────────────

# application-prod.properties

# Spring Cloud AWS — récupère les secrets automatiquement
spring.cloud.aws.region.static=eu-west-1
spring.cloud.aws.secretsmanager.enabled=true

# Le préfixe /secret/ mappe sur Secrets Manager
spring.config.import=aws-secretsmanager:/secret/taskflow/prod

# Dans Secrets Manager, le secret "taskflow/prod" contient :
# {
#   "db.url": "jdbc:postgresql://...",
#   "db.username": "taskflow_user",
#   "db.password": "...",
#   "jwt.secret": "...",
#   "redis.auth-token": "..."
# }

spring.datasource.url=${db.url}
spring.datasource.username=${db.username}
spring.datasource.password=${db.password}

application.security.jwt.secret=${jwt.secret}
spring.data.redis.password=${redis.auth-token}

CRÉATION DU SECRET VIA AWS CLI :
──────────────────────────────────

aws secretsmanager create-secret \
  --name "taskflow/prod" \
  --description "TaskFlow production secrets" \
  --secret-string '{
    "db.url": "jdbc:postgresql://taskflow-prod.cluster-xxx.eu-west-1.rds.amazonaws.com:5432/taskflow_prod",
    "db.username": "taskflow_user",
    "db.password": "SuperSecretPassword123!",
    "jwt.secret": "404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970",
    "redis.auth-token": "RedisAuthToken456!"
  }' \
  --region eu-west-1

# Rotation automatique du mot de passe RDS
aws secretsmanager rotate-secret \
  --secret-id "taskflow/prod" \
  --rotation-lambda-arn arn:aws:lambda:eu-west-1:123:function:SecretsManagerRotation \
  --rotation-rules AutomaticallyAfterDays=30

─────────────────────────────────────────────────────────────────────
44.6 CLOUDWATCH — LOGS, MÉTRIQUES, ALARMES
─────────────────────────────────────────────────────────────────────

CONFIGURATION CLOUDWATCH LOGS DANS SPRING BOOT :
──────────────────────────────────────────────────

<!-- pom.xml -->
<dependency>
    <groupId>ca.pjer</groupId>
    <artifactId>logback-awslogs-appender</artifactId>
    <version>1.6.0</version>
</dependency>

FICHIER : src/main/resources/logback-prod.xml
──────────────────────────────────────────────

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <!-- Appender CloudWatch -->
    <appender name="AWS_LOGS" class="ca.pjer.logback.AwsLogsAppender">
        <layout>
            <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </layout>
        <logGroupName>/taskflow/prod/api</logGroupName>
        <logStreamUuidPrefix>taskflow-api-</logStreamUuidPrefix>
        <logRegion>eu-west-1</logRegion>
        <maxBatchLogEvents>50</maxBatchLogEvents>
        <maxFlushTimeMillis>3000</maxFlushTimeMillis>
        <maxBlockTimeMillis>0</maxBlockTimeMillis>
    </appender>

    <!-- Appender Console (JSON structuré pour ECS) -->
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <includeMdcKeyName>requestId</includeMdcKeyName>
            <includeMdcKeyName>userId</includeMdcKeyName>
            <includeMdcKeyName>traceId</includeMdcKeyName>
        </encoder>
    </appender>

    <!-- Async pour ne pas bloquer les threads applicatifs -->
    <appender name="ASYNC_AWS" class="ch.qos.logback.classic.AsyncAppender">
        <appender-ref ref="AWS_LOGS"/>
        <queueSize>512</queueSize>
        <neverBlock>true</neverBlock>
    </appender>

    <root level="INFO">
        <appender-ref ref="STDOUT"/>
        <appender-ref ref="ASYNC_AWS"/>
    </root>

    <logger name="com.taskflow" level="DEBUG" additivity="false">
        <appender-ref ref="STDOUT"/>
        <appender-ref ref="ASYNC_AWS"/>
    </logger>

    <!-- Réduire le bruit Hibernate en prod -->
    <logger name="org.hibernate.SQL" level="WARN"/>
    <logger name="org.springframework.security" level="WARN"/>
</configuration>

TERRAFORM — CLOUDWATCH ALARMS :
─────────────────────────────────

# infrastructure/monitoring.tf

# ─── Log Groups ────────────────────────────────────────────────────
resource "aws_cloudwatch_log_group" "taskflow" {
  name              = "/taskflow/prod/api"
  retention_in_days = 30
  tags              = local.common_tags
}

# ─── Metric Filters ────────────────────────────────────────────────
resource "aws_cloudwatch_log_metric_filter" "error_count" {
  name           = "taskflow-error-count"
  pattern        = "[timestamp, requestId, level=ERROR, ...]"
  log_group_name = aws_cloudwatch_log_group.taskflow.name

  metric_transformation {
    name          = "ErrorCount"
    namespace     = "TaskFlow/API"
    value         = "1"
    default_value = "0"
  }
}

resource "aws_cloudwatch_log_metric_filter" "auth_failure" {
  name           = "taskflow-auth-failures"
  pattern        = "\"Authentication failed\" OR \"Invalid JWT\""
  log_group_name = aws_cloudwatch_log_group.taskflow.name

  metric_transformation {
    name      = "AuthFailureCount"
    namespace = "TaskFlow/Security"
    value     = "1"
  }
}

# ─── Alarms ────────────────────────────────────────────────────────
resource "aws_cloudwatch_metric_alarm" "high_error_rate" {
  alarm_name          = "taskflow-high-error-rate"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 2
  metric_name         = "ErrorCount"
  namespace           = "TaskFlow/API"
  period              = 300
  statistic           = "Sum"
  threshold           = 50
  alarm_description   = "Plus de 50 erreurs en 5 minutes"
  alarm_actions       = [aws_sns_topic.alerts.arn]
  ok_actions          = [aws_sns_topic.alerts.arn]
  treat_missing_data  = "notBreaching"
}

resource "aws_cloudwatch_metric_alarm" "high_latency" {
  alarm_name          = "taskflow-high-latency"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 3
  metric_name         = "TargetResponseTime"
  namespace           = "AWS/ApplicationELB"
  period              = 60
  extended_statistic  = "p99"
  threshold           = 2.0  # 2 secondes
  alarm_description   = "P99 latency > 2s"
  alarm_actions       = [aws_sns_topic.alerts.arn]

  dimensions = {
    LoadBalancer = aws_lb.taskflow.arn_suffix
    TargetGroup  = aws_lb_target_group.taskflow.arn_suffix
  }
}

resource "aws_cloudwatch_metric_alarm" "rds_cpu" {
  alarm_name          = "taskflow-rds-cpu"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 3
  metric_name         = "CPUUtilization"
  namespace           = "AWS/RDS"
  period              = 300
  statistic           = "Average"
  threshold           = 80
  alarm_description   = "CPU RDS > 80%"
  alarm_actions       = [aws_sns_topic.alerts.arn]

  dimensions = {
    DBInstanceIdentifier = aws_db_instance.taskflow.id
  }
}

resource "aws_cloudwatch_dashboard" "taskflow" {
  dashboard_name = "TaskFlow-Production"

  dashboard_body = jsonencode({
    widgets = [
      {
        type   = "metric"
        x      = 0; y = 0; width = 12; height = 6
        properties = {
          title   = "Request Count & Latency"
          metrics = [
            ["AWS/ApplicationELB", "RequestCount", "LoadBalancer", aws_lb.taskflow.arn_suffix],
            [".", "TargetResponseTime", ".", ".", { stat = "p50" }],
            [".", ".", ".", ".", { stat = "p99" }]
          ]
          view   = "timeSeries"
          period = 60
        }
      },
      {
        type   = "metric"
        x      = 12; y = 0; width = 12; height = 6
        properties = {
          title   = "ECS Service Health"
          metrics = [
            ["AWS/ECS", "CPUUtilization", "ServiceName", "taskflow-api", "ClusterName", "taskflow-cluster"],
            [".", "MemoryUtilization", ".", ".", ".", "."]
          ]
          view = "timeSeries"
        }
      }
    ]
  })
}

================================================================================
CHAPITRE 45 : KUBERNETES (K8S)
================================================================================

45.1 CONCEPTS FONDAMENTAUX KUBERNETES
───────────────────────────────────────

Kubernetes est la plateforme d'orchestration de containers la plus
répandue en entreprise. Voici les objets K8s essentiels :

GLOSSAIRE K8S :
───────────────

  Node        -> Machine physique ou virtuelle dans le cluster
  Pod         -> Plus petite unité déployable (1+ containers)
  Deployment  -> Gère le cycle de vie des Pods (rolling update, etc.)
  Service     -> Expose un ensemble de Pods via un réseau stable
  Ingress     -> Route le trafic HTTP/HTTPS vers les Services
  ConfigMap   -> Configuration non-sensible (clés/valeurs, fichiers)
  Secret      -> Données sensibles encodées base64
  Namespace   -> Isolation logique dans le cluster
  HPA         -> Horizontal Pod Autoscaler (scale automatique)
  PVC         -> PersistentVolumeClaim (stockage persistant)
  ServiceAccount -> Identité pour les Pods (accès à l'API K8s)

SCHÉMA D'ARCHITECTURE K8S POUR TASKFLOW :

  ┌─────────────────────────────────────────────────────────────────┐
  │  Namespace: taskflow-prod                                        │
  │                                                                   │
  │  ┌──────────────────┐    ┌──────────────────┐                   │
  │  │   Deployment     │    │   Deployment     │                   │
  │  │  taskflow-api    │    │  taskflow-worker │                   │
  │  │  (3 replicas)    │    │  (2 replicas)    │                   │
  │  │  ┌──────────┐    │    │  ┌──────────┐    │                   │
  │  │  │  Pod     │    │    │  │  Pod     │    │                   │
  │  │  │ :8080    │    │    │  │ :8081    │    │                   │
  │  │  └──────────┘    │    │  └──────────┘    │                   │
  │  └────────┬─────────┘    └──────────────────┘                   │
  │           │                                                       │
  │  ┌────────[BLACK_DOWN-POINTING_TRIANGLE]─────────┐    ┌──────────────────┐                   │
  │  │  Service         │    │  Service         │                   │
  │  │  ClusterIP:80    │    │  taskflow-db     │                   │
  │  └────────┬─────────┘    └──────────────────┘                   │
  │           │                                                       │
  │  ┌────────[BLACK_DOWN-POINTING_TRIANGLE]─────────┐                                           │
  │  │  Ingress         │                                           │
  │  │  api.taskflow.io │                                           │
  │  └──────────────────┘                                           │
  └─────────────────────────────────────────────────────────────────┘

─────────────────────────────────────────────────────────────────────
45.2 MANIFESTES YAML
─────────────────────────────────────────────────────────────────────

FICHIER : k8s/namespace.yaml
──────────────────────────────

apiVersion: v1
kind: Namespace
metadata:
  name: taskflow-prod
  labels:
    app.kubernetes.io/managed-by: helm
    environment: production

---
# ResourceQuota pour éviter la surconsommation
apiVersion: v1
kind: ResourceQuota
metadata:
  name: taskflow-quota
  namespace: taskflow-prod
spec:
  hard:
    requests.cpu: "4"
    requests.memory: 8Gi
    limits.cpu: "8"
    limits.memory: 16Gi
    pods: "20"

FICHIER : k8s/deployment.yaml
───────────────────────────────

apiVersion: apps/v1
kind: Deployment
metadata:
  name: taskflow-api
  namespace: taskflow-prod
  labels:
    app: taskflow-api
    version: "1.0.0"
    app.kubernetes.io/component: api
    app.kubernetes.io/part-of: taskflow
  annotations:
    # Permet le rollback via kubectl rollout undo
    kubernetes.io/change-cause: "Deploy v1.0.0 — feat: JWT refresh rotation"
spec:
  replicas: 3
  revisionHistoryLimit: 5  # Garder 5 révisions pour rollback

  selector:
    matchLabels:
      app: taskflow-api

  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1        # +1 Pod pendant le déploiement
      maxUnavailable: 0  # Aucune interruption de service

  template:
    metadata:
      labels:
        app: taskflow-api
        version: "1.0.0"
      annotations:
        # Force le redémarrage si le ConfigMap change
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
        prometheus.io/scrape: "true"
        prometheus.io/path: "/actuator/prometheus"
        prometheus.io/port: "8080"
    spec:
      serviceAccountName: taskflow-api

      # Sécurité du Pod
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        fsGroup: 1000

      # Anti-affinité : éviter 2 replicas sur le même nœud
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                labelSelector:
                  matchExpressions:
                    - key: app
                      operator: In
                      values:
                        - taskflow-api
                topologyKey: kubernetes.io/hostname

      terminationGracePeriodSeconds: 60

      containers:
        - name: taskflow-api
          image: gcr.io/taskflow-project/taskflow-api:1.0.0
          imagePullPolicy: Always

          ports:
            - name: http
              containerPort: 8080
              protocol: TCP

          # Ressources — TOUJOURS définir en production !
          resources:
            requests:
              cpu: 250m       # 0.25 vCPU garanti
              memory: 512Mi   # 512MB garanti
            limits:
              cpu: 1000m      # Max 1 vCPU
              memory: 1024Mi  # Max 1GB

          # Variables d'environnement depuis ConfigMap et Secrets
          envFrom:
            - configMapRef:
                name: taskflow-config
            - secretRef:
                name: taskflow-secrets

          env:
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            - name: POD_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
            - name: NODE_NAME
              valueFrom:
                fieldRef:
                  fieldPath: spec.nodeName

          # Liveness Probe : redémarre le container si non-vivant
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: http
            initialDelaySeconds: 60
            periodSeconds: 30
            timeoutSeconds: 10
            failureThreshold: 3
            successThreshold: 1

          # Readiness Probe : retire du LB si pas prêt à recevoir du trafic
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: http
            initialDelaySeconds: 30
            periodSeconds: 10
            timeoutSeconds: 5
            failureThreshold: 3
            successThreshold: 1

          # Startup Probe : laisse 5min au démarrage
          startupProbe:
            httpGet:
              path: /actuator/health/liveness
              port: http
            initialDelaySeconds: 10
            periodSeconds: 10
            timeoutSeconds: 5
            failureThreshold: 30  # 30 * 10s = 300s max

          # Lifecycle hooks
          lifecycle:
            preStop:
              exec:
                # Attend 15s avant d'arrêter (drain des connexions)
                command: ["/bin/sh", "-c", "sleep 15"]

          # Sécurité du container
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop:
                - ALL

          # Volume pour les logs (si filesystem en lecture seule)
          volumeMounts:
            - name: tmp-volume
              mountPath: /tmp
            - name: logs-volume
              mountPath: /var/log/taskflow

      volumes:
        - name: tmp-volume
          emptyDir: {}
        - name: logs-volume
          emptyDir: {}

      imagePullSecrets:
        - name: gcr-credentials

FICHIER : k8s/service.yaml
────────────────────────────

apiVersion: v1
kind: Service
metadata:
  name: taskflow-api
  namespace: taskflow-prod
  labels:
    app: taskflow-api
spec:
  type: ClusterIP  # Interne au cluster, exposé via Ingress
  selector:
    app: taskflow-api
  ports:
    - name: http
      port: 80
      targetPort: http
      protocol: TCP

---
# Service pour le monitoring Prometheus (port séparé si besoin)
apiVersion: v1
kind: Service
metadata:
  name: taskflow-api-metrics
  namespace: taskflow-prod
  labels:
    app: taskflow-api
    scrape: "true"
spec:
  type: ClusterIP
  selector:
    app: taskflow-api
  ports:
    - name: metrics
      port: 8080
      targetPort: http

FICHIER : k8s/ingress.yaml
────────────────────────────

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: taskflow-ingress
  namespace: taskflow-prod
  annotations:
    # Nginx Ingress Controller
    kubernetes.io/ingress.class: "nginx"
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
    nginx.ingress.kubernetes.io/proxy-body-size: "10m"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "60"

    # Rate limiting
    nginx.ingress.kubernetes.io/limit-rpm: "1000"
    nginx.ingress.kubernetes.io/limit-connections: "100"

    # CORS (si géré côté Nginx plutôt que Spring)
    # nginx.ingress.kubernetes.io/enable-cors: "true"
    # nginx.ingress.kubernetes.io/cors-allow-origin: "https://app.taskflow.io"

    # Cert-Manager — génère et renouvelle le certificat SSL automatiquement
    cert-manager.io/cluster-issuer: "letsencrypt-prod"

spec:
  tls:
    - hosts:
        - api.taskflow.io
      secretName: taskflow-tls  # Secret créé automatiquement par cert-manager

  rules:
    - host: api.taskflow.io
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: taskflow-api
                port:
                  name: http

─────────────────────────────────────────────────────────────────────
45.3 CONFIGMAP & SECRETS
─────────────────────────────────────────────────────────────────────

FICHIER : k8s/configmap.yaml
──────────────────────────────

apiVersion: v1
kind: ConfigMap
metadata:
  name: taskflow-config
  namespace: taskflow-prod
data:
  # Variables d'environnement Spring Boot
  SPRING_PROFILES_ACTIVE: "prod,k8s"
  SERVER_PORT: "8080"
  SERVER_TOMCAT_MAX_THREADS: "200"
  SERVER_TOMCAT_MIN_SPARE_THREADS: "20"

  # Base de données (URL sans credentials)
  SPRING_DATASOURCE_URL: "jdbc:postgresql://postgres-service.database.svc.cluster.local:5432/taskflow_prod"
  SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE: "20"
  SPRING_DATASOURCE_HIKARI_MINIMUM_IDLE: "5"

  # Redis
  SPRING_DATA_REDIS_HOST: "redis-service.database.svc.cluster.local"
  SPRING_DATA_REDIS_PORT: "6379"

  # Kafka
  SPRING_KAFKA_BOOTSTRAP_SERVERS: "kafka-service.messaging.svc.cluster.local:9092"

  # Actuator
  MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: "health,info,metrics,prometheus"
  MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS: "when_authorized"

  # Logging
  LOGGING_LEVEL_COM_TASKFLOW: "INFO"
  LOGGING_LEVEL_ORG_HIBERNATE_SQL: "WARN"

  # Tracing
  MANAGEMENT_TRACING_SAMPLING_PROBABILITY: "0.1"  # 10% des requêtes

FICHIER : k8s/secret.yaml (NE JAMAIS COMMITER EN GIT !)
─────────────────────────────────────────────────────────

# En production, utiliser External Secrets Operator ou Sealed Secrets
# Ce fichier est pour référence seulement

apiVersion: v1
kind: Secret
metadata:
  name: taskflow-secrets
  namespace: taskflow-prod
type: Opaque
stringData:  # stringData encode automatiquement en base64
  SPRING_DATASOURCE_USERNAME: "taskflow_user"
  SPRING_DATASOURCE_PASSWORD: "SuperSecretPassword123!"
  APPLICATION_SECURITY_JWT_SECRET: "404E635266556A586E3272357538782F413F4428..."
  SPRING_DATA_REDIS_PASSWORD: "RedisAuthToken456!"
  SPRING_MAIL_PASSWORD: "emailpassword"

EXTERNAL SECRETS OPERATOR (MEILLEURE PRATIQUE) :
──────────────────────────────────────────────────

# Synchronise automatiquement depuis AWS Secrets Manager / GCP Secret Manager
# vers des Secrets K8s

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: taskflow-external-secret
  namespace: taskflow-prod
spec:
  refreshInterval: 1h  # Rafraîchit le secret toutes les heures

  secretStoreRef:
    name: aws-secrets-manager
    kind: ClusterSecretStore

  target:
    name: taskflow-secrets
    creationPolicy: Owner

  data:
    - secretKey: SPRING_DATASOURCE_PASSWORD
      remoteRef:
        key: taskflow/prod
        property: db.password

    - secretKey: APPLICATION_SECURITY_JWT_SECRET
      remoteRef:
        key: taskflow/prod
        property: jwt.secret

---
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: aws-secrets-manager
spec:
  provider:
    aws:
      service: SecretsManager
      region: eu-west-1
      auth:
        jwt:
          serviceAccountRef:
            name: external-secrets-sa
            namespace: external-secrets

─────────────────────────────────────────────────────────────────────
45.4 HEALTH PROBES — CONFIGURATION SPRING BOOT
─────────────────────────────────────────────────────────────────────

Spring Boot Actuator expose automatiquement les endpoints de santé
utilisés par Kubernetes.

CONFIGURATION SPRING BOOT (K8s profile) :
──────────────────────────────────────────

# application-k8s.properties

# Activer les groupes de santé K8s
management.endpoint.health.probes.enabled=true
management.health.livenessState.enabled=true
management.health.readinessState.enabled=true

# Endpoints exposés
management.endpoints.web.exposure.include=health,info,metrics,prometheus
management.endpoint.health.show-details=when_authorized

# Endpoints spécifiques K8s
# GET /actuator/health/liveness  -> Liveness probe
# GET /actuator/health/readiness -> Readiness probe

INDICATEURS DE SANTÉ PERSONNALISÉS :
──────────────────────────────────────

package com.taskflow.backend.health;

import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;

/**
 * Indicateur de santé custom — vérifie la connectivité à la BDD
 * Ce composant est automatiquement intégré à /actuator/health
 */
@Component("database")
@RequiredArgsConstructor
public class DatabaseHealthIndicator implements HealthIndicator {

    private final DataSource dataSource;

    @Override
    public Health health() {
        try (Connection conn = dataSource.getConnection()) {
            // Test de connectivité
            boolean valid = conn.isValid(3);  // timeout 3s

            if (valid) {
                // Infos supplémentaires dans la réponse
                return Health.up()
                    .withDetail("database", "PostgreSQL")
                    .withDetail("maxPoolSize", getMaxPoolSize())
                    .withDetail("activeConnections", getActiveConnections())
                    .build();
            } else {
                return Health.down()
                    .withDetail("error", "Connection validation failed")
                    .build();
            }
        } catch (Exception e) {
            return Health.down()
                .withDetail("error", e.getMessage())
                .build();
        }
    }
}

/**
 * Indicateur de Readiness — l'app est prête quand les migrations Flyway
 * sont terminées et que les caches sont chauds
 */
@Component
@RequiredArgsConstructor
public class TaskFlowReadinessIndicator implements ApplicationListener<ApplicationReadyEvent> {

    private volatile boolean ready = false;

    @EventListener
    public void onApplicationReady(ApplicationReadyEvent event) {
        this.ready = true;
    }

    @Bean
    public HealthIndicator readinessIndicator() {
        return () -> ready
            ? Health.up().withDetail("status", "Application initialized").build()
            : Health.down().withDetail("status", "Still initializing").build();
    }
}

─────────────────────────────────────────────────────────────────────
45.5 HELM CHARTS POUR TASKFLOW
─────────────────────────────────────────────────────────────────────

Helm est le gestionnaire de packages pour Kubernetes.

STRUCTURE DU CHART HELM :
──────────────────────────

helm-charts/
├── Chart.yaml
├── values.yaml
├── values-staging.yaml
├── values-prod.yaml
└── templates/
    ├── deployment.yaml
    ├── service.yaml
    ├── ingress.yaml
    ├── configmap.yaml
    ├── hpa.yaml
    ├── serviceaccount.yaml
    ├── pdb.yaml            <- PodDisruptionBudget
    └── _helpers.tpl

FICHIER : helm-charts/Chart.yaml
──────────────────────────────────

apiVersion: v2
name: taskflow-api
description: TaskFlow Backend API — Spring Boot application
type: application
version: 1.0.0
appVersion: "1.0.0"

maintainers:
  - name: TaskFlow Team
    email: devops@taskflow.io

dependencies:
  - name: postgresql
    version: "15.5.x"
    repository: https://charts.bitnami.com/bitnami
    condition: postgresql.enabled
  - name: redis
    version: "19.x.x"
    repository: https://charts.bitnami.com/bitnami
    condition: redis.enabled

FICHIER : helm-charts/values.yaml
───────────────────────────────────

# Valeurs par défaut (surchargeables par values-prod.yaml)

replicaCount: 2

image:
  repository: gcr.io/taskflow-project/taskflow-api
  pullPolicy: Always
  tag: "latest"  # Surchargé lors du déploiement

imagePullSecrets:
  - name: gcr-credentials

nameOverride: ""
fullnameOverride: ""

serviceAccount:
  create: true
  annotations: {}
  name: ""

podAnnotations:
  prometheus.io/scrape: "true"
  prometheus.io/path: "/actuator/prometheus"
  prometheus.io/port: "8080"

podSecurityContext:
  runAsNonRoot: true
  runAsUser: 1000
  fsGroup: 1000

securityContext:
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop:
      - ALL

service:
  type: ClusterIP
  port: 80
  targetPort: 8080

ingress:
  enabled: true
  className: "nginx"
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
  hosts:
    - host: api.taskflow.io
      paths:
        - path: /
          pathType: Prefix
  tls:
    - secretName: taskflow-tls
      hosts:
        - api.taskflow.io

resources:
  requests:
    cpu: 250m
    memory: 512Mi
  limits:
    cpu: 1000m
    memory: 1024Mi

autoscaling:
  enabled: true
  minReplicas: 2
  maxReplicas: 10
  targetCPUUtilizationPercentage: 70
  targetMemoryUtilizationPercentage: 80

spring:
  profiles: prod,k8s
  datasource:
    url: ""  # À surcharger
    hikari:
      maximumPoolSize: 20

postgresql:
  enabled: false  # En prod, utiliser RDS externe

redis:
  enabled: false  # En prod, utiliser ElastiCache externe

FICHIER : helm-charts/values-prod.yaml
────────────────────────────────────────

replicaCount: 3

image:
  repository: gcr.io/taskflow-project/taskflow-api
  tag: "1.2.3"  # Version fixée en production

resources:
  requests:
    cpu: 500m
    memory: 768Mi
  limits:
    cpu: 2000m
    memory: 2048Mi

autoscaling:
  minReplicas: 3
  maxReplicas: 20

spring:
  datasource:
    url: "jdbc:postgresql://taskflow.cluster-xxxx.eu-west-1.rds.amazonaws.com:5432/taskflow_prod"

FICHIER : helm-charts/templates/hpa.yaml
──────────────────────────────────────────

{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: {{ include "taskflow-api.fullname" . }}
  namespace: {{ .Release.Namespace }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ include "taskflow-api.fullname" . }}
  minReplicas: {{ .Values.autoscaling.minReplicas }}
  maxReplicas: {{ .Values.autoscaling.maxReplicas }}
  metrics:
    {{- if .Values.autoscaling.targetCPUUtilizationPercentage }}
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
    {{- end }}
    {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }}
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
    {{- end }}
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300  # Attendre 5min avant de scale down
      policies:
        - type: Pods
          value: 1
          periodSeconds: 120
    scaleUp:
      stabilizationWindowSeconds: 60
      policies:
        - type: Pods
          value: 2
          periodSeconds: 60
{{- end }}

FICHIER : helm-charts/templates/pdb.yaml
──────────────────────────────────────────

# PodDisruptionBudget : garantit la disponibilité lors des maintenance K8s
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: {{ include "taskflow-api.fullname" . }}-pdb
  namespace: {{ .Release.Namespace }}
spec:
  minAvailable: 2  # Toujours au moins 2 pods disponibles
  selector:
    matchLabels:
      {{- include "taskflow-api.selectorLabels" . | nindent 6 }}

─────────────────────────────────────────────────────────────────────
45.6 DÉPLOIEMENT SUR GKE (GOOGLE KUBERNETES ENGINE)
─────────────────────────────────────────────────────────────────────

CREATION DU CLUSTER GKE :
──────────────────────────

# Créer le cluster GKE avec les meilleures pratiques de sécurité
gcloud container clusters create taskflow-cluster \
  --project taskflow-project \
  --region europe-west1 \
  --release-channel stable \
  --machine-type n2-standard-2 \
  --num-nodes 3 \
  --min-nodes 2 \
  --max-nodes 10 \
  --enable-autoscaling \
  --enable-autorepair \
  --enable-autoupgrade \
  --enable-ip-alias \
  --enable-network-policy \
  --enable-shielded-nodes \
  --workload-pool=taskflow-project.svc.id.goog \
  --enable-private-nodes \
  --master-ipv4-cidr 172.16.0.0/28 \
  --enable-master-authorized-networks \
  --master-authorized-networks 203.0.113.0/32

# Configurer kubectl
gcloud container clusters get-credentials taskflow-cluster \
  --region europe-west1 \
  --project taskflow-project

WORKLOAD IDENTITY (IAM SANS CLÉS DE SERVICE) :
────────────────────────────────────────────────

# Workload Identity permet aux Pods d'accéder aux API GCP
# sans stocker de clés de service (k8s secret)

# 1. Créer un Service Account GCP
gcloud iam service-accounts create taskflow-api \
  --display-name="TaskFlow API Service Account"

# 2. Donner les permissions nécessaires
gcloud projects add-iam-policy-binding taskflow-project \
  --member="serviceAccount:taskflow-api@taskflow-project.iam.gserviceaccount.com" \
  --role="roles/secretmanager.secretAccessor"

gcloud projects add-iam-policy-binding taskflow-project \
  --member="serviceAccount:taskflow-api@taskflow-project.iam.gserviceaccount.com" \
  --role="roles/cloudsql.client"

# 3. Lier le Service Account K8s au GCP SA
gcloud iam service-accounts add-iam-policy-binding \
  taskflow-api@taskflow-project.iam.gserviceaccount.com \
  --role="roles/iam.workloadIdentityUser" \
  --member="serviceAccount:taskflow-project.svc.id.goog[taskflow-prod/taskflow-api]"

# 4. Annoter le Service Account K8s
kubectl annotate serviceaccount taskflow-api \
  --namespace taskflow-prod \
  iam.gke.io/gcp-service-account=taskflow-api@taskflow-project.iam.gserviceaccount.com

PIPELINE GITHUB ACTIONS -> GKE :
─────────────────────────────────

# .github/workflows/deploy-gke.yml

name: Deploy to GKE

on:
  push:
    branches: [main]
    tags: ['v*.*.*']

env:
  PROJECT_ID: taskflow-project
  GKE_CLUSTER: taskflow-cluster
  GKE_REGION: europe-west1
  IMAGE: gcr.io/taskflow-project/taskflow-api

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    environment: production

    permissions:
      contents: read
      id-token: write   # Pour Workload Identity Federation

    steps:
      - uses: actions/checkout@v4

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: 'projects/123/locations/global/workloadIdentityPools/github/providers/github'
          service_account: 'github-actions@taskflow-project.iam.gserviceaccount.com'

      - name: Setup gcloud
        uses: google-github-actions/setup-gcloud@v2

      - name: Configure Docker
        run: gcloud auth configure-docker --quiet

      - name: Build and push Docker image
        run: |
          IMAGE_TAG="${{ env.IMAGE }}:${{ github.sha }}"
          docker build \
            --target runtime \
            --tag "$IMAGE_TAG" \
            --tag "${{ env.IMAGE }}:latest" \
            --cache-from "${{ env.IMAGE }}:latest" \
            .
          docker push "$IMAGE_TAG"
          docker push "${{ env.IMAGE }}:latest"
          echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV

      - name: Get GKE credentials
        uses: google-github-actions/get-gke-credentials@v2
        with:
          cluster_name: ${{ env.GKE_CLUSTER }}
          location: ${{ env.GKE_REGION }}

      - name: Deploy with Helm
        run: |
          helm upgrade --install taskflow-api ./helm-charts \
            --namespace taskflow-prod \
            --create-namespace \
            --values helm-charts/values-prod.yaml \
            --set image.tag="${{ github.sha }}" \
            --set image.repository="${{ env.IMAGE }}" \
            --atomic \
            --timeout 10m \
            --wait

      - name: Verify deployment
        run: |
          kubectl rollout status deployment/taskflow-api \
            --namespace taskflow-prod \
            --timeout=5m

          # Smoke test
          INGRESS_IP=$(kubectl get ingress taskflow-ingress \
            --namespace taskflow-prod \
            -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
          
          curl -f -H "Host: api.taskflow.io" \
            "http://$INGRESS_IP/actuator/health" || exit 1
          
          echo "[OK] Deployment successful!"

      - name: Rollback on failure
        if: failure()
        run: |
          helm rollback taskflow-api --namespace taskflow-prod
          echo "[X] Deployment failed — rolled back!"

─────────────────────────────────────────────────────────────────────
45.7 ROLLING UPDATES & ROLLBACKS
─────────────────────────────────────────────────────────────────────

COMMANDES KUBECTL ESSENTIELLES :
──────────────────────────────────

# Voir l'historique des déploiements
kubectl rollout history deployment/taskflow-api -n taskflow-prod

# Détail d'une révision spécifique
kubectl rollout history deployment/taskflow-api \
  --revision=3 -n taskflow-prod

# Rollback à la révision précédente
kubectl rollout undo deployment/taskflow-api -n taskflow-prod

# Rollback à une révision spécifique
kubectl rollout undo deployment/taskflow-api \
  --to-revision=2 -n taskflow-prod

# Surveiller le déploiement en temps réel
kubectl rollout status deployment/taskflow-api \
  -n taskflow-prod --watch

# Forcer le redéploiement (même image — force redémarrage)
kubectl rollout restart deployment/taskflow-api -n taskflow-prod

# Mettre en pause un déploiement (canary manuel)
kubectl rollout pause deployment/taskflow-api -n taskflow-prod

# Reprendre
kubectl rollout resume deployment/taskflow-api -n taskflow-prod

CANARY DEPLOYMENT AVEC NGINX INGRESS :
────────────────────────────────────────

# Déployer la version canary (5% du trafic)
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: taskflow-ingress-canary
  namespace: taskflow-prod
  annotations:
    kubernetes.io/ingress.class: nginx
    nginx.ingress.kubernetes.io/canary: "true"
    nginx.ingress.kubernetes.io/canary-weight: "5"  # 5% du trafic
spec:
  rules:
    - host: api.taskflow.io
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: taskflow-api-canary
                port:
                  number: 80

─────────────────────────────────────────────────────────────────────
BONNES PRATIQUES — CLOUD & KUBERNETES
─────────────────────────────────────────────────────────────────────

1. RESOURCE LIMITS TOUJOURS DÉFINIS
   -> Sans limits, un Pod peut consommer toute la RAM du nœud
   -> Règle : limits = 2x requests (pour absorber les pics)

2. NEVER STORE SECRETS IN GIT
   -> Utiliser External Secrets Operator, Sealed Secrets, ou Vault
   -> Les secrets K8s en base64 ne sont PAS chiffrés

3. HEALTH PROBES CORRECTEMENT CONFIGURÉES
   -> startupProbe : évite les redémarrages intempestifs au démarrage
   -> readinessProbe : retire du LB pendant les redémarrages/updates
   -> livenessProbe : redémarre si l'app est bloquée

4. PODANTI-AFFINITY
   -> Répartir les Pods sur différents nœuds pour la HA

5. PODDISRUPTIONBUDGET
   -> Garantir qu'au moins N pods restent disponibles pendant les maintenances

6. REQUESTS/LIMITS CORRECTES
   -> CPU request trop haute = nœuds sous-utilisés
   -> Memory limit trop basse = OOMKilled en prod

7. GRACEFUL SHUTDOWN
   -> terminationGracePeriodSeconds >= preStop hook duration + délai de déregistration LB
   -> Spring Boot : server.shutdown=graceful

8. IMAGE TAGS FIXES EN PRODUCTION
   -> Jamais :latest en production (impossible de rollback)
   -> Toujours le SHA du commit ou un tag sémantique

─────────────────────────────────────────────────────────────────────
ERREURS FRÉQUENTES
─────────────────────────────────────────────────────────────────────

[X] ERREUR 1 : CrashLoopBackOff
   Symptôme : Le Pod redémarre en boucle
   Causes courantes :
     - Secret ou ConfigMap manquant (env var non définie)
     - Port déjà utilisé
     - Erreur de démarrage Spring Boot (regarder les logs !)
   Diagnostic : kubectl describe pod <pod> -n <ns>
                kubectl logs <pod> -n <ns> --previous

[X] ERREUR 2 : Pending Pods
   Symptôme : Les Pods restent en état Pending
   Causes courantes :
     - Ressources insuffisantes sur les nœuds (CPU/RAM)
     - PVC non bound (stockage non disponible)
     - Affinité/taints impossibles à satisfaire
   Diagnostic : kubectl describe pod <pod> -> section Events

[X] ERREUR 3 : ImagePullBackOff
   Symptôme : Impossible de télécharger l'image Docker
   Causes courantes :
     - Tag inexistant
     - Credentials de registry incorrects (imagePullSecret)
     - Image privée sans credential
   Solution : vérifier que le Secret de registry est correct

[X] ERREUR 4 : OOMKilled
   Symptôme : Le container est tué par le nœud
   Cause : memory limit dépassée
   Solution : augmenter la memory limit ou fixer la fuite mémoire
   Diagnostic : kubectl describe pod -> "OOMKilled"

[X] ERREUR 5 : 503 depuis l'Ingress
   Symptôme : Requêtes retournent 503 intermittents
   Causes courantes :
     - readinessProbe échoue -> Pod retiré du Service
     - Tous les Pods en rolling update simultanément
     - Connection draining trop court

─────────────────────────────────────────────────────────────────────
EXERCICES
─────────────────────────────────────────────────────────────────────

NIVEAU DÉBUTANT :
  1. Déployer TaskFlow sur un cluster Minikube local.
     Créer le Deployment, Service et un Ingress basique.
     Vérifier avec kubectl get all -n taskflow-dev.

  2. Créer un ConfigMap contenant les variables d'environnement
     Spring Boot (profil, port, URL DB) et l'utiliser dans
     le Deployment avec envFrom.

  3. Ajouter un HorizontalPodAutoscaler qui scale entre 2 et 5
     replicas quand le CPU dépasse 60%. Simuler la charge avec
     kubectl run -it --rm load-generator --image=busybox.

NIVEAU INTERMÉDIAIRE :
  1. Configurer les 3 health probes (liveness, readiness, startup)
     avec des délais réalistes pour un démarrage Spring Boot de 45s.
     Vérifier qu'un Pod en panne est bien retiré du trafic.

  2. Implémenter un déploiement canary : 10% du trafic vers v2
     via les annotations Nginx Ingress. Vérifier dans les logs
     la répartition du trafic.

  3. Créer un Helm Chart minimal pour TaskFlow avec les fichiers
     deployment.yaml, service.yaml et values.yaml. Utiliser
     helm template pour valider le rendu YAML.

NIVEAU AVANCÉ :
  1. Configurer Workload Identity sur GKE pour accéder à
     Google Secret Manager sans clés de service. L'application
     doit lire le secret JWT au démarrage.

  2. Mettre en place une stratégie de déploiement Blue/Green
     complet : deux Deployments (blue et green), un seul Service
     qui bascule le trafic, et un script de validation automatique
     avant bascule.

  3. Configurer cluster autoscaling + VPA (Vertical Pod Autoscaler)
     pour ajuster automatiquement les resource requests des Pods
     TaskFlow en fonction de l'utilisation réelle.

================================================================================
RÉSUMÉ PARTIE 15
================================================================================

CHAPITRE 44 — AWS :
  [OK] Architecture AWS complète (VPC, ALB, ECS, RDS, ElastiCache, CloudWatch)
  [OK] Terraform Infrastructure as Code (modules VPC, SG, RDS, Redis, ALB)
  [OK] Elastic Beanstalk avec configuration .ebextensions et Nginx
  [OK] ECS Fargate : Task Definition, Service, Auto Scaling (CPU + Memory)
  [OK] AWS Secrets Manager intégration avec Spring Cloud AWS
  [OK] CloudWatch Logs (logback-awslogs-appender), Metric Filters, Alarms
  [OK] Dashboard CloudWatch pour monitoring applicatif

CHAPITRE 45 — KUBERNETES :
  [OK] Manifestes complets : Deployment, Service, Ingress (SSL, rate limiting)
  [OK] ConfigMap + Secrets (+ External Secrets Operator pour la production)
  [OK] Health Probes : liveness, readiness, startup bien configurées
  [OK] PodDisruptionBudget pour la haute disponibilité
  [OK] Helm Charts complets : Chart.yaml, values.yaml, templates, PDB, HPA
  [OK] HPA v2 avec CPU + Memory + behavior (scaleUp/scaleDown cooldowns)
  [OK] GKE : création cluster, Workload Identity, pipeline GitHub Actions
  [OK] Rolling Updates + Canary Deployment avec Nginx Ingress
  [OK] Bonnes pratiques prod (resource limits, no :latest, graceful shutdown)
  [OK] Diagnostic CrashLoopBackOff / Pending / OOMKilled

================================================================================
FIN PARTIE 15
Prochaine partie -> Logging avancé & Monitoring (Logback, ELK, Prometheus, Grafana)
================================================================================

================================================================================
GUIDE SPRING BOOT ENTREPRISE — PARTIE 16
LOGGING AVANCÉ & MONITORING (LOGBACK, ELK, PROMETHEUS, GRAFANA)
Chapitres 46-47
Projet : TaskFlow Backend
================================================================================

TABLE DES MATIÈRES — PARTIE 16
────────────────────────────────
Chapitre 46 : Logging professionnel avec Logback
  46.1  Niveaux de log et stratégie
  46.2  Logback — Configuration avancée
  46.3  Logs structurés JSON (Logstash Encoder)
  46.4  MDC (Mapped Diagnostic Context) — Traçabilité des requêtes
  46.5  Corrélation de logs (Micrometer Tracing / Zipkin)
  46.6  Log rotation et archivage
  46.7  Elk Stack (Elasticsearch + Logstash + Kibana)

Chapitre 47 : Monitoring avec Prometheus & Grafana
  47.1  Spring Boot Actuator + Micrometer
  47.2  Métriques personnalisées (Counter, Gauge, Timer, DistributionSummary)
  47.3  Prometheus — Scraping et configuration
  47.4  Grafana — Dashboards et alertes
  47.5  Distributed Tracing (OpenTelemetry)
  47.6  Health Dashboard complet
  47.7  Alertmanager — Notifications (Slack, PagerDuty, email)

================================================================================
CHAPITRE 46 : LOGGING PROFESSIONNEL
================================================================================

46.1 STRATÉGIE DE LOGGING
───────────────────────────

NIVEAUX DE LOG (du plus verbeux au plus critique) :
────────────────────────────────────────────────────

  TRACE  -> Détails fins d'exécution (jamais en production)
  DEBUG  -> Informations de débogage (développement uniquement)
  INFO   -> Événements importants du cycle de vie (démarrage, requêtes, etc.)
  WARN   -> Situations anormales mais récupérables
  ERROR  -> Erreurs qui affectent l'opération courante
  FATAL  -> Erreurs critiques qui stoppent l'application (rare avec Spring)

RÈGLE D'OR DU LOGGING EN PRODUCTION :

  Niveau recommandé par package en production :
  ┌─────────────────────────────────────┬───────┐
  │ Package                             │ Niveau│
  ├─────────────────────────────────────┼───────┤
  │ com.taskflow (votre code)           │ INFO  │
  │ org.springframework.web             │ WARN  │
  │ org.springframework.security        │ WARN  │
  │ org.hibernate.SQL                   │ WARN  │
  │ org.hibernate.type.descriptor.sql  │ WARN  │
  │ com.zaxxer.hikari                   │ INFO  │
  │ org.flywaydb                        │ INFO  │
  └─────────────────────────────────────┴───────┘

CE QU'ON DOIT LOGGER ET CE QU'ON NE DOIT PAS :

  [OK] LOGGER :
    - Démarrage et arrêt de l'application
    - Connexions / déconnexions importantes
    - Erreurs et exceptions (avec contexte)
    - Actions métier importantes (création d'une tâche, assignation)
    - Requêtes externes lentes
    - Changements de configuration

  [X] NE PAS LOGGER :
    - Mots de passe, tokens JWT, clés API (même en DEBUG !)
    - Données personnelles (email, nom, numéro de carte)
    - Résultats de requêtes contenant des données sensibles
    - Informations en boucle tight (dans un for à haute fréquence)

─────────────────────────────────────────────────────────────────────
46.2 LOGBACK — CONFIGURATION AVANCÉE
─────────────────────────────────────────────────────────────────────

DÉPENDANCES :
──────────────

<!-- logstash-logback-encoder pour les logs JSON -->
<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>7.4</version>
</dependency>

FICHIER : src/main/resources/logback-spring.xml
─────────────────────────────────────────────────

<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="30 seconds">

    <!-- Import des propriétés Spring Boot -->
    <springProperty scope="context" name="appName"
                    source="spring.application.name" defaultValue="taskflow-api"/>
    <springProperty scope="context" name="appVersion"
                    source="info.app.version" defaultValue="unknown"/>
    <springProperty scope="context" name="activeProfile"
                    source="spring.profiles.active" defaultValue="dev"/>

    <!-- ─── Propriétés de configuration ─────────────────────────── -->
    <property name="LOG_PATH" value="${LOG_PATH:-/var/log/taskflow}"/>
    <property name="MAX_FILE_SIZE" value="100MB"/>
    <property name="MAX_HISTORY" value="30"/>
    <property name="TOTAL_SIZE_CAP" value="3GB"/>

    <!-- ─── Appender Console (développement) ────────────────────── -->
    <springProfile name="!prod">
        <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
            <encoder>
                <!-- Format coloré et lisible en développement -->
                <pattern>
                    %clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint}
                    %clr(%5p) %clr(${PID:-}){magenta}
                    %clr(---){faint} %clr([%15.15t]){faint}
                    %clr(%-40.40logger{39}){cyan}
                    %clr(:){faint} %m
                    %clr([traceId=%X{traceId:-none}, requestId=%X{requestId:-none}]){faint}
                    %n%throwable
                </pattern>
                <charset>UTF-8</charset>
            </encoder>
        </appender>
    </springProfile>

    <!-- ─── Appender Console JSON (production) ──────────────────── -->
    <springProfile name="prod">
        <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
            <encoder class="net.logstash.logback.encoder.LogstashEncoder">
                <!-- Champs de base enrichis -->
                <customFields>{"app":"${appName}","version":"${appVersion}","env":"${activeProfile}"}</customFields>
                <includeMdcKeyName>requestId</includeMdcKeyName>
                <includeMdcKeyName>userId</includeMdcKeyName>
                <includeMdcKeyName>userEmail</includeMdcKeyName>
                <includeMdcKeyName>traceId</includeMdcKeyName>
                <includeMdcKeyName>spanId</includeMdcKeyName>
                <includeMdcKeyName>projectUuid</includeMdcKeyName>
                <!-- Ne pas inclure les données sensibles -->
                <excludeMdcKeyName>password</excludeMdcKeyName>
                <excludeMdcKeyName>token</excludeMdcKeyName>
                <throwableConverter class="net.logstash.logback.stacktrace.ShortenedThrowableConverter">
                    <maxDepthPerThrowable>10</maxDepthPerThrowable>
                    <maxLength>2048</maxLength>
                    <rootCauseFirst>true</rootCauseFirst>
                </throwableConverter>
            </encoder>
        </appender>
    </springProfile>

    <!-- ─── Appender Fichier avec rotation ──────────────────────── -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_PATH}/application.log</file>
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <customFields>{"app":"${appName}","version":"${appVersion}"}</customFields>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <!-- Rotation quotidienne + par taille -->
            <fileNamePattern>${LOG_PATH}/archived/application.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>${MAX_FILE_SIZE}</maxFileSize>
            <maxHistory>${MAX_HISTORY}</maxHistory>
            <totalSizeCap>${TOTAL_SIZE_CAP}</totalSizeCap>
            <cleanHistoryOnStart>true</cleanHistoryOnStart>
        </rollingPolicy>
    </appender>

    <!-- ─── Appender Fichier Erreurs séparé ─────────────────────── -->
    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_PATH}/error.log</file>
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <level>ERROR</level>
            <onMatch>ACCEPT</onMatch>
            <onMismatch>DENY</onMismatch>
        </filter>
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <customFields>{"app":"${appName}","severity":"error"}</customFields>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>${LOG_PATH}/archived/error.%d{yyyy-MM-dd}.log.gz</fileNamePattern>
            <maxHistory>60</maxHistory>  <!-- Garder 60 jours pour les erreurs -->
        </rollingPolicy>
    </appender>

    <!-- ─── Appender Asynchrone (performance) ───────────────────── -->
    <appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
        <appender-ref ref="FILE"/>
        <queueSize>1024</queueSize>
        <discardingThreshold>0</discardingThreshold>  <!-- Ne jamais discarder -->
        <neverBlock>false</neverBlock>
        <includeCallerData>false</includeCallerData>  <!-- Performance -->
    </appender>

    <!-- ─── Loggers Spécifiques ──────────────────────────────────── -->

    <!-- Logger d'audit pour les actions sensibles -->
    <appender name="AUDIT_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_PATH}/audit.log</file>
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <customFields>{"app":"${appName}","log_type":"audit"}</customFields>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <fileNamePattern>${LOG_PATH}/archived/audit.%d{yyyy-MM-dd}.log.gz</fileNamePattern>
            <maxHistory>365</maxHistory>  <!-- Audit conservé 1 an -->
        </rollingPolicy>
    </appender>

    <logger name="com.taskflow.backend.audit" level="INFO" additivity="false">
        <appender-ref ref="AUDIT_FILE"/>
        <appender-ref ref="CONSOLE"/>
    </logger>

    <!-- Logger SQL pour développement -->
    <springProfile name="!prod">
        <logger name="org.hibernate.SQL" level="DEBUG" additivity="false">
            <appender-ref ref="CONSOLE"/>
        </logger>
        <logger name="org.hibernate.type.descriptor.sql.BasicBinder" level="TRACE" additivity="false">
            <appender-ref ref="CONSOLE"/>
        </logger>
    </springProfile>

    <!-- ─── Root Logger ──────────────────────────────────────────── -->
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="ASYNC_FILE"/>
        <appender-ref ref="ERROR_FILE"/>
    </root>

    <!-- Réduire le bruit des librairies tierces -->
    <logger name="org.springframework" level="WARN"/>
    <logger name="org.apache" level="WARN"/>
    <logger name="com.zaxxer.hikari" level="INFO"/>
    <logger name="org.flywaydb" level="INFO"/>
    <logger name="io.lettuce" level="WARN"/>

    <!-- Logs de sécurité en DEBUG pour audit (dev seulement) -->
    <springProfile name="dev">
        <logger name="org.springframework.security" level="DEBUG"/>
    </springProfile>

</configuration>

─────────────────────────────────────────────────────────────────────
46.3 MDC — TRAÇABILITÉ DES REQUÊTES
─────────────────────────────────────────────────────────────────────

Le MDC (Mapped Diagnostic Context) permet d'ajouter des données
contextuelles à TOUS les logs d'une même requête HTTP.

FILTRE MDC — INJECTE LES DONNÉES DANS CHAQUE REQUÊTE :
────────────────────────────────────────────────────────

package com.taskflow.backend.logging;

import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.core.annotation.Order;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;

/**
 * Filtre MDC — enrichit les logs avec les données contextuelles
 * de chaque requête HTTP.
 *
 * Données ajoutées au MDC :
 *   - requestId   : UUID unique de la requête (pour corréler les logs)
 *   - traceId     : ID de trace distribué (Micrometer Tracing)
 *   - userId      : UUID de l'utilisateur connecté
 *   - userEmail   : Email de l'utilisateur
 *   - method      : HTTP method (GET, POST, etc.)
 *   - path        : URI de la requête
 *   - ip          : IP du client
 *   - userAgent   : User-Agent du client
 */
@Slf4j
@Component
@Order(1)  // Exécuté avant les autres filtres
public class MdcLoggingFilter extends OncePerRequestFilter {

    // Header standard pour l'ID de corrélation (utilisé par les load balancers)
    private static final String CORRELATION_HEADER = "X-Correlation-ID";
    private static final String REQUEST_ID_HEADER  = "X-Request-ID";

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        long startTime = System.currentTimeMillis();

        try {
            // 1. Générer ou récupérer l'ID de corrélation
            String correlationId = request.getHeader(CORRELATION_HEADER);
            if (correlationId == null || correlationId.isBlank()) {
                correlationId = UUID.randomUUID().toString();
            }
            String requestId = UUID.randomUUID().toString();

            // 2. Peupler le MDC
            MDC.put("requestId",   requestId);
            MDC.put("traceId",     correlationId);
            MDC.put("method",      request.getMethod());
            MDC.put("path",        request.getRequestURI());
            MDC.put("ip",          getClientIp(request));
            MDC.put("userAgent",   truncate(request.getHeader("User-Agent"), 100));

            // 3. Ajouter l'ID de corrélation dans la réponse
            response.setHeader(CORRELATION_HEADER, correlationId);
            response.setHeader(REQUEST_ID_HEADER, requestId);

            // 4. Log de début de requête
            log.info("-> {} {} [correlationId={}]",
                request.getMethod(), request.getRequestURI(), correlationId);

            // 5. Exécuter la chaîne de filtres
            filterChain.doFilter(request, response);

            // 6. Log de fin de requête avec durée et statut
            long duration = System.currentTimeMillis() - startTime;
            MDC.put("duration", String.valueOf(duration));
            MDC.put("status", String.valueOf(response.getStatus()));

            if (duration > 2000) {
                log.warn("<- SLOW {} {} [{}ms] [status={}]",
                    request.getMethod(), request.getRequestURI(),
                    duration, response.getStatus());
            } else {
                log.info("<- {} {} [{}ms] [status={}]",
                    request.getMethod(), request.getRequestURI(),
                    duration, response.getStatus());
            }

        } finally {
            // CRUCIAL : toujours nettoyer le MDC pour éviter les fuites de données
            // entre les threads du thread pool Tomcat
            MDC.clear();
        }
    }

    /**
     * Enrichit le MDC avec les infos de l'utilisateur authentifié.
     * Appelé après l'authentification JWT par JwtAuthenticationFilter.
     */
    public static void enrichWithUser(UserPrincipal principal) {
        if (principal != null) {
            MDC.put("userId",    principal.getUuid().toString());
            MDC.put("userEmail", principal.getEmail());
            MDC.put("userRole",  principal.getRole().name());
        }
    }

    private String getClientIp(HttpServletRequest request) {
        // Prend en compte les reverse proxies (X-Forwarded-For)
        String forwarded = request.getHeader("X-Forwarded-For");
        if (forwarded != null && !forwarded.isBlank()) {
            return forwarded.split(",")[0].trim();
        }
        String realIp = request.getHeader("X-Real-IP");
        if (realIp != null && !realIp.isBlank()) {
            return realIp;
        }
        return request.getRemoteAddr();
    }

    private String truncate(String value, int maxLength) {
        if (value == null) return "unknown";
        return value.length() > maxLength ? value.substring(0, maxLength) + "..." : value;
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        // Ne pas logger les health checks (trop de bruit)
        String path = request.getRequestURI();
        return path.startsWith("/actuator/health") || path.equals("/favicon.ico");
    }
}

UTILISATION DU MDC DANS LES SERVICES :
────────────────────────────────────────

package com.taskflow.backend.service;

@Slf4j
@Service
@RequiredArgsConstructor
public class TaskServiceImpl implements TaskService {

    @Override
    @Transactional
    public TaskResponse createTask(CreateTaskRequest request, UUID projectUuid) {
        // Enrichir le MDC avec les données métier
        MDC.put("projectUuid", projectUuid.toString());
        MDC.put("action", "createTask");

        try {
            log.info("Creating task '{}' in project {}", request.getTitle(), projectUuid);

            Task task = buildTask(request, project);
            task = taskRepository.save(task);

            log.info("Task created successfully [taskUuid={}]", task.getUuid());

            return taskMapper.toResponse(task);

        } catch (Exception e) {
            log.error("Failed to create task [projectUuid={}] [error={}]",
                projectUuid, e.getMessage(), e);
            throw e;
        } finally {
            MDC.remove("projectUuid");
            MDC.remove("action");
        }
    }
}

SERVICE D'AUDIT :
──────────────────

package com.taskflow.backend.audit;

/**
 * Service d'audit — log toutes les actions sensibles dans un fichier dédié.
 * Ces logs doivent être conservés pour conformité réglementaire.
 */
@Slf4j(topic = "com.taskflow.backend.audit")
@Service
@RequiredArgsConstructor
public class AuditService {

    // Utilise un logger séparé -> va dans audit.log (voir logback-spring.xml)
    private static final Logger AUDIT_LOGGER =
        LoggerFactory.getLogger("com.taskflow.backend.audit");

    public void logAction(AuditAction action, String resourceType,
                          String resourceId, String details) {
        // Structuré pour indexation Elasticsearch
        AUDIT_LOGGER.info("AUDIT_EVENT",
            StructuredArguments.keyValue("action", action.name()),
            StructuredArguments.keyValue("resourceType", resourceType),
            StructuredArguments.keyValue("resourceId", resourceId),
            StructuredArguments.keyValue("userId", MDC.get("userId")),
            StructuredArguments.keyValue("userEmail", MDC.get("userEmail")),
            StructuredArguments.keyValue("ip", MDC.get("ip")),
            StructuredArguments.keyValue("details", details),
            StructuredArguments.keyValue("timestamp", Instant.now().toString())
        );
    }

    public enum AuditAction {
        USER_LOGIN, USER_LOGOUT, USER_CREATED, USER_DELETED,
        PASSWORD_CHANGED, TASK_CREATED, TASK_DELETED,
        PROJECT_CREATED, PROJECT_MEMBER_ADDED, ROLE_CHANGED,
        ADMIN_ACTION
    }
}

─────────────────────────────────────────────────────────────────────
46.4 ELK STACK — CENTRALISATION DES LOGS
─────────────────────────────────────────────────────────────────────

L'ELK Stack (Elasticsearch + Logstash + Kibana) centralise tous
les logs de tous les services en un seul endroit.

SCHÉMA ELK :

  [Spring Boot App] -> (JSON logs) -> [Filebeat]
                                         v
                                    [Logstash]  <- parsing, enrichissement
                                         v
                                 [Elasticsearch] <- stockage + indexation
                                         ^
                                      [Kibana]  <- visualisation + alertes

DOCKER-COMPOSE POUR L'ELK STACK :
────────────────────────────────────

# docker-compose-elk.yml

version: '3.8'

services:

  # ─── Elasticsearch ────────────────────────────────────────────────
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.12.0
    environment:
      - discovery.type=single-node
      - ES_JAVA_OPTS=-Xms1g -Xmx1g
      - xpack.security.enabled=false  # Désactivé pour dev local
    ports:
      - "9200:9200"
    volumes:
      - es_data:/usr/share/elasticsearch/data
    healthcheck:
      test: curl -s http://localhost:9200/_cluster/health | grep -q '"status":"green"'
      interval: 30s
      timeout: 10s
      retries: 5

  # ─── Logstash ─────────────────────────────────────────────────────
  logstash:
    image: docker.elastic.co/logstash/logstash:8.12.0
    ports:
      - "5044:5044"   # Beats input
      - "5000:5000"   # TCP input
      - "9600:9600"   # API
    volumes:
      - ./elk/logstash/pipeline:/usr/share/logstash/pipeline
    environment:
      LS_JAVA_OPTS: "-Xmx512m -Xms512m"
    depends_on:
      elasticsearch:
        condition: service_healthy

  # ─── Kibana ───────────────────────────────────────────────────────
  kibana:
    image: docker.elastic.co/kibana/kibana:8.12.0
    ports:
      - "5601:5601"
    environment:
      ELASTICSEARCH_HOSTS: http://elasticsearch:9200
    depends_on:
      elasticsearch:
        condition: service_healthy

  # ─── Filebeat ─────────────────────────────────────────────────────
  # Collecte les logs des fichiers et les envoie à Logstash
  filebeat:
    image: docker.elastic.co/beats/filebeat:8.12.0
    user: root
    volumes:
      - ./elk/filebeat/filebeat.yml:/usr/share/filebeat/filebeat.yml:ro
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
    depends_on:
      - logstash

volumes:
  es_data:

FICHIER : elk/logstash/pipeline/taskflow.conf
──────────────────────────────────────────────

input {
  beats {
    port => 5044
  }
  tcp {
    port => 5000
    codec => json_lines
  }
}

filter {
  # Parser le JSON de Logstash Encoder
  if [message] =~ /^\{/ {
    json {
      source => "message"
      target => "log_data"
    }

    # Extraire les champs importants
    mutate {
      rename => {
        "[log_data][level]"       => "level"
        "[log_data][logger_name]" => "logger"
        "[log_data][message]"     => "log_message"
        "[log_data][requestId]"   => "request_id"
        "[log_data][userId]"      => "user_id"
        "[log_data][traceId]"     => "trace_id"
        "[log_data][duration]"    => "duration_ms"
        "[log_data][status]"      => "http_status"
        "[log_data][path]"        => "http_path"
        "[log_data][method]"      => "http_method"
        "[log_data][app]"         => "service"
        "[log_data][version]"     => "app_version"
        "[log_data][env]"         => "environment"
      }
    }

    # Convertir la durée en numérique pour les requêtes Kibana
    if [duration_ms] {
      mutate {
        convert => { "duration_ms" => "integer" }
      }
    }
  }

  # Ajouter des tags selon le niveau
  if [level] == "ERROR" {
    mutate { add_tag => ["error"] }
  }

  if [duration_ms] and [duration_ms] > 1000 {
    mutate { add_tag => ["slow_request"] }
  }

  # Supprimer les champs redondants
  mutate {
    remove_field => ["log_data", "message", "host", "agent"]
  }

  # Geoip sur l'IP client (pour les dashboards géographiques)
  if [ip] and [ip] != "127.0.0.1" {
    geoip {
      source => "ip"
      target => "geoip"
    }
  }
}

output {
  elasticsearch {
    hosts => ["elasticsearch:9200"]
    index => "taskflow-%{environment}-%{+YYYY.MM.dd}"
    # ILM Policy pour la gestion du cycle de vie des indices
    ilm_enabled => true
    ilm_rollover_alias => "taskflow"
    ilm_pattern => "{now/d}-000001"
    ilm_policy => "taskflow-ilm-policy"
  }

  # En dev, afficher aussi dans la console
  if [environment] == "dev" {
    stdout {
      codec => rubydebug
    }
  }
}

FILEBEAT CONFIGURATION :
──────────────────────────

# elk/filebeat/filebeat.yml

filebeat.inputs:
  - type: container
    paths:
      - '/var/lib/docker/containers/*/*.log'
    processors:
      - add_docker_metadata:
          host: "unix:///var/run/docker.sock"
      # Filtrer uniquement les containers TaskFlow
      - drop_event:
          when:
            not:
              contains:
                docker.container.labels.com.docker.compose.service: "taskflow"

filebeat.config.modules:
  path: ${path.config}/modules.d/*.yml
  reload.enabled: false

output.logstash:
  hosts: ["logstash:5044"]
  loadbalance: true
  bulk_max_size: 2048

processors:
  - add_host_metadata:
      when.not.contains.tags: forwarded

logging.level: warning

================================================================================
CHAPITRE 47 : MONITORING AVEC PROMETHEUS & GRAFANA
================================================================================

47.1 SPRING BOOT ACTUATOR + MICROMETER
────────────────────────────────────────

Micrometer est la "façade de métriques" de Spring Boot — il expose
les métriques dans le format attendu par Prometheus.

DÉPENDANCES :
──────────────

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

<!-- Distributed Tracing -->
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>

<dependency>
    <groupId>io.opentelemetry.exporter</groupId>
    <artifactId>opentelemetry-exporter-zipkin</artifactId>
</dependency>

CONFIGURATION ACTUATOR :
─────────────────────────

# application.properties

# Exposer les endpoints Actuator
management.endpoints.web.exposure.include=health,info,metrics,prometheus,loggers,env,threaddump,heapdump
management.endpoints.web.base-path=/actuator

# Health checks détaillés (uniquement pour les admins)
management.endpoint.health.show-details=when_authorized
management.endpoint.health.show-components=when_authorized
management.endpoint.health.roles=ADMIN

# Groupes de santé pour K8s
management.endpoint.health.probes.enabled=true
management.health.livenessState.enabled=true
management.health.readinessState.enabled=true

# Prometheus — toutes les métriques
management.prometheus.metrics.export.enabled=true

# Info de l'application
management.info.env.enabled=true
management.info.git.enabled=true
management.info.build.enabled=true
management.info.java.enabled=true

info.app.name=TaskFlow API
info.app.description=Task management platform backend
info.app.version=@project.version@
info.app.encoding=@project.build.sourceEncoding@

# Distributed Tracing
management.tracing.sampling.probability=1.0  # 100% en dev, 10% en prod
management.zipkin.tracing.endpoint=http://zipkin:9411/api/v2/spans

# Nommage des métriques (préfixe)
management.metrics.tags.application=taskflow-api
management.metrics.tags.environment=${spring.profiles.active}

─────────────────────────────────────────────────────────────────────
47.2 MÉTRIQUES PERSONNALISÉES
─────────────────────────────────────────────────────────────────────

Micrometer fournit 4 types de métriques principaux :

  Counter           -> Compte des événements (toujours croissant)
  Gauge             -> Valeur instantanée (peut descendre)
  Timer             -> Durée d'exécution d'opérations
  DistributionSummary -> Distribution de valeurs

IMPLÉMENTATION DES MÉTRIQUES TASKFLOW :
────────────────────────────────────────

package com.taskflow.backend.metrics;

import io.micrometer.core.instrument.*;
import io.micrometer.core.instrument.binder.MeterBinder;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

/**
 * Métriques métier TaskFlow.
 * Ces métriques apparaissent dans /actuator/prometheus et sont
 * scrapées par Prometheus, puis visualisées dans Grafana.
 */
@Component
@RequiredArgsConstructor
public class TaskFlowMetrics implements MeterBinder {

    private final TaskRepository taskRepository;
    private final UserRepository userRepository;
    private final ProjectRepository projectRepository;

    // ─── Compteurs (toujours croissants) ───────────────────────────
    private Counter taskCreatedCounter;
    private Counter taskCompletedCounter;
    private Counter taskDeletedCounter;
    private Counter authSuccessCounter;
    private Counter authFailureCounter;
    private Counter apiErrorCounter;

    // ─── Timers (durées) ────────────────────────────────────────────
    private Timer taskCreationTimer;
    private Timer dbQueryTimer;

    @Override
    public void bindTo(MeterRegistry registry) {

        // ─── Compteurs d'actions métier ────────────────────────────
        taskCreatedCounter = Counter.builder("taskflow.tasks.created")
            .description("Nombre total de tâches créées")
            .tag("app", "taskflow-api")
            .register(registry);

        taskCompletedCounter = Counter.builder("taskflow.tasks.completed")
            .description("Nombre total de tâches complétées")
            .register(registry);

        taskDeletedCounter = Counter.builder("taskflow.tasks.deleted")
            .description("Nombre total de tâches supprimées")
            .register(registry);

        // ─── Compteurs d'authentification ──────────────────────────
        authSuccessCounter = Counter.builder("taskflow.auth.success")
            .description("Connexions réussies")
            .register(registry);

        authFailureCounter = Counter.builder("taskflow.auth.failure")
            .description("Échecs d'authentification")
            .register(registry);

        // ─── Compteur d'erreurs API ─────────────────────────────────
        apiErrorCounter = Counter.builder("taskflow.api.errors")
            .description("Erreurs API par type")
            .register(registry);

        // ─── Timers ─────────────────────────────────────────────────
        taskCreationTimer = Timer.builder("taskflow.task.creation.time")
            .description("Durée de création d'une tâche")
            .publishPercentiles(0.5, 0.95, 0.99)  // p50, p95, p99
            .publishPercentileHistogram()
            .register(registry);

        // ─── Gauges (valeurs instantanées depuis la BD) ─────────────
        // ATTENTION : les Gauges sont évaluées périodiquement par Prometheus
        // Ne pas faire de requêtes lourdes ici !

        Gauge.builder("taskflow.users.active", this, metrics -> {
            try {
                return (double) userRepository.countByStatus(UserStatus.ACTIVE);
            } catch (Exception e) {
                return 0d;
            }
        })
        .description("Nombre d'utilisateurs actifs")
        .register(registry);

        Gauge.builder("taskflow.tasks.pending", this, metrics -> {
            try {
                return (double) taskRepository.countByStatus(TaskStatus.TODO);
            } catch (Exception e) {
                return 0d;
            }
        })
        .description("Tâches en attente")
        .register(registry);

        Gauge.builder("taskflow.tasks.overdue", this, metrics -> {
            try {
                return (double) taskRepository.countOverdueTasks(LocalDateTime.now());
            } catch (Exception e) {
                return 0d;
            }
        })
        .description("Tâches en retard")
        .register(registry);

        // ─── DistributionSummary ────────────────────────────────────
        DistributionSummary.builder("taskflow.project.tasks.count")
            .description("Distribution du nombre de tâches par projet")
            .publishPercentiles(0.5, 0.75, 0.95)
            .register(registry);
    }

    // ─── Méthodes publiques appelées par les services ──────────────

    public void recordTaskCreated(String priority) {
        taskCreatedCounter.increment();
    }

    public void recordTaskCompleted(String priority) {
        taskCompletedCounter.increment();
    }

    public void recordAuthSuccess(String method) {
        authSuccessCounter.increment();
    }

    public void recordAuthFailure(String reason) {
        Counter.builder("taskflow.auth.failure")
            .tag("reason", reason)  // ex: "INVALID_PASSWORD", "ACCOUNT_LOCKED"
            .register(Metrics.globalRegistry)
            .increment();
    }

    public void recordApiError(int statusCode, String errorCode) {
        Counter.builder("taskflow.api.errors")
            .tag("status", String.valueOf(statusCode))
            .tag("error_code", errorCode)
            .register(Metrics.globalRegistry)
            .increment();
    }

    public Timer.Sample startTaskCreationTimer() {
        return Timer.start();
    }

    public void stopTaskCreationTimer(Timer.Sample sample) {
        sample.stop(taskCreationTimer);
    }
}

UTILISATION DANS LES SERVICES :
─────────────────────────────────

@Service
@RequiredArgsConstructor
public class TaskServiceImpl implements TaskService {

    private final TaskFlowMetrics metrics;

    @Override
    @Transactional
    public TaskResponse createTask(CreateTaskRequest request, UUID projectUuid) {
        Timer.Sample timerSample = metrics.startTaskCreationTimer();

        try {
            Task task = buildAndSaveTask(request, projectUuid);
            metrics.recordTaskCreated(request.getPriority().name());
            return taskMapper.toResponse(task);
        } catch (Exception e) {
            metrics.recordApiError(500, "TASK_CREATION_FAILED");
            throw e;
        } finally {
            metrics.stopTaskCreationTimer(timerSample);
        }
    }
}

ANNOTATION @TIMED POUR LES CONTROLLERS :
──────────────────────────────────────────

// Micrometer génère automatiquement un Timer pour chaque endpoint
@RestController
@Timed(value = "taskflow.http.requests", histogram = true)
@RequestMapping("/api/v1/tasks")
public class TaskController {

    @GetMapping("/{taskUuid}")
    @Timed(value = "taskflow.task.fetch", description = "Récupération d'une tâche par UUID")
    public ResponseEntity<ApiResponse<TaskResponse>> getTask(
            @PathVariable UUID taskUuid) {
        // ...
    }
}

─────────────────────────────────────────────────────────────────────
47.3 PROMETHEUS — CONFIGURATION
─────────────────────────────────────────────────────────────────────

FICHIER : monitoring/prometheus/prometheus.yml
───────────────────────────────────────────────

global:
  scrape_interval:     15s  # Fréquence de scraping des métriques
  evaluation_interval: 15s  # Fréquence d'évaluation des règles d'alerte
  external_labels:
    environment: production
    region: eu-west-1

# Fichiers de règles d'alerte
rule_files:
  - "rules/taskflow_alerts.yml"
  - "rules/infrastructure_alerts.yml"

# Configuration Alertmanager
alerting:
  alertmanagers:
    - static_configs:
        - targets:
            - alertmanager:9093

scrape_configs:

  # ─── TaskFlow API ──────────────────────────────────────────────
  - job_name: 'taskflow-api'
    metrics_path: '/actuator/prometheus'
    scrape_interval: 15s
    scrape_timeout: 10s
    static_configs:
      - targets:
          - 'taskflow-api:8080'
        labels:
          service: taskflow-api
          team: backend

  # Découverte automatique des Pods K8s (en production sur K8s)
  - job_name: 'kubernetes-pods'
    kubernetes_sd_configs:
      - role: pod
        namespaces:
          names: ['taskflow-prod']
    relabel_configs:
      - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
        action: keep
        regex: true
      - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
        action: replace
        target_label: __metrics_path__
        regex: (.+)
      - source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
        action: replace
        regex: ([^:]+)(?::\d+)?;(\d+)
        replacement: $1:$2
        target_label: __address__

  # ─── PostgreSQL ────────────────────────────────────────────────
  - job_name: 'postgresql'
    static_configs:
      - targets: ['postgres-exporter:9187']
    relabel_configs:
      - source_labels: []
        target_label: service
        replacement: postgresql

  # ─── Redis ────────────────────────────────────────────────────
  - job_name: 'redis'
    static_configs:
      - targets: ['redis-exporter:9121']

  # ─── JVM Métriques (déjà exposées par Micrometer) ─────────────
  # Automatiquement incluses dans /actuator/prometheus

FICHIER : monitoring/prometheus/rules/taskflow_alerts.yml
──────────────────────────────────────────────────────────

groups:
  - name: taskflow-api
    rules:

      # ─── Disponibilité ─────────────────────────────────────────
      - alert: TaskFlowApiDown
        expr: up{job="taskflow-api"} == 0
        for: 1m
        labels:
          severity: critical
          team: backend
        annotations:
          summary: "TaskFlow API is DOWN"
          description: "L'instance {{ $labels.instance }} est hors-ligne depuis plus d'1 minute."

      # ─── Taux d'erreur ─────────────────────────────────────────
      - alert: HighErrorRate
        expr: |
          rate(http_server_requests_seconds_count{
            job="taskflow-api",
            status=~"5.."
          }[5m])
          /
          rate(http_server_requests_seconds_count{
            job="taskflow-api"
          }[5m])
          > 0.05
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Taux d'erreur 5xx > 5%"
          description: "Taux d'erreur : {{ $value | humanizePercentage }}"

      # ─── Latence P99 ────────────────────────────────────────────
      - alert: HighP99Latency
        expr: |
          histogram_quantile(0.99,
            rate(http_server_requests_seconds_bucket{
              job="taskflow-api"
            }[5m])
          ) > 2
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "P99 latency > 2 secondes"
          description: "P99: {{ $value | humanizeDuration }}"

      # ─── JVM Memory ────────────────────────────────────────────
      - alert: JvmMemoryHigh
        expr: |
          jvm_memory_used_bytes{area="heap", job="taskflow-api"}
          /
          jvm_memory_max_bytes{area="heap", job="taskflow-api"}
          > 0.85
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "JVM Heap Memory > 85%"
          description: "Heap: {{ $value | humanizePercentage }}"

      # ─── Pool HikariCP ──────────────────────────────────────────
      - alert: DatabaseConnectionPoolExhausted
        expr: |
          hikaricp_connections_pending{job="taskflow-api"} > 5
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Connection pool épuisé — plus de 5 connexions en attente"

      # ─── Tâches en retard ───────────────────────────────────────
      - alert: TooManyOverdueTasks
        expr: taskflow_tasks_overdue > 1000
        for: 30m
        labels:
          severity: info
          team: product
        annotations:
          summary: "Plus de 1000 tâches en retard"
          description: "{{ $value }} tâches sont en retard."

─────────────────────────────────────────────────────────────────────
47.4 GRAFANA — DASHBOARDS
─────────────────────────────────────────────────────────────────────

DOCKER-COMPOSE MONITORING STACK :
───────────────────────────────────

# docker-compose-monitoring.yml

version: '3.8'
services:

  prometheus:
    image: prom/prometheus:v2.50.1
    ports:
      - "9090:9090"
    volumes:
      - ./monitoring/prometheus:/etc/prometheus
      - prometheus_data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--storage.tsdb.path=/prometheus'
      - '--storage.tsdb.retention.time=30d'
      - '--web.console.libraries=/usr/share/prometheus/console_libraries'
      - '--web.console.templates=/usr/share/prometheus/consoles'
      - '--web.enable-lifecycle'  # Reload config sans redémarrage

  grafana:
    image: grafana/grafana:10.3.1
    ports:
      - "3000:3000"
    environment:
      GF_SECURITY_ADMIN_PASSWORD: adminpassword
      GF_USERS_ALLOW_SIGN_UP: false
      GF_AUTH_ANONYMOUS_ENABLED: false
      # Provisioning automatique des datasources
      GF_PROVISIONING_PATH: /etc/grafana/provisioning
    volumes:
      - grafana_data:/var/lib/grafana
      - ./monitoring/grafana/provisioning:/etc/grafana/provisioning
      - ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards

  alertmanager:
    image: prom/alertmanager:v0.26.0
    ports:
      - "9093:9093"
    volumes:
      - ./monitoring/alertmanager:/etc/alertmanager
    command:
      - '--config.file=/etc/alertmanager/alertmanager.yml'

  # Exporters pour les composants externes
  postgres-exporter:
    image: prometheuscommunity/postgres-exporter:v0.15.0
    environment:
      DATA_SOURCE_NAME: "postgresql://taskflow_user:password@postgres:5432/taskflow_dev?sslmode=disable"
    ports:
      - "9187:9187"

  redis-exporter:
    image: oliver006/redis_exporter:v1.58.0
    environment:
      REDIS_ADDR: redis:6379
    ports:
      - "9121:9121"

  # Zipkin pour le distributed tracing
  zipkin:
    image: openzipkin/zipkin:3
    ports:
      - "9411:9411"

volumes:
  prometheus_data:
  grafana_data:

PROVISIONING GRAFANA (DATASOURCES) :
──────────────────────────────────────

# monitoring/grafana/provisioning/datasources/datasources.yml

apiVersion: 1

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true
    jsonData:
      timeInterval: "15s"
      exemplarTraceIdDestinations:
        - name: traceID
          datasourceUid: zipkin

  - name: Zipkin
    type: zipkin
    access: proxy
    url: http://zipkin:9411
    uid: zipkin

  - name: Elasticsearch
    type: elasticsearch
    access: proxy
    url: http://elasticsearch:9200
    database: taskflow-prod-*
    jsonData:
      esVersion: 8
      timeField: "@timestamp"

DASHBOARD GRAFANA — REQUÊTES PROMETHEUES CLÉS :
─────────────────────────────────────────────────

# Panel 1 : Requêtes par seconde (RPS)
sum(rate(http_server_requests_seconds_count{job="taskflow-api"}[1m]))

# Panel 2 : Taux d'erreur
sum(rate(http_server_requests_seconds_count{
  job="taskflow-api", status=~"5.."
}[1m]))
/
sum(rate(http_server_requests_seconds_count{job="taskflow-api"}[1m]))
* 100

# Panel 3 : Latence P50, P95, P99
histogram_quantile(0.50, sum(rate(http_server_requests_seconds_bucket{job="taskflow-api"}[5m])) by (le))
histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{job="taskflow-api"}[5m])) by (le))
histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{job="taskflow-api"}[5m])) by (le))

# Panel 4 : JVM Heap utilisé
jvm_memory_used_bytes{area="heap", job="taskflow-api"}
/
jvm_memory_max_bytes{area="heap", job="taskflow-api"}
* 100

# Panel 5 : Threads actifs
jvm_threads_live_threads{job="taskflow-api"}

# Panel 6 : Connexions HikariCP actives
hikaricp_connections_active{job="taskflow-api"}

# Panel 7 : Tâches créées (rate sur 5min)
rate(taskflow_tasks_created_total[5m])

# Panel 8 : Authentifications échouées
rate(taskflow_auth_failure_total[5m])

# Panel 9 : Tâches en retard (gauge)
taskflow_tasks_overdue

─────────────────────────────────────────────────────────────────────
47.5 ALERTMANAGER — NOTIFICATIONS
─────────────────────────────────────────────────────────────────────

FICHIER : monitoring/alertmanager/alertmanager.yml
────────────────────────────────────────────────────

global:
  smtp_from: alerting@taskflow.io
  smtp_smarthost: smtp.sendgrid.net:587
  smtp_auth_username: apikey
  smtp_auth_password: SG.xxxx

  # Slack webhook
  slack_api_url: https://hooks.slack.com/services/T00000000/B00000000/XXXX

route:
  # Route par défaut
  receiver: slack-general
  group_by: ['alertname', 'service']
  group_wait: 30s       # Attendre 30s avant d'envoyer (regroupe les alertes)
  group_interval: 5m    # Délai min entre 2 envois d'un même groupe
  repeat_interval: 4h   # Répéter si toujours actif après 4h

  routes:
    # Alertes critiques -> PagerDuty + Slack
    - match:
        severity: critical
      receiver: critical-alerts
      continue: true

    # Alertes info -> Email seulement
    - match:
        severity: info
      receiver: email-only

    # Alertes de sécurité -> Canal dédié
    - match_re:
        alertname: ".*Auth.*|.*Security.*"
      receiver: security-alerts

receivers:
  - name: slack-general
    slack_configs:
      - channel: '#alerts-taskflow'
        title: '{{ template "slack.title" . }}'
        text: '{{ template "slack.text" . }}'
        color: '{{ if eq .Status "firing" }}danger{{ else }}good{{ end }}'
        send_resolved: true
        actions:
          - type: button
            text: 'View in Grafana'
            url: 'http://grafana:3000/d/taskflow/taskflow-overview'
          - type: button
            text: 'Runbook'
            url: 'https://wiki.taskflow.io/runbooks/{{ .CommonLabels.alertname }}'

  - name: critical-alerts
    pagerduty_configs:
      - routing_key: 'your-pagerduty-routing-key'
        description: '{{ .CommonAnnotations.summary }}'
        severity: '{{ .CommonLabels.severity }}'
    slack_configs:
      - channel: '#alerts-critical'
        title: '[ALERTE] CRITICAL: {{ .CommonAnnotations.summary }}'

  - name: email-only
    email_configs:
      - to: 'team@taskflow.io'
        subject: '[TaskFlow] {{ .CommonAnnotations.summary }}'
        html: '{{ template "email.html" . }}'

  - name: security-alerts
    slack_configs:
      - channel: '#security-alerts'
        title: '[VERROUILLE] Security Alert: {{ .CommonAnnotations.summary }}'

inhibit_rules:
  # Si l'app est down, inhiber toutes les autres alertes de ce service
  - source_match:
      alertname: TaskFlowApiDown
    target_match_re:
      alertname: '.*'
    equal: ['service']

─────────────────────────────────────────────────────────────────────
47.6 DISTRIBUTED TRACING AVEC OPENTELEMETRY
─────────────────────────────────────────────────────────────────────

Le Distributed Tracing permet de suivre une requête à travers
plusieurs services (API -> service -> base de données -> cache...).

CONFIGURATION SPRING BOOT :
─────────────────────────────

# application.properties

# Activer le tracing
management.tracing.enabled=true
management.tracing.sampling.probability=1.0  # 100% en dev

# Exporter vers Zipkin
management.zipkin.tracing.endpoint=http://zipkin:9411/api/v2/spans
management.zipkin.tracing.connect-timeout=1s
management.zipkin.tracing.read-timeout=10s

# Inclure les IDs de trace dans les logs
logging.pattern.correlation=[${spring.application.name:},%X{traceId:-},%X{spanId:-}]

CRÉER DES SPANS PERSONNALISÉS :
─────────────────────────────────

package com.taskflow.backend.service;

import io.micrometer.observation.Observation;
import io.micrometer.observation.ObservationRegistry;
import io.micrometer.observation.annotation.Observed;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class TaskServiceImpl implements TaskService {

    private final ObservationRegistry observationRegistry;

    /**
     * @Observed crée automatiquement un Span pour cette méthode
     * Visible dans Zipkin/Jaeger sous le nom "task.create"
     */
    @Observed(name = "task.create",
              contextualName = "creating-task",
              lowCardinalityKeyValues = {"service", "TaskService"})
    @Override
    @Transactional
    public TaskResponse createTask(CreateTaskRequest request, UUID projectUuid) {
        return Observation.createNotStarted("task.external.validation", observationRegistry)
            .observe(() -> {
                // Validation, création, etc.
                return buildAndSaveTask(request, projectUuid);
            });
    }

    private TaskResponse buildAndSaveTask(CreateTaskRequest request, UUID projectUuid) {
        // Créer un span enfant pour la requête BD
        return Observation.createNotStarted("task.repository.save", observationRegistry)
            .lowCardinalityKeyValue("db.operation", "INSERT")
            .observe(() -> {
                Task task = Task.builder()
                    .title(request.getTitle())
                    .build();
                return taskMapper.toResponse(taskRepository.save(task));
            });
    }
}

─────────────────────────────────────────────────────────────────────
BONNES PRATIQUES — LOGGING & MONITORING
─────────────────────────────────────────────────────────────────────

1. LOGS STRUCTURÉS (JSON) EN PRODUCTION
   -> Elasticsearch peut indexer les champs JSON individuellement
   -> Facilite la recherche et l'agrégation dans Kibana
   -> Ne jamais utiliser des logs texte non structurés en production

2. MDC TOUJOURS NETTOYÉ
   -> MDC.clear() dans un finally block
   -> Sans ça, les données d'une requête contaminent la suivante

3. NE JAMAIS LOGGER DE DONNÉES SENSIBLES
   -> Mots de passe, tokens, numéros de carte
   -> Utiliser des masques : "password=***"

4. MÉTRIQUES AVANT LES ALERTES
   -> Définir les métriques clés avant les alertes
   -> Comprendre les valeurs normales avant de configurer les seuils

5. RÈGLE DES 4 GOLDEN SIGNALS (Google SRE)
   -> Latency    : durée des requêtes réussies vs échouées
   -> Traffic    : requêtes par seconde
   -> Errors     : taux d'erreur
   -> Saturation : CPU, mémoire, connexions BD proches du max

6. ALERTES ACTIONNABLES SEULEMENT
   -> Si une alerte ne peut pas déclencher une action, la supprimer
   -> Chaque alerte doit avoir un runbook associé

7. RETENTION DES LOGS
   -> Logs applicatifs : 30 jours
   -> Logs d'erreurs : 60 jours
   -> Logs d'audit : 1 an minimum (conformité réglementaire)

─────────────────────────────────────────────────────────────────────
EXERCICES
─────────────────────────────────────────────────────────────────────

NIVEAU DÉBUTANT :
  1. Configurer logback-spring.xml avec 3 appenders : CONSOLE (format
     coloré), FILE (rotation quotidienne), et ERROR_FILE (erreurs seul.).
     Vérifier que les logs apparaissent correctement.

  2. Implémenter le MdcLoggingFilter et vérifier que chaque log dans
     la console affiche le requestId et la durée de la requête.

  3. Ajouter l'endpoint /actuator/prometheus à l'application et
     lancer Prometheus + Grafana via docker-compose. Créer un panel
     simple montrant le nombre de requêtes par seconde.

NIVEAU INTERMÉDIAIRE :
  1. Créer un AuditService qui log dans un fichier séparé (audit.log)
     les événements LOGIN, TASK_CREATED, PROJECT_DELETED avec les
     champs : userId, action, resourceId, ip, timestamp.

  2. Implémenter 3 métriques personnalisées Micrometer :
     - Counter taskflow.auth.failure avec tag "reason"
     - Timer taskflow.task.creation.time avec percentiles p95, p99
     - Gauge taskflow.tasks.overdue (requête repository)
     Vérifier leur présence dans /actuator/prometheus.

  3. Configurer des règles d'alerte Prometheus pour :
     - Taux d'erreur HTTP > 5% pendant 5min
     - P99 latency > 2s pendant 10min
     - JVM Heap > 85%
     Tester en provoquant délibérément des erreurs.

NIVEAU AVANCÉ :
  1. Mettre en place la stack ELK complète (docker-compose).
     Configurer Logstash pour parser les logs JSON de Spring Boot.
     Créer un dashboard Kibana montrant : top 10 erreurs par classe,
     distribution de latence par endpoint, évolution du trafic.

  2. Implémenter le Distributed Tracing avec OpenTelemetry + Zipkin.
     Créer des spans personnalisés pour les opérations critiques
     (createTask, sendNotification). Visualiser la trace complète
     d'une requête POST /tasks dans l'UI Zipkin.

  3. Configurer Alertmanager avec 3 receivers : Slack (général),
     PagerDuty (critique), Email (info). Tester les inhibit_rules
     pour éviter les floods d'alertes quand l'API est down.

================================================================================
RÉSUMÉ PARTIE 16
================================================================================

CHAPITRE 46 — LOGGING :
  [OK] Stratégie de logging par niveau et par package
  [OK] logback-spring.xml complet : 5 appenders (console dev, console JSON prod,
     fichier rotatif, fichier erreurs, fichier audit)
  [OK] Logstash Encoder pour logs JSON structurés
  [OK] MDC complet : requestId, traceId, userId, IP, durée, statut HTTP
  [OK] MdcLoggingFilter : enrichissement automatique de chaque requête
  [OK] AuditService avec logger dédié (audit.log conservé 1 an)
  [OK] ELK Stack complet : docker-compose, Logstash pipeline, Filebeat config
  [OK] Index Lifecycle Management (ILM) Elasticsearch

CHAPITRE 47 — MONITORING :
  [OK] Spring Boot Actuator + Micrometer + Prometheus Registry
  [OK] MeterBinder complet : Counter, Gauge, Timer, DistributionSummary
  [OK] Métriques métier : tâches créées/complétées, auth, erreurs API, overdue
  [OK] @Timed et @Observed pour instrumentation automatique
  [OK] prometheus.yml complet : scrape K8s pods, PostgreSQL, Redis exporters
  [OK] Règles d'alerte Prometheus : disponibilité, erreurs, latence, JVM, HikariCP
  [OK] docker-compose monitoring : Prometheus, Grafana, Alertmanager, Zipkin
  [OK] Grafana provisioning automatique (datasources + dashboards)
  [OK] Requêtes PromQL pour les 4 Golden Signals
  [OK] Alertmanager : routes, receivers (Slack/PagerDuty/Email), inhibit_rules
  [OK] Distributed Tracing : OpenTelemetry, spans personnalisés, Zipkin

================================================================================
FIN PARTIE 16
Prochaine partie -> Clean Architecture & Domain-Driven Design (DDD)
================================================================================

================================================================================
GUIDE SPRING BOOT ENTREPRISE — PARTIE 17
CLEAN ARCHITECTURE & DOMAIN-DRIVEN DESIGN (DDD)
Chapitres 48-49
Projet : TaskFlow Backend
================================================================================

TABLE DES MATIÈRES — PARTIE 17
────────────────────────────────
Chapitre 48 : Clean Architecture
  48.1  Pourquoi la Clean Architecture ?
  48.2  Les 4 couches de la Clean Architecture
  48.3  La règle de dépendance (Dependency Rule)
  48.4  Restructuration de TaskFlow en Clean Architecture
  48.5  Ports & Adapters (Architecture Hexagonale)
  48.6  Use Cases — cœur de la logique métier
  48.7  Tests facilités par la Clean Architecture

Chapitre 49 : Domain-Driven Design (DDD)
  49.1  Concepts fondamentaux du DDD
  49.2  Bounded Contexts et Ubiquitous Language
  49.3  Entités, Value Objects, Agrégats
  49.4  Repositories et Domain Services
  49.5  Domain Events
  49.6  CQRS — Command Query Responsibility Segregation
  49.7  Event Sourcing (introduction)
  49.8  Application du DDD à TaskFlow

================================================================================
CHAPITRE 48 : CLEAN ARCHITECTURE
================================================================================

48.1 POURQUOI LA CLEAN ARCHITECTURE ?
───────────────────────────────────────

PROBLÈMES DE L'ARCHITECTURE TRADITIONNELLE EN COUCHES :

    Controller -> Service -> Repository -> Database

Ce modèle simple présente des problèmes à l'échelle :
  [X] Le domaine métier (Task, Project) dépend de la BD (annotations JPA)
  [X] Changer de BD (PostgreSQL -> MongoDB) implique tout réécrire
  [X] Tester le Service nécessite un vrai Repository (+ vraie BD)
  [X] La logique métier est diluée entre Controllers et Services
  [X] Les Services connaissent les détails HTTP (DTOs, HttpStatus)

SOLUTION : LA CLEAN ARCHITECTURE (Robert C. Martin, 2012)

    OBJECTIF : rendre le code indépendant de :
      - La base de données (PostgreSQL, MongoDB, H2...)
      - Le framework web (Spring, Quarkus, Micronaut...)
      - Les librairies tierces (Jackson, HikariCP...)
      - L'interface utilisateur (REST, gRPC, GraphQL...)

48.2 LES 4 COUCHES DE LA CLEAN ARCHITECTURE
─────────────────────────────────────────────

            ┌───────────────────────────────────┐
            │   Frameworks & Drivers            │  <- Couche externe
            │   (Spring, JPA, HTTP, Docker...)  │
            ├───────────────────────────────────┤
            │   Interface Adapters              │
            │   (Controllers, Presenters,       │
            │    Repositories Implémentations)  │
            ├───────────────────────────────────┤
            │   Application Business Rules      │
            │   (Use Cases)                     │
            ├───────────────────────────────────┤
            │   Enterprise Business Rules       │  <- Couche interne
            │   (Entities, Domain Objects)      │
            └───────────────────────────────────┘

RÈGLE FONDAMENTALE : Les dépendances pointent VERS L'INTÉRIEUR.
Le Domain ne connaît pas les Use Cases.
Les Use Cases ne connaissent pas les Controllers.
Les Controllers ne connaissent pas Spring MVC directement.

48.3 RESTRUCTURATION DE TASKFLOW
──────────────────────────────────

ANCIENNE STRUCTURE (couches traditionnelles) :
─────────────────────────────────────────────

com.taskflow.backend/
├── controller/      <- HTTP + validation + réponse
├── service/         <- Logique métier + accès BD
├── repository/      <- JPA Repositories
├── entity/          <- Entités JPA (avec annotations Spring)
├── dto/             <- DTOs pour les APIs
└── mapper/          <- MapStruct

NOUVELLE STRUCTURE (Clean Architecture) :
──────────────────────────────────────────

com.taskflow.backend/
│
├── domain/                      <- COUCHE DOMAINE (0 dépendance externe)
│   ├── model/                   <- Entités et Value Objects purs
│   │   ├── Task.java
│   │   ├── Project.java
│   │   ├── User.java
│   │   └── valueobject/
│   │       ├── TaskStatus.java
│   │       ├── Email.java
│   │       └── Money.java
│   ├── event/                   <- Domain Events
│   │   ├── TaskCreatedEvent.java
│   │   └── TaskStatusChangedEvent.java
│   ├── exception/               <- Exceptions métier (pas Spring!)
│   │   ├── TaskNotFoundException.java
│   │   └── ProjectAccessDeniedException.java
│   └── repository/              <- INTERFACES des repositories (ports)
│       ├── TaskRepository.java
│       ├── UserRepository.java
│       └── ProjectRepository.java
│
├── application/                 <- COUCHE APPLICATION (Use Cases)
│   ├── usecase/
│   │   ├── task/
│   │   │   ├── CreateTaskUseCase.java
│   │   │   ├── UpdateTaskStatusUseCase.java
│   │   │   ├── GetProjectTasksUseCase.java
│   │   │   └── DeleteTaskUseCase.java
│   │   ├── project/
│   │   │   ├── CreateProjectUseCase.java
│   │   │   └── AddProjectMemberUseCase.java
│   │   └── auth/
│   │       ├── LoginUseCase.java
│   │       └── RefreshTokenUseCase.java
│   ├── port/                    <- Ports (interfaces vers l'extérieur)
│   │   ├── in/                  <- Ports d'entrée (ce que l'app offre)
│   │   │   ├── CreateTaskPort.java
│   │   │   └── LoginPort.java
│   │   └── out/                 <- Ports de sortie (ce que l'app utilise)
│   │       ├── SendEmailPort.java
│   │       ├── CachePort.java
│   │       └── EventPublisherPort.java
│   └── dto/                     <- DTOs spécifiques aux Use Cases
│       ├── CreateTaskCommand.java
│       └── TaskResult.java
│
├── infrastructure/              <- COUCHE INFRASTRUCTURE
│   ├── persistence/             <- Implémentations JPA
│   │   ├── entity/              <- Entités JPA (avec @Entity, etc.)
│   │   │   ├── TaskJpaEntity.java
│   │   │   └── UserJpaEntity.java
│   │   ├── repository/          <- Spring Data JPA
│   │   │   ├── TaskJpaRepository.java
│   │   │   └── UserJpaRepository.java
│   │   ├── adapter/             <- Implémente les ports du domaine
│   │   │   ├── TaskRepositoryAdapter.java
│   │   │   └── UserRepositoryAdapter.java
│   │   └── mapper/              <- Domaine <-> JPA
│   │       └── TaskPersistenceMapper.java
│   ├── messaging/               <- Kafka, RabbitMQ
│   │   └── KafkaEventPublisher.java
│   ├── cache/                   <- Redis
│   │   └── RedisCacheAdapter.java
│   └── email/                   <- SMTP, SendGrid
│       └── SendGridEmailAdapter.java
│
└── adapter/                     <- COUCHE INTERFACE
    ├── web/                     <- REST Controllers
    │   ├── TaskController.java
    │   ├── dto/                 <- DTOs HTTP
    │   │   ├── TaskRequest.java
    │   │   └── TaskResponse.java
    │   └── mapper/              <- HTTP DTO <-> Application DTO
    │       └── TaskWebMapper.java
    └── config/                  <- Configuration Spring
        ├── SecurityConfig.java
        └── PersistenceConfig.java

─────────────────────────────────────────────────────────────────────
48.4 IMPLÉMENTATION COMPLÈTE — COUCHE DOMAINE
─────────────────────────────────────────────────────────────────────

ENTITÉ DOMAINE (aucune annotation Spring/JPA) :
────────────────────────────────────────────────

package com.taskflow.backend.domain.model;

import com.taskflow.backend.domain.event.TaskCreatedEvent;
import com.taskflow.backend.domain.event.TaskStatusChangedEvent;
import com.taskflow.backend.domain.exception.InvalidTaskStateException;
import com.taskflow.backend.domain.valueobject.TaskStatus;
import com.taskflow.backend.domain.valueobject.TaskPriority;

import java.time.LocalDateTime;
import java.util.*;

/**
 * Entité racine de l'agrégat Task.
 * AUCUNE dépendance vers Spring, JPA, ou toute librairie externe.
 * La logique métier est concentrée ici.
 */
public class Task {

    private final UUID id;
    private String title;
    private String description;
    private TaskStatus status;
    private TaskPriority priority;
    private LocalDateTime dueDate;
    private UUID assigneeId;
    private UUID projectId;
    private UUID createdById;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    // Domain Events collectés avant publication
    private final List<Object> domainEvents = new ArrayList<>();

    // Constructeur privé — utiliser le factory method
    private Task() {}

    // ─── Factory Method ────────────────────────────────────────────

    /**
     * Factory method qui garantit l'invariant métier :
     * une nouvelle tâche est TOUJOURS créée avec le statut TODO.
     */
    public static Task create(
            UUID id,
            String title,
            String description,
            TaskPriority priority,
            LocalDateTime dueDate,
            UUID projectId,
            UUID createdById) {

        Objects.requireNonNull(id, "Task ID cannot be null");
        Objects.requireNonNull(title, "Task title cannot be null");
        Objects.requireNonNull(projectId, "Project ID cannot be null");

        if (title.isBlank()) {
            throw new InvalidTaskStateException("Task title cannot be blank");
        }

        if (dueDate != null && dueDate.isBefore(LocalDateTime.now())) {
            throw new InvalidTaskStateException("Due date cannot be in the past");
        }

        Task task = new Task();
        task.id          = id;
        task.title       = title;
        task.description = description;
        task.status      = TaskStatus.TODO;  // Invariant : toujours TODO à la création
        task.priority    = priority != null ? priority : TaskPriority.MEDIUM;
        task.dueDate     = dueDate;
        task.projectId   = projectId;
        task.createdById = createdById;
        task.createdAt   = LocalDateTime.now();
        task.updatedAt   = LocalDateTime.now();

        // Émettre un domain event
        task.domainEvents.add(new TaskCreatedEvent(task.id, projectId, createdById));

        return task;
    }

    /**
     * Reconstitution depuis la persistance (sans émettre d'events)
     */
    public static Task reconstitute(UUID id, String title, String description,
            TaskStatus status, TaskPriority priority, LocalDateTime dueDate,
            UUID assigneeId, UUID projectId, UUID createdById,
            LocalDateTime createdAt, LocalDateTime updatedAt) {

        Task task = new Task();
        task.id          = id;
        task.title       = title;
        task.description = description;
        task.status      = status;
        task.priority    = priority;
        task.dueDate     = dueDate;
        task.assigneeId  = assigneeId;
        task.projectId   = projectId;
        task.createdById = createdById;
        task.createdAt   = createdAt;
        task.updatedAt   = updatedAt;
        return task;
    }

    // ─── Comportements métier ──────────────────────────────────────

    /**
     * Machine d'états avec règles métier.
     * Centralise TOUTE la logique de transition de statut.
     */
    public void changeStatus(TaskStatus newStatus, UUID changedById) {
        if (!this.status.canTransitionTo(newStatus)) {
            throw new InvalidTaskStateException(
                String.format("Cannot transition from %s to %s", this.status, newStatus));
        }

        TaskStatus oldStatus = this.status;
        this.status    = newStatus;
        this.updatedAt = LocalDateTime.now();

        // Émettre l'event de changement de statut
        this.domainEvents.add(new TaskStatusChangedEvent(
            this.id, oldStatus, newStatus, changedById));
    }

    public void assignTo(UUID userId) {
        Objects.requireNonNull(userId, "Assignee cannot be null");
        this.assigneeId = userId;
        this.updatedAt  = LocalDateTime.now();
    }

    public void unassign() {
        this.assigneeId = null;
        this.updatedAt  = LocalDateTime.now();
    }

    public void updateTitle(String newTitle) {
        if (newTitle == null || newTitle.isBlank()) {
            throw new InvalidTaskStateException("Title cannot be blank");
        }
        this.title     = newTitle;
        this.updatedAt = LocalDateTime.now();
    }

    public boolean isOverdue() {
        return this.dueDate != null
            && this.dueDate.isBefore(LocalDateTime.now())
            && !this.status.isTerminal();
    }

    public boolean isCompleted() {
        return this.status == TaskStatus.DONE;
    }

    // ─── Gestion des Domain Events ────────────────────────────────

    public List<Object> getDomainEvents() {
        return Collections.unmodifiableList(domainEvents);
    }

    public void clearDomainEvents() {
        domainEvents.clear();
    }

    // ─── Getters (pas de setters — l'état change via les méthodes) ─

    public UUID getId()          { return id; }
    public String getTitle()     { return title; }
    public String getDescription() { return description; }
    public TaskStatus getStatus() { return status; }
    public TaskPriority getPriority() { return priority; }
    public LocalDateTime getDueDate() { return dueDate; }
    public UUID getAssigneeId()  { return assigneeId; }
    public UUID getProjectId()   { return projectId; }
    public UUID getCreatedById() { return createdById; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Task)) return false;
        Task task = (Task) o;
        return Objects.equals(id, task.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

VALUE OBJECT — EMAIL :
───────────────────────

package com.taskflow.backend.domain.valueobject;

import java.util.regex.Pattern;

/**
 * Value Object — immuable, pas d'identité propre, égalité par valeur.
 * Encapsule la validation de l'email au niveau domaine.
 */
public final class Email {

    private static final Pattern EMAIL_PATTERN =
        Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$");

    private final String value;

    private Email(String value) {
        this.value = value;
    }

    public static Email of(String value) {
        if (value == null || value.isBlank()) {
            throw new IllegalArgumentException("Email cannot be null or blank");
        }
        String normalized = value.trim().toLowerCase();
        if (!EMAIL_PATTERN.matcher(normalized).matches()) {
            throw new IllegalArgumentException("Invalid email format: " + value);
        }
        return new Email(normalized);
    }

    public String getValue() { return value; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Email)) return false;
        return value.equals(((Email) o).value);
    }

    @Override
    public int hashCode() { return value.hashCode(); }

    @Override
    public String toString() { return value; }
}

VALUE OBJECT — TASKSTATUS AVEC MACHINE D'ÉTATS :
──────────────────────────────────────────────────

package com.taskflow.backend.domain.valueobject;

import java.util.Set;

public enum TaskStatus {

    TODO {
        @Override
        public Set<TaskStatus> allowedTransitions() {
            return Set.of(IN_PROGRESS, CANCELLED);
        }
    },
    IN_PROGRESS {
        @Override
        public Set<TaskStatus> allowedTransitions() {
            return Set.of(TODO, IN_REVIEW, BLOCKED, CANCELLED);
        }
    },
    IN_REVIEW {
        @Override
        public Set<TaskStatus> allowedTransitions() {
            return Set.of(IN_PROGRESS, DONE, CANCELLED);
        }
    },
    BLOCKED {
        @Override
        public Set<TaskStatus> allowedTransitions() {
            return Set.of(TODO, IN_PROGRESS, CANCELLED);
        }
    },
    DONE {
        @Override
        public Set<TaskStatus> allowedTransitions() {
            return Set.of();  // Terminal — aucune transition
        }
    },
    CANCELLED {
        @Override
        public Set<TaskStatus> allowedTransitions() {
            return Set.of();  // Terminal
        }
    };

    public abstract Set<TaskStatus> allowedTransitions();

    public boolean canTransitionTo(TaskStatus next) {
        return allowedTransitions().contains(next);
    }

    public boolean isTerminal() {
        return allowedTransitions().isEmpty();
    }
}

PORT DU DOMAINE (Interface Repository) :
─────────────────────────────────────────

package com.taskflow.backend.domain.repository;

import com.taskflow.backend.domain.model.Task;
import com.taskflow.backend.domain.valueobject.TaskStatus;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

/**
 * Port du domaine — définit CE QUE l'application a besoin,
 * pas COMMENT c'est implémenté.
 * Cette interface est dans le domaine, mais son implémentation
 * est dans la couche infrastructure.
 */
public interface TaskRepository {

    Task save(Task task);
    Optional<Task> findById(UUID id);
    List<Task> findByProjectId(UUID projectId);
    List<Task> findByProjectIdAndStatus(UUID projectId, TaskStatus status);
    List<Task> findOverdueTasks(LocalDateTime before);
    void delete(UUID id);
    boolean existsById(UUID id);
}

─────────────────────────────────────────────────────────────────────
48.5 COUCHE APPLICATION — USE CASES
─────────────────────────────────────────────────────────────────────

USE CASE — CRÉER UNE TÂCHE :
──────────────────────────────

package com.taskflow.backend.application.usecase.task;

import com.taskflow.backend.application.port.out.EventPublisherPort;
import com.taskflow.backend.domain.model.Task;
import com.taskflow.backend.domain.repository.ProjectRepository;
import com.taskflow.backend.domain.repository.TaskRepository;
import com.taskflow.backend.domain.repository.UserRepository;
import com.taskflow.backend.domain.exception.ProjectNotFoundException;
import com.taskflow.backend.domain.exception.UserNotFoundException;
import com.taskflow.backend.domain.exception.ProjectAccessDeniedException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.UUID;

/**
 * Use Case : Créer une tâche.
 *
 * Responsabilité :
 *   1. Valider que le projet existe
 *   2. Valider que l'utilisateur a accès au projet
 *   3. Déléguer la création à l'entité domaine Task
 *   4. Persister
 *   5. Publier les domain events
 *
 * Ce use case ne connaît pas Spring MVC, JPA, ou les DTOs HTTP.
 */
@Service
@RequiredArgsConstructor
public class CreateTaskUseCase {

    private final TaskRepository taskRepository;
    private final ProjectRepository projectRepository;
    private final UserRepository userRepository;
    private final EventPublisherPort eventPublisher;

    @Transactional
    public TaskResult execute(CreateTaskCommand command) {

        // 1. Vérifier que le projet existe
        var project = projectRepository.findById(command.projectId())
            .orElseThrow(() -> new ProjectNotFoundException(command.projectId()));

        // 2. Vérifier les droits d'accès
        if (!project.isMember(command.createdById())) {
            throw new ProjectAccessDeniedException(
                command.createdById(), command.projectId());
        }

        // 3. Créer l'entité domaine (logique métier dans le domaine)
        Task task = Task.create(
            UUID.randomUUID(),
            command.title(),
            command.description(),
            command.priority(),
            command.dueDate(),
            command.projectId(),
            command.createdById()
        );

        // 4. Assigner si précisé
        if (command.assigneeId() != null) {
            userRepository.findById(command.assigneeId())
                .orElseThrow(() -> new UserNotFoundException(command.assigneeId()));
            task.assignTo(command.assigneeId());
        }

        // 5. Persister
        Task saved = taskRepository.save(task);

        // 6. Publier les domain events collectés
        saved.getDomainEvents().forEach(eventPublisher::publish);
        saved.clearDomainEvents();

        return TaskResult.from(saved);
    }
}

COMMAND ET RESULT (DTOs de Use Case) :
────────────────────────────────────────

package com.taskflow.backend.application.usecase.task;

// Command — données en entrée du Use Case (pas de HTTP, pas d'annotations)
public record CreateTaskCommand(
    String title,
    String description,
    TaskPriority priority,
    LocalDateTime dueDate,
    UUID projectId,
    UUID assigneeId,
    UUID createdById
) {}

// Result — données en sortie du Use Case (pas de ResponseEntity, pas d'HTTP)
public record TaskResult(
    UUID id,
    String title,
    String description,
    TaskStatus status,
    TaskPriority priority,
    LocalDateTime dueDate,
    UUID assigneeId,
    UUID projectId,
    LocalDateTime createdAt
) {
    public static TaskResult from(Task task) {
        return new TaskResult(
            task.getId(),
            task.getTitle(),
            task.getDescription(),
            task.getStatus(),
            task.getPriority(),
            task.getDueDate(),
            task.getAssigneeId(),
            task.getProjectId(),
            task.getCreatedAt()
        );
    }
}

─────────────────────────────────────────────────────────────────────
48.6 COUCHE INFRASTRUCTURE — ADAPTATEURS
─────────────────────────────────────────────────────────────────────

ENTITÉ JPA (distincte de l'entité domaine) :
──────────────────────────────────────────────

package com.taskflow.backend.infrastructure.persistence.entity;

import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.UUID;

/**
 * Entité JPA — mapped uniquement à la base de données.
 * N'a aucune logique métier — c'est un simple conteneur de données.
 */
@Entity
@Table(name = "tasks")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor
@Builder
public class TaskJpaEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private UUID uuid;

    @Column(nullable = false, length = 255)
    private String title;

    @Column(columnDefinition = "TEXT")
    private String description;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private TaskStatus status;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private TaskPriority priority;

    private LocalDateTime dueDate;

    @Column(nullable = false)
    private UUID projectUuid;

    private UUID assigneeUuid;

    @Column(nullable = false)
    private UUID createdByUuid;

    @Column(nullable = false)
    private LocalDateTime createdAt;

    private LocalDateTime updatedAt;
}

MAPPER PERSISTANCE (Domaine <-> JPA) :
──────────────────────────────────────

package com.taskflow.backend.infrastructure.persistence.mapper;

import com.taskflow.backend.domain.model.Task;
import com.taskflow.backend.infrastructure.persistence.entity.TaskJpaEntity;
import org.springframework.stereotype.Component;

/**
 * Traduit entre le modèle domaine et l'entité JPA.
 * Isole la couche domaine des détails de persistance.
 */
@Component
public class TaskPersistenceMapper {

    public Task toDomain(TaskJpaEntity entity) {
        return Task.reconstitute(
            entity.getUuid(),
            entity.getTitle(),
            entity.getDescription(),
            entity.getStatus(),
            entity.getPriority(),
            entity.getDueDate(),
            entity.getAssigneeUuid(),
            entity.getProjectUuid(),
            entity.getCreatedByUuid(),
            entity.getCreatedAt(),
            entity.getUpdatedAt()
        );
    }

    public TaskJpaEntity toJpaEntity(Task task) {
        return TaskJpaEntity.builder()
            .uuid(task.getId())
            .title(task.getTitle())
            .description(task.getDescription())
            .status(task.getStatus())
            .priority(task.getPriority())
            .dueDate(task.getDueDate())
            .projectUuid(task.getProjectId())
            .assigneeUuid(task.getAssigneeId())
            .createdByUuid(task.getCreatedById())
            .createdAt(task.getCreatedAt())
            .updatedAt(task.getUpdatedAt())
            .build();
    }
}

ADAPTATEUR REPOSITORY (Implémente le port du domaine) :
─────────────────────────────────────────────────────────

package com.taskflow.backend.infrastructure.persistence.adapter;

import com.taskflow.backend.domain.model.Task;
import com.taskflow.backend.domain.repository.TaskRepository;
import com.taskflow.backend.domain.valueobject.TaskStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

/**
 * Adaptateur — implémente le port du domaine TaskRepository
 * en utilisant Spring Data JPA.
 *
 * Le domaine ne connaît que l'interface TaskRepository.
 * Cette implémentation peut être remplacée par une version MongoDB,
 * en-mémoire, ou autre sans toucher au domaine.
 */
@Repository
@RequiredArgsConstructor
public class TaskRepositoryAdapter implements TaskRepository {

    private final TaskJpaRepository jpaRepository;
    private final TaskPersistenceMapper mapper;

    @Override
    public Task save(Task task) {
        TaskJpaEntity entity = mapper.toJpaEntity(task);
        TaskJpaEntity saved  = jpaRepository.save(entity);
        return mapper.toDomain(saved);
    }

    @Override
    public Optional<Task> findById(UUID id) {
        return jpaRepository.findByUuid(id)
            .map(mapper::toDomain);
    }

    @Override
    public List<Task> findByProjectId(UUID projectId) {
        return jpaRepository.findByProjectUuid(projectId).stream()
            .map(mapper::toDomain)
            .toList();
    }

    @Override
    public List<Task> findByProjectIdAndStatus(UUID projectId, TaskStatus status) {
        return jpaRepository.findByProjectUuidAndStatus(projectId, status).stream()
            .map(mapper::toDomain)
            .toList();
    }

    @Override
    public List<Task> findOverdueTasks(LocalDateTime before) {
        return jpaRepository.findByDueDateBeforeAndStatusNotIn(
            before, List.of(TaskStatus.DONE, TaskStatus.CANCELLED)
        ).stream().map(mapper::toDomain).toList();
    }

    @Override
    public void delete(UUID id) {
        jpaRepository.findByUuid(id).ifPresent(jpaRepository::delete);
    }

    @Override
    public boolean existsById(UUID id) {
        return jpaRepository.existsByUuid(id);
    }
}

─────────────────────────────────────────────────────────────────────
48.7 COUCHE INTERFACE — CONTROLLER
─────────────────────────────────────────────────────────────────────

package com.taskflow.backend.adapter.web;

import com.taskflow.backend.application.usecase.task.CreateTaskCommand;
import com.taskflow.backend.application.usecase.task.CreateTaskUseCase;
import com.taskflow.backend.application.usecase.task.TaskResult;
import com.taskflow.backend.adapter.web.dto.CreateTaskRequest;
import com.taskflow.backend.adapter.web.dto.TaskResponse;
import com.taskflow.backend.adapter.web.mapper.TaskWebMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;

@RestController
@RequestMapping("/api/v1/projects/{projectUuid}/tasks")
@RequiredArgsConstructor
public class TaskController {

    private final CreateTaskUseCase createTaskUseCase;
    private final TaskWebMapper webMapper;

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ApiResponse<TaskResponse> createTask(
            @PathVariable UUID projectUuid,
            @Valid @RequestBody CreateTaskRequest request,
            @AuthenticationPrincipal UserPrincipal principal) {

        // 1. Traduire le DTO HTTP en Command Use Case
        CreateTaskCommand command = CreateTaskCommand.builder()
            .title(request.getTitle())
            .description(request.getDescription())
            .priority(request.getPriority())
            .dueDate(request.getDueDate())
            .projectId(projectUuid)
            .assigneeId(request.getAssigneeUuid())
            .createdById(principal.getUuid())
            .build();

        // 2. Exécuter le Use Case (ne connaît pas HTTP)
        TaskResult result = createTaskUseCase.execute(command);

        // 3. Traduire le Result en DTO HTTP
        return ApiResponse.success("Task created", webMapper.toResponse(result));
    }
}

================================================================================
CHAPITRE 49 : DOMAIN-DRIVEN DESIGN (DDD)
================================================================================

49.1 CONCEPTS FONDAMENTAUX DU DDD
───────────────────────────────────

Le DDD (Eric Evans, 2003) est une approche de conception logicielle
qui place le domaine métier au centre de toutes les décisions.

LES BUILDING BLOCKS DU DDD :
──────────────────────────────

  Entity          -> Objet avec une identité unique persistante
  Value Object    -> Objet immuable, égalité par valeur
  Aggregate       -> Cluster d'entités avec une racine (Aggregate Root)
  Repository      -> Abstraction de la persistance (1 par Aggregat)
  Domain Service  -> Logique qui n'appartient pas à une entité
  Domain Event    -> Événement qui s'est passé dans le domaine
  Factory         -> Crée des objets complexes
  Bounded Context -> Limite explicite d'un sous-domaine

49.2 BOUNDED CONTEXTS DANS TASKFLOW
─────────────────────────────────────

DÉCOUPAGE DES BOUNDED CONTEXTS :
──────────────────────────────────

  ┌────────────────────────┐    ┌────────────────────────┐
  │  Identity & Access     │    │  Project Management    │
  │  (IAM Context)         │    │  Context               │
  │                        │    │                        │
  │  User (Aggregate Root) │    │  Project (Aggr. Root)  │
  │  Role                  │    │  ProjectMember         │
  │  Permission            │    │  Task (Aggr. Root)     │
  │  RefreshToken          │    │  Tag                   │
  └────────────────────────┘    └────────────────────────┘
            │                              │
            │         Context Map          │
            └──────────── <-> ───────────────┘
            (Shared Kernel : User UUID référencé dans les 2 contextes)

  ┌────────────────────────┐    ┌────────────────────────┐
  │  Notification Context  │    │  Reporting Context     │
  │                        │    │                        │
  │  Notification          │    │  Report                │
  │  NotificationTemplate  │    │  ProjectStats          │
  │  NotificationChannel   │    │  UserProductivity      │
  └────────────────────────┘    └────────────────────────┘

UBIQUITOUS LANGUAGE (Langage omniprésent) :
────────────────────────────────────────────

Dans le contexte "Project Management" :
  - "Tâche" -> Task (entité, a un statut, une priorité)
  - "Projet" -> Project (agrégat, a des membres et des tâches)
  - "Assignation" -> Action d'assigner une tâche à un membre du projet
  - "Overdue" -> Tâche dont la date d'échéance est dépassée et non complétée
  - "Sprint" -> Période de travail, contient des tâches

Important : Le code utilise EXACTEMENT les mêmes termes que les experts métier.
  [X] ProcessEntity, DataObject, ItemRecord
  [OK] Task, Project, Member, Sprint

49.3 AGRÉGATS DANS TASKFLOW
─────────────────────────────

RÈGLES DES AGRÉGATS :
  1. Un agrégat = une unité de cohérence transactionnelle
  2. Les modifications passent TOUJOURS par la racine (Aggregate Root)
  3. Les références entre agrégats se font par UUID (pas d'objet direct)
  4. Une transaction = un agrégat (règle d'or)

AGRÉGAT PROJECT :
──────────────────

package com.taskflow.backend.domain.model;

/**
 * Aggregate Root : Project
 * Gère la cohérence de : Project + ProjectMember
 * (Les Tasks sont dans leur propre agrégat, référencé par UUID)
 */
public class Project {

    private final UUID id;
    private String name;
    private String description;
    private final UUID ownerId;
    private final List<ProjectMember> members;
    private ProjectStatus status;
    private final LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    private final List<Object> domainEvents = new ArrayList<>();

    // ─── Factory method ────────────────────────────────────────────
    public static Project create(UUID id, String name, UUID ownerId) {
        Project project = new Project(id, name, ownerId);
        // Le créateur est automatiquement OWNER
        project.members.add(ProjectMember.of(ownerId, ProjectRole.OWNER));
        project.domainEvents.add(new ProjectCreatedEvent(id, ownerId));
        return project;
    }

    // ─── Comportements ─────────────────────────────────────────────

    public void addMember(UUID userId, ProjectRole role) {
        // Invariant : un utilisateur ne peut être membre qu'une seule fois
        if (isMember(userId)) {
            throw new DomainException("User " + userId + " is already a member");
        }
        members.add(ProjectMember.of(userId, role));
        domainEvents.add(new ProjectMemberAddedEvent(id, userId, role));
    }

    public void removeMember(UUID userId, UUID requestedById) {
        // Invariant : impossible de retirer le propriétaire
        if (isOwner(userId)) {
            throw new DomainException("Cannot remove the project owner");
        }
        // Seul un ADMIN ou OWNER peut retirer un membre
        if (!isAdminOrOwner(requestedById)) {
            throw new ProjectAccessDeniedException(requestedById, id);
        }
        members.removeIf(m -> m.getUserId().equals(userId));
    }

    public void changeMemberRole(UUID userId, ProjectRole newRole, UUID requestedById) {
        if (!isOwner(requestedById)) {
            throw new ProjectAccessDeniedException(requestedById, id);
        }
        members.stream()
            .filter(m -> m.getUserId().equals(userId))
            .findFirst()
            .ifPresentOrElse(
                m -> m.changeRole(newRole),
                () -> { throw new DomainException("Member not found: " + userId); }
            );
    }

    public boolean isMember(UUID userId) {
        return members.stream().anyMatch(m -> m.getUserId().equals(userId));
    }

    public boolean isOwner(UUID userId) {
        return members.stream()
            .anyMatch(m -> m.getUserId().equals(userId) && m.getRole() == ProjectRole.OWNER);
    }

    private boolean isAdminOrOwner(UUID userId) {
        return members.stream()
            .anyMatch(m -> m.getUserId().equals(userId) &&
                (m.getRole() == ProjectRole.OWNER || m.getRole() == ProjectRole.ADMIN));
    }

    // Getters...
}

49.4 DOMAIN EVENTS
────────────────────

Les Domain Events représentent des faits métier qui se sont produits.
Ils découplent les agrégats et permettent des réactions asynchrones.

DÉFINITION D'UN DOMAIN EVENT :
────────────────────────────────

package com.taskflow.backend.domain.event;

import java.time.Instant;
import java.util.UUID;

/**
 * Base commune à tous les Domain Events.
 * Immuable, contient toutes les infos nécessaires pour les handlers.
 */
public sealed interface DomainEvent
    permits TaskCreatedEvent, TaskStatusChangedEvent,
            ProjectCreatedEvent, ProjectMemberAddedEvent {

    UUID eventId();
    Instant occurredAt();
}

public record TaskCreatedEvent(
    UUID eventId,
    Instant occurredAt,
    UUID taskId,
    UUID projectId,
    UUID createdById
) implements DomainEvent {

    public TaskCreatedEvent(UUID taskId, UUID projectId, UUID createdById) {
        this(UUID.randomUUID(), Instant.now(), taskId, projectId, createdById);
    }
}

public record TaskStatusChangedEvent(
    UUID eventId,
    Instant occurredAt,
    UUID taskId,
    TaskStatus oldStatus,
    TaskStatus newStatus,
    UUID changedById
) implements DomainEvent {

    public TaskStatusChangedEvent(UUID taskId, TaskStatus old,
                                   TaskStatus next, UUID changedById) {
        this(UUID.randomUUID(), Instant.now(), taskId, old, next, changedById);
    }
}

PUBLICATION ET HANDLING DES DOMAIN EVENTS (Spring) :
──────────────────────────────────────────────────────

package com.taskflow.backend.infrastructure.event;

import com.taskflow.backend.application.port.out.EventPublisherPort;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;

/**
 * Adaptateur : publie les Domain Events via Spring ApplicationEventPublisher.
 * Le domaine ne connaît pas Spring — il utilise l'interface EventPublisherPort.
 */
@Component
@RequiredArgsConstructor
public class SpringEventPublisherAdapter implements EventPublisherPort {

    private final ApplicationEventPublisher publisher;

    @Override
    public void publish(Object event) {
        publisher.publishEvent(event);
    }
}

HANDLERS DES DOMAIN EVENTS :
──────────────────────────────

package com.taskflow.backend.application.handler;

import com.taskflow.backend.domain.event.TaskCreatedEvent;
import com.taskflow.backend.domain.event.TaskStatusChangedEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@Component
@RequiredArgsConstructor
public class TaskDomainEventHandler {

    private final NotificationService notificationService;
    private final KafkaEventPublisher kafkaPublisher;
    private final AuditService auditService;

    /**
     * @TransactionalEventListener(phase = AFTER_COMMIT) :
     * Le handler ne s'exécute QU'APRÈS le commit de la transaction.
     * Évite les notifications si la transaction rollback.
     */
    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleTaskCreated(TaskCreatedEvent event) {
        log.info("Handling TaskCreatedEvent [taskId={}]", event.taskId());

        // Notifier l'assigné si la tâche est assignée
        notificationService.notifyTaskCreated(event.taskId(), event.projectId());

        // Publier sur Kafka pour les autres microservices
        kafkaPublisher.publish("tasks", event.taskId().toString(), event);

        // Log d'audit
        auditService.logAction(AuditAction.TASK_CREATED,
            "TASK", event.taskId().toString(), "Task created in project " + event.projectId());
    }

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleTaskStatusChanged(TaskStatusChangedEvent event) {
        log.info("Task {} changed from {} to {}",
            event.taskId(), event.oldStatus(), event.newStatus());

        // Notifier si la tâche vient d'être complétée
        if (event.newStatus() == TaskStatus.DONE) {
            notificationService.notifyTaskCompleted(event.taskId(), event.changedById());
        }

        kafkaPublisher.publish("task-status-changes", event.taskId().toString(), event);
    }
}

49.5 CQRS — COMMAND QUERY RESPONSIBILITY SEGREGATION
──────────────────────────────────────────────────────

CQRS sépare les opérations de lecture (Query) des opérations d'écriture
(Command) car elles ont des besoins très différents :
  - Écriture : logique métier complexe, consistance forte
  - Lecture : performance, vues dénormalisées, format spécifique à l'UI

SCHÉMA CQRS POUR TASKFLOW :

    ┌────────────────┐      COMMAND         ┌────────────────────┐
    │                │ ─────────────────->   │  Write Model       │
    │   API Client   │                      │  (Domain Entities) │
    │                │ <-─────────────────   │  (Use Cases)       │
    └────────────────┘      RESULT          └──────────┬─────────┘
           │                                           │
           │                                    Domain Events
           │                                           v
           │               QUERY         ┌────────────────────┐
           └──────────────────────────->  │  Read Model        │
                                         │  (Projections SQL) │
                                         │  (Dénormalisé)     │
                                         └────────────────────┘

IMPLÉMENTATION CQRS DANS TASKFLOW :
─────────────────────────────────────

// ─── COMMAND SIDE ───────────────────────────────────────────────────

// Bus de commandes
package com.taskflow.backend.application.command;

public interface CommandBus {
    <R> R dispatch(Command<R> command);
}

public interface Command<R> {}

// Exemples de commandes
public record CreateTaskCommand(
    String title, TaskPriority priority, UUID projectId, UUID createdById
) implements Command<TaskResult> {}

public record ChangeTaskStatusCommand(
    UUID taskId, TaskStatus newStatus, UUID changedById
) implements Command<Void> {}

// ─── QUERY SIDE ──────────────────────────────────────────────────────

// Les queries utilisent directement JPQL/SQL dénormalisé — pas les entités domaine
package com.taskflow.backend.application.query;

public interface QueryBus {
    <R> R dispatch(Query<R> query);
}

public interface Query<R> {}

// Query optimisée pour la vue "liste des tâches d'un projet"
public record GetProjectTasksQuery(
    UUID projectId,
    TaskStatus status,
    int page,
    int size
) implements Query<Page<TaskSummary>> {}

// Handler de la query — utilise directement la BD sans passer par le domaine
@Component
@RequiredArgsConstructor
public class GetProjectTasksQueryHandler implements QueryHandler<GetProjectTasksQuery, Page<TaskSummary>> {

    // Ici on peut utiliser directement une interface JPA optimisée
    // avec des projections SQL, des JOIN FETCH, etc.
    private final TaskQueryRepository taskQueryRepository;

    @Override
    @Transactional(readOnly = true)
    public Page<TaskSummary> handle(GetProjectTasksQuery query) {
        Pageable pageable = PageRequest.of(query.page(), query.size(),
            Sort.by("dueDate").ascending());

        return taskQueryRepository.findProjectTasksSummary(
            query.projectId(), query.status(), pageable);
    }
}

// Interface de lecture OPTIMISÉE (différente du Repository du domaine)
public interface TaskQueryRepository {

    @Query("""
        SELECT new com.taskflow.backend.application.query.TaskSummary(
            t.uuid, t.title, t.status, t.priority, t.dueDate,
            u.uuid, u.firstName, u.lastName,
            t.createdAt
        )
        FROM TaskJpaEntity t
        LEFT JOIN UserJpaEntity u ON t.assigneeUuid = u.uuid
        WHERE t.projectUuid = :projectId
          AND (:status IS NULL OR t.status = :status)
        """)
    Page<TaskSummary> findProjectTasksSummary(
        @Param("projectId") UUID projectId,
        @Param("status") TaskStatus status,
        Pageable pageable
    );
}

─────────────────────────────────────────────────────────────────────
BONNES PRATIQUES — CLEAN ARCH & DDD
─────────────────────────────────────────────────────────────────────

1. COMMENCER SIMPLE, ÉVOLUER VERS LA COMPLEXITÉ
   -> Ne pas appliquer toute la Clean Architecture dès le début
   -> Commencer par extraire la logique dans les entités domaine
   -> Ajouter les Use Cases quand les Services deviennent trop gros

2. LE DOMAINE NE DÉPEND DE RIEN
   -> Aucun import de Spring, JPA, Jackson dans les classes domaine
   -> Tester le domaine sans aucune configuration Spring

3. 1 TRANSACTION = 1 AGRÉGAT
   -> Ne pas modifier 2 agrégats dans la même transaction
   -> Utiliser les Domain Events pour la cohérence éventuelle

4. LES REFERENCES ENTRE AGRÉGATS PAR UUID
   -> Task référence Project par son UUID, pas l'objet Project
   -> Évite les problèmes de chargement et de couplage fort

5. LES VALUE OBJECTS POUR ENCAPSULER LES RÈGLES DE VALIDATION
   -> Email valide dès sa création
   -> Impossible de créer un Email invalide
   -> Les services n'ont pas à valider l'email — il est déjà valide

6. DOMAIN EVENTS POUR LE DÉCOUPLAGE
   -> Notifier en async, après commit
   -> Le domaine ne connaît pas les side-effects (email, Kafka)

7. UBIQUITOUS LANGUAGE DANS LE CODE
   -> Les noms de classes et méthodes = termes métier
   -> task.complete() plutôt que task.setStatus(DONE)
   -> project.addMember() plutôt que memberList.add()

─────────────────────────────────────────────────────────────────────
EXERCICES
─────────────────────────────────────────────────────────────────────

NIVEAU DÉBUTANT :
  1. Créer l'entité domaine User sans aucune annotation Spring/JPA.
     Implémenter les méthodes : activate(), deactivate(), changeEmail(),
     changePassword() avec les invariants métier correspondants.

  2. Créer le Value Object Money(amount, currency) immuable avec
     les opérations add(), subtract(), multiply() et les règles :
     montant positif, devises compatibles pour les opérations.

  3. Restructurer le package com.taskflow.backend en utilisant
     la structure domain/application/infrastructure/adapter.
     (Déplacer les classes existantes sans les modifier.)

NIVEAU INTERMÉDIAIRE :
  1. Implémenter le Use Case AddProjectMemberUseCase qui :
     - Vérifie que le projet existe
     - Vérifie que l'utilisateur existe
     - Vérifie que le demandeur est OWNER ou ADMIN
     - Délègue à project.addMember()
     - Persiste et publie les domain events.

  2. Implémenter un Domain Service ProjectStatsDomainService
     qui calcule les statistiques d'un projet (tâches par statut,
     taux de complétion, tâches en retard). Ce service utilise
     TaskRepository et ne connaît pas Spring.

  3. Implémenter le CQRS pour la fonctionnalité "Dashboard utilisateur" :
     - Command : UpdateTaskStatusCommand
     - Query : GetUserDashboardQuery (tâches assignées, projets actifs)
     Créer une vue dénormalisée avec une requête SQL optimisée.

NIVEAU AVANCÉ :
  1. Implémenter un système d'Event Sourcing simple pour l'agrégat Task :
     - Stocker les TaskDomainEvents en BD (table task_events)
     - Reconstituer l'état d'une Task en rejouant ses events
     - Implémenter les snapshots après N events

  2. Séparer TaskFlow en 2 Bounded Contexts (Identity + ProjectManagement)
     en modules Maven séparés. Gérer la communication via Domain Events
     (TaskCreatedEvent déclenche la création d'une notification).

  3. Implémenter un CommandBus avec middleware : logging, validation,
     transaction, métriques (pattern decorator/chain of responsibility).
     Chaque Command est tracée dans Zipkin.

================================================================================
RÉSUMÉ PARTIE 17
================================================================================

CHAPITRE 48 — CLEAN ARCHITECTURE :
  [OK] Problèmes de l'architecture en couches traditionnelle
  [OK] Les 4 cercles de la Clean Architecture (Domain -> Application -> Infra -> Interface)
  [OK] Restructuration complète de TaskFlow (domain/application/infrastructure/adapter)
  [OK] Entité domaine Task pure (sans Spring/JPA) avec factory method + machine d'états
  [OK] Value Object Email (immuable, validation dès la création)
  [OK] TaskStatus enum avec machine d'états (canTransitionTo, isTerminal)
  [OK] Port du domaine TaskRepository (interface) vs Adaptateur JPA
  [OK] Use Case CreateTaskUseCase (orchestration sans connaître HTTP)
  [OK] Command/Result records pour les Use Cases
  [OK] Entité JPA distincte de l'entité domaine + mapper bidirectionnel
  [OK] Adaptateur TaskRepositoryAdapter (implémente le port domaine)
  [OK] Controller ultra-mince (traduit HTTP <-> Command)

CHAPITRE 49 — DDD :
  [OK] 8 Building Blocks DDD (Entity, Value Object, Aggregate, etc.)
  [OK] Découpage des Bounded Contexts TaskFlow (IAM, ProjectManagement, Notif, Reporting)
  [OK] Ubiquitous Language — le code parle le même langage que les experts métier
  [OK] Agrégat Project avec invariants (owner non retirable, membre unique)
  [OK] Domain Events (sealed interface, records immuables)
  [OK] Publication via EventPublisherPort + SpringEventPublisherAdapter
  [OK] Handlers TransactionalEventListener(AFTER_COMMIT) + @Async
  [OK] CQRS : CommandBus, QueryBus, Query optimisée sans passer par le domaine
  [OK] Projections SQL dénormalisées pour les lectures (Page<TaskSummary>)

================================================================================
FIN PARTIE 17
Prochaine partie -> Projets pratiques & Projet Final TaskFlow complet
================================================================================

================================================================================
GUIDE SPRING BOOT ENTREPRISE — PARTIES 18 & 19
PROJETS PRATIQUES GUIDÉS
Chapitres 50-51
================================================================================

TABLE DES MATIÈRES
────────────────────
Chapitre 50 : Projet Pratique 1 — API de Blog multi-auteur
Chapitre 51 : Projet Pratique 2 — Notifications en temps réel (SSE)

================================================================================
CHAPITRE 50 : PROJET PRATIQUE 1 — API DE BLOG
================================================================================

50.1 CAHIER DES CHARGES
────────────────────────

OBJECTIF : Construire une API REST complète de blog multi-auteur
intégrable dans TaskFlow (journal de bord de projet).

FONCTIONNALITÉS :
  * CRUD articles (create, read, update, delete)
  * Commentaires imbriqués (2 niveaux max)
  * Système de likes pour articles et commentaires
  * Catégories et tags
  * Recherche full-text PostgreSQL
  * Pagination et tri
  * Contrôle d'accès (auteur peut modifier ses articles)

MODÈLE DE DONNÉES :
  Article(id, uuid, title, slug, content, summary, status, authorId,
          categoryId, viewCount, likeCount, publishedAt, createdAt, updatedAt)
  Comment(id, uuid, content, authorId, articleId, parentId, likeCount, ...)
  Category(id, name, slug, description)
  Tag(id, name, slug)
  ArticleTag(articleId, tagId)  <- Relation N-N
  Like(userId, targetType, targetId)  <- Polymorphique

ENDPOINTS REST :
  POST   /api/v1/blog/articles               <- Créer un article
  GET    /api/v1/blog/articles               <- Lister (paginé + filtré)
  GET    /api/v1/blog/articles/{slug}        <- Lire (incrémente view_count)
  PUT    /api/v1/blog/articles/{uuid}        <- Modifier (auteur/admin)
  DELETE /api/v1/blog/articles/{uuid}        <- Supprimer (auteur/admin)
  POST   /api/v1/blog/articles/{uuid}/publish <- Publier (DRAFT -> PUBLISHED)
  POST   /api/v1/blog/articles/{uuid}/like    <- Liker / unliker (toggle)

  POST   /api/v1/blog/articles/{uuid}/comments  <- Commenter
  GET    /api/v1/blog/articles/{uuid}/comments  <- Lister commentaires
  DELETE /api/v1/blog/comments/{uuid}           <- Supprimer commentaire
  POST   /api/v1/blog/comments/{uuid}/like      <- Liker commentaire

  GET    /api/v1/blog/categories             <- Lister catégories
  GET    /api/v1/blog/tags                   <- Tags avec compteur
  GET    /api/v1/blog/search?q=spring        <- Recherche full-text

─────────────────────────────────────────────────────────────────────
50.2 MIGRATION FLYWAY
─────────────────────────────────────────────────────────────────────

-- V10__create_blog_tables.sql

CREATE TABLE blog_categories (
    id          BIGSERIAL PRIMARY KEY,
    uuid        UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
    name        VARCHAR(100) NOT NULL UNIQUE,
    slug        VARCHAR(100) NOT NULL UNIQUE,
    description TEXT,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE blog_tags (
    id         BIGSERIAL PRIMARY KEY,
    uuid       UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
    name       VARCHAR(50) NOT NULL UNIQUE,
    slug       VARCHAR(50) NOT NULL UNIQUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE blog_articles (
    id           BIGSERIAL PRIMARY KEY,
    uuid         UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
    title        VARCHAR(255) NOT NULL,
    slug         VARCHAR(255) NOT NULL UNIQUE,
    content      TEXT NOT NULL,
    summary      TEXT,
    status       VARCHAR(20) NOT NULL DEFAULT 'DRAFT'
                 CHECK (status IN ('DRAFT','PUBLISHED','ARCHIVED')),
    author_id    BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    category_id  BIGINT REFERENCES blog_categories(id) ON DELETE SET NULL,
    view_count   INT NOT NULL DEFAULT 0,
    like_count   INT NOT NULL DEFAULT 0,
    published_at TIMESTAMPTZ,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    -- Index full-text PostgreSQL (tsvector généré automatiquement)
    search_vector TSVECTOR GENERATED ALWAYS AS (
        to_tsvector('french',
            coalesce(title,'') || ' ' ||
            coalesce(summary,'') || ' ' ||
            content)
    ) STORED
);

CREATE INDEX idx_blog_articles_author     ON blog_articles(author_id);
CREATE INDEX idx_blog_articles_status     ON blog_articles(status);
CREATE INDEX idx_blog_articles_published  ON blog_articles(published_at DESC);
CREATE INDEX idx_blog_articles_search     ON blog_articles USING GIN(search_vector);

CREATE TABLE blog_article_tags (
    article_id BIGINT NOT NULL REFERENCES blog_articles(id) ON DELETE CASCADE,
    tag_id     BIGINT NOT NULL REFERENCES blog_tags(id)     ON DELETE CASCADE,
    PRIMARY KEY (article_id, tag_id)
);

CREATE TABLE blog_comments (
    id         BIGSERIAL PRIMARY KEY,
    uuid       UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
    content    TEXT NOT NULL,
    author_id  BIGINT NOT NULL REFERENCES users(id)         ON DELETE CASCADE,
    article_id BIGINT NOT NULL REFERENCES blog_articles(id) ON DELETE CASCADE,
    parent_id  BIGINT REFERENCES blog_comments(id)         ON DELETE CASCADE,
    like_count INT NOT NULL DEFAULT 0,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_blog_comments_article ON blog_comments(article_id);
CREATE INDEX idx_blog_comments_parent  ON blog_comments(parent_id);

-- Likes polymorphiques : un utilisateur like un article ou un commentaire
CREATE TABLE blog_likes (
    user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    target_type VARCHAR(20) NOT NULL CHECK (target_type IN ('ARTICLE','COMMENT')),
    target_id   BIGINT NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    PRIMARY KEY (user_id, target_type, target_id)
);

CREATE INDEX idx_blog_likes_target ON blog_likes(target_type, target_id);

─────────────────────────────────────────────────────────────────────
50.3 ENTITÉ ARTICLE
─────────────────────────────────────────────────────────────────────

package com.taskflow.backend.blog.entity;

@Entity
@Table(name = "blog_articles")
@SQLRestriction("status != 'ARCHIVED'")
@EntityListeners(AuditingEntityListener.class)
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class BlogArticle extends BaseEntity {

    @Column(nullable = false)
    private String title;

    @Column(nullable = false, unique = true)
    private String slug;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;

    @Column(columnDefinition = "TEXT")
    private String summary;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private ArticleStatus status = ArticleStatus.DRAFT;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id", nullable = false)
    private User author;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")
    private BlogCategory category;

    @ManyToMany(fetch = FetchType.LAZY)
    @JoinTable(name = "blog_article_tags",
        joinColumns        = @JoinColumn(name = "article_id"),
        inverseJoinColumns = @JoinColumn(name = "tag_id"))
    private Set<BlogTag> tags = new HashSet<>();

    @Column(nullable = false)
    private int viewCount = 0;

    @Column(nullable = false)
    private int likeCount = 0;

    private LocalDateTime publishedAt;

    // ─── Comportements métier ──────────────────────────────────────

    public void publish() {
        if (this.status != ArticleStatus.DRAFT) {
            throw new IllegalStateException("Only DRAFT articles can be published");
        }
        this.status      = ArticleStatus.PUBLISHED;
        this.publishedAt = LocalDateTime.now();
    }

    public void incrementViewCount() { this.viewCount++; }
    public void addLike()    { this.likeCount++; }
    public void removeLike() { this.likeCount = Math.max(0, likeCount - 1); }

    public boolean isAuthor(UUID userId) {
        return author != null && author.getUuid().equals(userId);
    }

    /**
     * Génère un slug URL à partir du titre.
     * "Mon Article Spring Boot" -> "mon-article-spring-boot"
     */
    public static String generateSlug(String title) {
        return title.toLowerCase()
            .replaceAll("[àáâãäå]", "a").replaceAll("[èéêë]", "e")
            .replaceAll("[ìíîï]",   "i").replaceAll("[òóôõö]", "o")
            .replaceAll("[ùúûü]",   "u").replaceAll("[ç]", "c")
            .replaceAll("[^a-z0-9\\s-]", "")
            .replaceAll("\\s+", "-")
            .replaceAll("-+", "-")
            .replaceAll("^-|-$", "");
    }
}

─────────────────────────────────────────────────────────────────────
50.4 REPOSITORY AVEC FULL-TEXT SEARCH
─────────────────────────────────────────────────────────────────────

@Repository
public interface BlogArticleRepository extends JpaRepository<BlogArticle, Long> {

    Optional<BlogArticle> findByUuid(UUID uuid);
    Optional<BlogArticle> findBySlug(String slug);
    boolean existsBySlug(String slug);

    @Query("""
        SELECT a FROM BlogArticle a
        LEFT JOIN FETCH a.author
        LEFT JOIN FETCH a.category
        WHERE a.status = 'PUBLISHED'
          AND (:categorySlug IS NULL OR a.category.slug = :categorySlug)
          AND (:tagSlug IS NULL OR EXISTS (
                SELECT 1 FROM a.tags t WHERE t.slug = :tagSlug))
        ORDER BY a.publishedAt DESC
        """)
    Page<BlogArticle> findPublished(
        @Param("categorySlug") String categorySlug,
        @Param("tagSlug")      String tagSlug,
        Pageable pageable);

    // Recherche full-text PostgreSQL — to_tsquery et ts_rank
    @Query(value = """
        SELECT a.*, ts_rank(a.search_vector, query) AS rank
        FROM blog_articles a, to_tsquery('french', :tsQuery) query
        WHERE a.search_vector @@ query
          AND a.status = 'PUBLISHED'
        ORDER BY rank DESC
        """,
        countQuery = """
            SELECT COUNT(*) FROM blog_articles a,
                to_tsquery('french', :tsQuery) query
            WHERE a.search_vector @@ query
              AND a.status = 'PUBLISHED'
            """,
        nativeQuery = true)
    Page<BlogArticle> searchFullText(
        @Param("tsQuery") String tsQuery, Pageable pageable);

    @Modifying
    @Query("UPDATE BlogArticle a SET a.viewCount = a.viewCount + 1 " +
           "WHERE a.uuid = :uuid")
    void incrementViewCount(@Param("uuid") UUID uuid);
}

─────────────────────────────────────────────────────────────────────
50.5 BLOG SERVICE
─────────────────────────────────────────────────────────────────────

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class BlogServiceImpl implements BlogService {

    private final BlogArticleRepository articleRepository;
    private final BlogCommentRepository commentRepository;
    private final BlogLikeRepository    likeRepository;
    private final BlogTagRepository     tagRepository;
    private final BlogCategoryRepository categoryRepository;
    private final UserRepository        userRepository;
    private final BlogMapper            blogMapper;

    // ─── Créer un article ─────────────────────────────────────────
    @Override
    public ArticleResponse createArticle(CreateArticleRequest req, UUID authorUuid) {
        User author = userRepository.findByUuid(authorUuid)
            .orElseThrow(() -> new UserNotFoundException(authorUuid));

        String slug = ensureUniqueSlug(BlogArticle.generateSlug(req.getTitle()));

        BlogCategory category = null;
        if (req.getCategoryUuid() != null) {
            category = categoryRepository.findByUuid(req.getCategoryUuid())
                .orElseThrow(() -> new NotFoundException("Category not found"));
        }

        // Créer les tags manquants à la volée
        Set<BlogTag> tags = new HashSet<>();
        if (req.getTagNames() != null) {
            for (String tagName : req.getTagNames()) {
                String tagSlug = BlogArticle.generateSlug(tagName);
                tags.add(tagRepository.findBySlug(tagSlug)
                    .orElseGet(() -> tagRepository.save(
                        BlogTag.builder().name(tagName).slug(tagSlug).build())));
            }
        }

        BlogArticle article = BlogArticle.builder()
            .title(req.getTitle()).slug(slug)
            .content(req.getContent()).summary(req.getSummary())
            .status(ArticleStatus.DRAFT)
            .author(author).category(category).tags(tags)
            .build();

        return blogMapper.toArticleResponse(articleRepository.save(article), false);
    }

    // ─── Lire un article (incrémente view_count) ──────────────────
    @Override
    @Transactional(readOnly = true)
    public ArticleResponse getArticle(String slug, UUID currentUserId) {
        BlogArticle article = articleRepository.findBySlug(slug)
            .orElseThrow(() -> new NotFoundException("Article not found: " + slug));

        // Incrément optimisé sans charger l'entité complète
        articleRepository.incrementViewCount(article.getUuid());

        boolean liked = currentUserId != null &&
            likeRepository.exists(currentUserId, LikeTargetType.ARTICLE, article.getId());

        return blogMapper.toArticleResponse(article, liked);
    }

    // ─── Toggle like article ───────────────────────────────────────
    @Override
    public void toggleLike(UUID articleUuid, UUID userUuid) {
        BlogArticle article = articleRepository.findByUuid(articleUuid)
            .orElseThrow(() -> new NotFoundException("Article not found"));
        User user = userRepository.findByUuid(userUuid)
            .orElseThrow(() -> new UserNotFoundException(userUuid));

        if (likeRepository.exists(user.getId(), LikeTargetType.ARTICLE, article.getId())) {
            likeRepository.delete(user.getId(), LikeTargetType.ARTICLE, article.getId());
            article.removeLike();
        } else {
            likeRepository.save(new BlogLike(user.getId(), LikeTargetType.ARTICLE, article.getId()));
            article.addLike();
        }
    }

    // ─── Commenter un article ─────────────────────────────────────
    @Override
    public CommentResponse addComment(UUID articleUuid,
                                       CreateCommentRequest req, UUID authorUuid) {
        BlogArticle article = articleRepository.findByUuid(articleUuid)
            .orElseThrow(() -> new NotFoundException("Article not found"));

        if (article.getStatus() != ArticleStatus.PUBLISHED) {
            throw new BusinessException("Cannot comment on unpublished article");
        }

        User author = userRepository.findByUuid(authorUuid)
            .orElseThrow(() -> new UserNotFoundException(authorUuid));

        BlogComment parent = null;
        if (req.getParentUuid() != null) {
            parent = commentRepository.findByUuid(req.getParentUuid())
                .orElseThrow(() -> new NotFoundException("Parent comment not found"));
            if (parent.getParent() != null) {
                throw new BusinessException("Max comment depth is 2 levels");
            }
        }

        BlogComment comment = BlogComment.builder()
            .content(req.getContent()).author(author)
            .article(article).parent(parent).build();

        return blogMapper.toCommentResponse(commentRepository.save(comment), false);
    }

    // ─── Recherche full-text ──────────────────────────────────────
    @Override
    @Transactional(readOnly = true)
    public PagedResponse<ArticleSummaryResponse> search(String q, Pageable pageable) {
        if (q == null || q.trim().length() < 3) {
            throw new ValidationException("Search query must be at least 3 characters");
        }
        // Convertir en format tsquery : "spring boot" -> "spring & boot"
        String tsQuery = Arrays.stream(q.trim().split("\\s+"))
            .filter(w -> w.length() >= 2)
            .collect(Collectors.joining(" & "));

        Page<BlogArticle> page = articleRepository.searchFullText(tsQuery, pageable);
        return PagedResponse.from(page.map(a -> blogMapper.toSummaryResponse(a, false)));
    }

    private String ensureUniqueSlug(String base) {
        String slug = base;
        int n = 1;
        while (articleRepository.existsBySlug(slug)) {
            slug = base + "-" + n++;
        }
        return slug;
    }
}

─────────────────────────────────────────────────────────────────────
50.6 BLOG CONTROLLER
─────────────────────────────────────────────────────────────────────

@RestController
@RequestMapping("/api/v1/blog")
@RequiredArgsConstructor
@Tag(name = "Blog")
public class BlogController {

    private final BlogService blogService;

    @PostMapping("/articles")
    @PreAuthorize("isAuthenticated()")
    @ResponseStatus(HttpStatus.CREATED)
    public ApiResponse<ArticleResponse> create(
            @Valid @RequestBody CreateArticleRequest req,
            @AuthenticationPrincipal UserPrincipal p) {
        return ApiResponse.success("Article created",
            blogService.createArticle(req, p.getUuid()));
    }

    @GetMapping("/articles")
    public ApiResponse<PagedResponse<ArticleSummaryResponse>> list(
            @RequestParam(required = false) String category,
            @RequestParam(required = false) String tag,
            @PageableDefault(size = 10, sort = "publishedAt",
                             direction = Sort.Direction.DESC) Pageable pageable) {
        return ApiResponse.success("OK", blogService.listArticles(category, tag, pageable));
    }

    @GetMapping("/articles/{slug}")
    public ApiResponse<ArticleResponse> get(
            @PathVariable String slug,
            @AuthenticationPrincipal UserPrincipal p) {
        UUID userId = p != null ? p.getUuid() : null;
        return ApiResponse.success("OK", blogService.getArticle(slug, userId));
    }

    @PutMapping("/articles/{uuid}")
    @PreAuthorize("isAuthenticated()")
    public ApiResponse<ArticleResponse> update(
            @PathVariable UUID uuid,
            @Valid @RequestBody UpdateArticleRequest req,
            @AuthenticationPrincipal UserPrincipal p) {
        return ApiResponse.success("Updated", blogService.updateArticle(uuid, req, p.getUuid()));
    }

    @DeleteMapping("/articles/{uuid}")
    @PreAuthorize("isAuthenticated()")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable UUID uuid,
                       @AuthenticationPrincipal UserPrincipal p) {
        blogService.deleteArticle(uuid, p.getUuid());
    }

    @PostMapping("/articles/{uuid}/publish")
    @PreAuthorize("isAuthenticated()")
    public ApiResponse<ArticleResponse> publish(
            @PathVariable UUID uuid, @AuthenticationPrincipal UserPrincipal p) {
        return ApiResponse.success("Published", blogService.publishArticle(uuid, p.getUuid()));
    }

    @PostMapping("/articles/{uuid}/like")
    @PreAuthorize("isAuthenticated()")
    public ApiResponse<Void> toggleLike(
            @PathVariable UUID uuid, @AuthenticationPrincipal UserPrincipal p) {
        blogService.toggleLike(uuid, p.getUuid());
        return ApiResponse.success("Like toggled", null);
    }

    @PostMapping("/articles/{uuid}/comments")
    @PreAuthorize("isAuthenticated()")
    @ResponseStatus(HttpStatus.CREATED)
    public ApiResponse<CommentResponse> addComment(
            @PathVariable UUID uuid,
            @Valid @RequestBody CreateCommentRequest req,
            @AuthenticationPrincipal UserPrincipal p) {
        return ApiResponse.success("Comment added",
            blogService.addComment(uuid, req, p.getUuid()));
    }

    @GetMapping("/articles/{uuid}/comments")
    public ApiResponse<List<CommentResponse>> getComments(
            @PathVariable UUID uuid,
            @AuthenticationPrincipal UserPrincipal p) {
        UUID userId = p != null ? p.getUuid() : null;
        return ApiResponse.success("OK", blogService.getComments(uuid, userId));
    }

    @GetMapping("/search")
    public ApiResponse<PagedResponse<ArticleSummaryResponse>> search(
            @RequestParam String q,
            @PageableDefault(size = 10) Pageable pageable) {
        return ApiResponse.success("OK", blogService.search(q, pageable));
    }
}

─────────────────────────────────────────────────────────────────────
50.7 TESTS — API BLOG
─────────────────────────────────────────────────────────────────────

@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
@ActiveProfiles("test")
class BlogControllerIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired MockMvc mockMvc;
    @Autowired ObjectMapper objectMapper;
    @Autowired UserRepository userRepository;
    @Autowired BlogArticleRepository articleRepository;

    private String authorToken;
    private UUID authorUuid;

    @BeforeEach
    void setup() {
        // Créer un auteur et obtenir son JWT pour les tests
        authorToken = createTestUserAndGetToken("author@test.com", "password");
        authorUuid  = getUserUuid("author@test.com");
    }

    @Test
    @DisplayName("Créer un article — retourne 201 avec le slug généré")
    void createArticle_success() throws Exception {
        var request = Map.of(
            "title",   "Introduction à Spring Boot",
            "content", "Spring Boot simplifie le développement Java...",
            "summary", "Un guide complet pour débuter avec Spring Boot"
        );

        mockMvc.perform(post("/api/v1/blog/articles")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request))
                .header("Authorization", "Bearer " + authorToken))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.success").value(true))
            .andExpect(jsonPath("$.data.slug").value("introduction-a-spring-boot"))
            .andExpect(jsonPath("$.data.status").value("DRAFT"))
            .andExpect(jsonPath("$.data.viewCount").value(0));
    }

    @Test
    @DisplayName("Recherche full-text — retourne les articles pertinents")
    void searchFullText_returnsRelevantArticles() throws Exception {
        // Créer et publier un article sur Spring
        createAndPublishTestArticle("Guide Spring Security",
            "Spring Security protège votre application...");

        // Créer un autre article non lié
        createAndPublishTestArticle("Introduction à Docker",
            "Docker permet de containeriser vos applications...");

        mockMvc.perform(get("/api/v1/blog/search?q=spring"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.data.content[0].title")
                .value("Guide Spring Security"))
            .andExpect(jsonPath("$.data.totalElements").value(1));
    }

    @Test
    @DisplayName("Liker un article — toggle like")
    void toggleLike_incrementsAndDecrementsCount() throws Exception {
        UUID articleUuid = createTestArticleAndPublish();

        // Premier like
        mockMvc.perform(post("/api/v1/blog/articles/" + articleUuid + "/like")
                .header("Authorization", "Bearer " + authorToken))
            .andExpect(status().isOk());

        assertThat(articleRepository.findByUuid(articleUuid).get().getLikeCount())
            .isEqualTo(1);

        // Deuxième appel — unlike
        mockMvc.perform(post("/api/v1/blog/articles/" + articleUuid + "/like")
                .header("Authorization", "Bearer " + authorToken))
            .andExpect(status().isOk());

        assertThat(articleRepository.findByUuid(articleUuid).get().getLikeCount())
            .isEqualTo(0);
    }

    @Test
    @DisplayName("Commenter imbriqué — 3ème niveau rejeté")
    void addComment_thirdLevelRejected() throws Exception {
        UUID articleUuid = createTestArticleAndPublish();
        UUID comment1Uuid = addComment(articleUuid, "Commentaire niveau 1", null);
        UUID comment2Uuid = addComment(articleUuid, "Réponse niveau 2", comment1Uuid);

        // Tentative de réponse à une réponse (niveau 3 interdit)
        var request = Map.of(
            "content",    "Niveau 3 interdit",
            "parentUuid", comment2Uuid.toString()
        );

        mockMvc.perform(post("/api/v1/blog/articles/" + articleUuid + "/comments")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request))
                .header("Authorization", "Bearer " + authorToken))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.message").value(
                containsString("Max comment depth is 2")));
    }
}

================================================================================
CHAPITRE 51 : PROJET PRATIQUE 2 — NOTIFICATIONS EN TEMPS RÉEL (SSE)
================================================================================

51.1 CAHIER DES CHARGES
────────────────────────

OBJECTIF : Système de notifications push sans polling.
Les utilisateurs voient immédiatement les événements qui les concernent.

TYPES DE NOTIFICATIONS :
  task.assigned          -> Tâche assignée à l'utilisateur
  task.status_changed    -> Statut d'une tâche assignée modifié
  task.comment_added     -> Nouveau commentaire sur une tâche
  task.due_soon          -> Tâche due dans moins de 24h (schedulé)
  project.member_added   -> Ajouté à un projet
  project.member_removed -> Retiré d'un projet
  mention                -> Mentionné (@username) dans un commentaire

TECHNOLOGIE CHOISIE : SSE (Server-Sent Events)
  [OK] Plus simple que WebSocket
  [OK] Unidirectionnel (serveur -> client) — suffisant pour les notifications
  [OK] Reconnexion automatique du client
  [OK] Compatible avec Spring MVC (pas besoin de Spring WebFlux)

SCHÉMA DE FLUX :
  Domain Event          -> (AFTER_COMMIT)
       v
  NotificationService   -> Persiste en BD
       v
  SseEmitterService     -> Pousse vers le client connecté
       v
  Browser EventSource   -> Affiche la notification

─────────────────────────────────────────────────────────────────────
51.2 MIGRATION FLYWAY
─────────────────────────────────────────────────────────────────────

-- V11__create_notifications_table.sql

CREATE TABLE notifications (
    id         BIGSERIAL PRIMARY KEY,
    uuid       UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(),
    user_id    BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    type       VARCHAR(50)  NOT NULL,
    title      VARCHAR(255) NOT NULL,
    message    TEXT NOT NULL,
    link       VARCHAR(500),      -- URL vers la ressource (optionnel)
    is_read    BOOLEAN NOT NULL DEFAULT FALSE,
    data       JSONB,             -- Payload supplémentaire (taskUuid, etc.)
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_notifications_user_unread
    ON notifications(user_id, is_read, created_at DESC);

CREATE INDEX idx_notifications_user_date
    ON notifications(user_id, created_at DESC);

─────────────────────────────────────────────────────────────────────
51.3 ENTITÉ ET REPOSITORY
─────────────────────────────────────────────────────────────────────

@Entity
@Table(name = "notifications")
@Getter @Setter @Builder
@NoArgsConstructor @AllArgsConstructor
public class Notification {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private UUID uuid;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Column(nullable = false) private String type;
    @Column(nullable = false) private String title;
    @Column(columnDefinition = "TEXT", nullable = false) private String message;
    private String link;

    @Column(nullable = false)
    private boolean isRead = false;

    @Type(JsonType.class)
    @Column(columnDefinition = "jsonb")
    private Map<String, Object> data;

    @Column(nullable = false)
    private LocalDateTime createdAt = LocalDateTime.now();

    public void markAsRead() { this.isRead = true; }
}

public interface NotificationRepository extends JpaRepository<Notification, Long> {

    Optional<Notification> findByUuid(UUID uuid);

    @Query("SELECT n FROM Notification n WHERE n.user.uuid = :uuid " +
           "ORDER BY n.createdAt DESC")
    Page<Notification> findByUserUuid(@Param("uuid") UUID uuid, Pageable pageable);

    long countByUserUuidAndIsReadFalse(UUID userUuid);

    @Modifying
    @Query("UPDATE Notification n SET n.isRead = true " +
           "WHERE n.user.uuid = :uuid AND n.isRead = false")
    int markAllAsRead(@Param("uuid") UUID uuid);
}

─────────────────────────────────────────────────────────────────────
51.4 SERVICE SSE EMITTER
─────────────────────────────────────────────────────────────────────

package com.taskflow.backend.notification.sse;

/**
 * Gère le registre des connexions SSE actives.
 * Un utilisateur peut avoir plusieurs connexions simultanées
 * (plusieurs onglets/appareils). ConcurrentHashMap pour thread-safety.
 */
@Slf4j
@Service
public class SseEmitterService {

    // userId -> (emitterId -> SseEmitter)
    private final Map<UUID, Map<String, SseEmitter>> registry = new ConcurrentHashMap<>();

    private static final long SSE_TIMEOUT_MS = 30 * 60 * 1000L; // 30 minutes

    private final ScheduledExecutorService heartbeat =
        Executors.newSingleThreadScheduledExecutor();

    public SseEmitterService() {
        // Heartbeat toutes les 20s — évite que les proxy/LB ferment la connexion idle
        heartbeat.scheduleAtFixedRate(this::sendHeartbeatAll, 20, 20, TimeUnit.SECONDS);
    }

    public SseEmitter subscribe(UUID userId) {
        SseEmitter emitter  = new SseEmitter(SSE_TIMEOUT_MS);
        String      eid     = UUID.randomUUID().toString();

        registry.computeIfAbsent(userId, k -> new ConcurrentHashMap<>())
                .put(eid, emitter);

        log.info("SSE subscribed [userId={} emitterId={}]", userId, eid);

        Runnable cleanup = () -> {
            var userMap = registry.get(userId);
            if (userMap != null) {
                userMap.remove(eid);
                if (userMap.isEmpty()) registry.remove(userId);
            }
            log.info("SSE unsubscribed [userId={} emitterId={}]", userId, eid);
        };

        emitter.onCompletion(cleanup);
        emitter.onTimeout(cleanup);
        emitter.onError(e -> cleanup.run());

        // Évenement initial de confirmation de connexion
        try {
            emitter.send(SseEmitter.event()
                .name("connected")
                .data(Map.of("userId", userId, "ts", Instant.now())));
        } catch (IOException e) {
            log.warn("Initial SSE event failed", e);
        }

        return emitter;
    }

    /**
     * Envoie une notification JSON à tous les emitters de l'utilisateur.
     */
    public void push(UUID userId, Object payload) {
        Map<String, SseEmitter> emitters = registry.get(userId);
        if (emitters == null || emitters.isEmpty()) return;

        emitters.forEach((eid, emitter) -> {
            try {
                emitter.send(SseEmitter.event()
                    .name("notification")
                    .data(payload)
                    .reconnectTime(3000));
            } catch (IOException e) {
                log.warn("SSE send failed [emitterId={}] — removing", eid);
                emitter.completeWithError(e);
            }
        });
    }

    public boolean isConnected(UUID userId) {
        var m = registry.get(userId);
        return m != null && !m.isEmpty();
    }

    public int connectionCount() {
        return registry.values().stream().mapToInt(Map::size).sum();
    }

    private void sendHeartbeatAll() {
        registry.forEach((userId, emitters) ->
            emitters.forEach((eid, emitter) -> {
                try {
                    emitter.send(SseEmitter.event()
                        .name("heartbeat")
                        .data(Map.of("ts", System.currentTimeMillis())));
                } catch (IOException e) {
                    emitter.completeWithError(e);
                }
            })
        );
    }
}

─────────────────────────────────────────────────────────────────────
51.5 NOTIFICATION SERVICE
─────────────────────────────────────────────────────────────────────

@Slf4j
@Service
@RequiredArgsConstructor
public class NotificationService {

    private final NotificationRepository notifRepo;
    private final UserRepository         userRepo;
    private final SseEmitterService      sseService;
    private final NotificationMapper     mapper;

    /**
     * Crée et persiste une notification, puis la pousse via SSE
     * si l'utilisateur est connecté.
     */
    @Transactional
    public NotificationDto createAndSend(
            UUID recipientUuid, String type,
            String title, String message,
            String link, Map<String, Object> data) {

        User recipient = userRepo.findByUuid(recipientUuid)
            .orElseThrow(() -> new UserNotFoundException(recipientUuid));

        Notification notif = Notification.builder()
            .uuid(UUID.randomUUID())
            .user(recipient)
            .type(type).title(title).message(message)
            .link(link).data(data)
            .isRead(false)
            .createdAt(LocalDateTime.now())
            .build();

        Notification saved = notifRepo.save(notif);
        log.info("Notification created [type={} recipient={}]", type, recipientUuid);

        // Push SSE si l'utilisateur est connecté (pas de garantie, best-effort)
        if (sseService.isConnected(recipientUuid)) {
            sseService.push(recipientUuid, mapper.toDto(saved));
        }

        return mapper.toDto(saved);
    }

    @Transactional(readOnly = true)
    public PagedResponse<NotificationDto> getForUser(UUID userUuid, Pageable pageable) {
        return PagedResponse.from(
            notifRepo.findByUserUuid(userUuid, pageable).map(mapper::toDto));
    }

    @Transactional(readOnly = true)
    public long unreadCount(UUID userUuid) {
        return notifRepo.countByUserUuidAndIsReadFalse(userUuid);
    }

    @Transactional
    public void markRead(UUID notifUuid, UUID userUuid) {
        Notification n = notifRepo.findByUuid(notifUuid)
            .orElseThrow(() -> new NotFoundException("Notification not found"));
        if (!n.getUser().getUuid().equals(userUuid))
            throw new AccessDeniedException("Not your notification");
        n.markAsRead();
    }

    @Transactional
    public int markAllRead(UUID userUuid) {
        return notifRepo.markAllAsRead(userUuid);
    }
}

─────────────────────────────────────────────────────────────────────
51.6 DOMAIN EVENT HANDLERS
─────────────────────────────────────────────────────────────────────

@Slf4j
@Component
@RequiredArgsConstructor
public class NotificationEventHandler {

    private final NotificationService notifService;
    private final TaskRepository      taskRepo;

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onTaskAssigned(TaskAssignedEvent event) {
        taskRepo.findByUuid(event.taskId()).ifPresent(task -> {
            if (task.getAssigneeId() == null) return;
            notifService.createAndSend(
                task.getAssigneeId(),
                "task.assigned",
                "New task assigned to you",
                "Task '" + task.getTitle() + "' has been assigned to you",
                "/projects/" + task.getProjectId() + "/tasks/" + task.getId(),
                Map.of(
                    "taskId",    task.getId().toString(),
                    "projectId", task.getProjectId().toString(),
                    "priority",  task.getPriority().name()
                )
            );
        });
    }

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onTaskStatusChanged(TaskStatusChangedEvent event) {
        taskRepo.findByUuid(event.taskId()).ifPresent(task -> {
            if (task.getAssigneeId() == null) return;
            // Ne pas notifier si c'est l'assigné lui-même qui change
            if (task.getAssigneeId().equals(event.changedById())) return;

            notifService.createAndSend(
                task.getAssigneeId(),
                "task.status_changed",
                "Task status updated",
                String.format("'%s' changed from %s to %s",
                    task.getTitle(), event.oldStatus(), event.newStatus()),
                "/tasks/" + task.getId(),
                Map.of(
                    "taskId",    task.getId().toString(),
                    "oldStatus", event.oldStatus().name(),
                    "newStatus", event.newStatus().name()
                )
            );
        });
    }

    @Async
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onProjectMemberAdded(ProjectMemberAddedEvent event) {
        notifService.createAndSend(
            event.userId(),
            "project.member_added",
            "You have been added to a project",
            "You were added to project with role " + event.role().name(),
            "/projects/" + event.projectId(),
            Map.of(
                "projectId", event.projectId().toString(),
                "role",      event.role().name()
            )
        );
    }
}

─────────────────────────────────────────────────────────────────────
51.7 SCHEDULEUR — RAPPELS TÂCHES DUES BIENTÔT
─────────────────────────────────────────────────────────────────────

@Slf4j
@Component
@RequiredArgsConstructor
public class TaskReminderScheduler {

    private final TaskRepository     taskRepo;
    private final NotificationService notifService;

    /**
     * Toutes les heures — envoie des rappels pour les tâches dues dans < 24h.
     * @Scheduled utilise une expression cron ou fixedDelay (en ms).
     */
    @Scheduled(cron = "0 0 * * * *")  // Toutes les heures pile
    @Transactional(readOnly = true)
    public void sendDueSoonReminders() {
        LocalDateTime now  = LocalDateTime.now();
        LocalDateTime soon = now.plusHours(24);

        List<Task> tasks = taskRepo.findAssignedTasksDueBetween(now, soon);
        log.info("Due-soon reminder: {} tasks to notify", tasks.size());

        tasks.forEach(task -> {
            if (task.getAssigneeId() == null) return;
            try {
                notifService.createAndSend(
                    task.getAssigneeId(),
                    "task.due_soon",
                    "Task due soon!",
                    String.format("'%s' is due %s",
                        task.getTitle(),
                        task.getDueDate().format(
                            DateTimeFormatter.ofPattern("MM/dd 'at' HH:mm"))),
                    "/tasks/" + task.getId(),
                    Map.of(
                        "taskId",  task.getId().toString(),
                        "dueDate", task.getDueDate().toString()
                    )
                );
            } catch (Exception e) {
                log.error("Reminder failed for task {}", task.getId(), e);
            }
        });
    }
}

─────────────────────────────────────────────────────────────────────
51.8 NOTIFICATION CONTROLLER
─────────────────────────────────────────────────────────────────────

@RestController
@RequestMapping("/api/v1/notifications")
@RequiredArgsConstructor
@Tag(name = "Notifications")
public class NotificationController {

    private final NotificationService notifService;
    private final SseEmitterService   sseService;

    /**
     * Endpoint SSE — le client Browser maintient cette connexion ouverte.
     *
     * Utilisation côté client (JavaScript) :
     * ─────────────────────────────────────────
     * const es = new EventSource('/api/v1/notifications/stream', {
     *   headers: { 'Authorization': 'Bearer ' + token }
     * });
     *
     * es.addEventListener('notification', e => {
     *   const notif = JSON.parse(e.data);
     *   showToast(notif.title, notif.message);
     *   updateBadge(notif.type);
     * });
     *
     * es.addEventListener('heartbeat', e => {
     *   console.debug('SSE alive', JSON.parse(e.data).ts);
     * });
     *
     * es.onerror = () => {
     *   // Le navigateur reconnecte automatiquement après reconnectTime (3s)
     *   console.warn('SSE reconnecting...');
     * };
     */
    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    @PreAuthorize("isAuthenticated()")
    public SseEmitter stream(@AuthenticationPrincipal UserPrincipal p) {
        return sseService.subscribe(p.getUuid());
    }

    @GetMapping
    @PreAuthorize("isAuthenticated()")
    public ApiResponse<PagedResponse<NotificationDto>> list(
            @AuthenticationPrincipal UserPrincipal p,
            @PageableDefault(size = 20) Pageable pageable) {
        return ApiResponse.success("OK", notifService.getForUser(p.getUuid(), pageable));
    }

    @GetMapping("/unread-count")
    @PreAuthorize("isAuthenticated()")
    public ApiResponse<Long> unreadCount(@AuthenticationPrincipal UserPrincipal p) {
        return ApiResponse.success("OK", notifService.unreadCount(p.getUuid()));
    }

    @PatchMapping("/{uuid}/read")
    @PreAuthorize("isAuthenticated()")
    public ApiResponse<Void> markRead(
            @PathVariable UUID uuid,
            @AuthenticationPrincipal UserPrincipal p) {
        notifService.markRead(uuid, p.getUuid());
        return ApiResponse.success("Marked as read", null);
    }

    @PatchMapping("/read-all")
    @PreAuthorize("isAuthenticated()")
    public ApiResponse<Integer> markAllRead(@AuthenticationPrincipal UserPrincipal p) {
        int count = notifService.markAllRead(p.getUuid());
        return ApiResponse.success(count + " notifications marked as read", count);
    }

    @GetMapping("/stats")
    @PreAuthorize("hasRole('ADMIN')")
    public ApiResponse<Map<String, Object>> stats() {
        return ApiResponse.success("SSE stats", Map.of(
            "activeConnections", sseService.connectionCount()
        ));
    }
}

─────────────────────────────────────────────────────────────────────
EXERCICES — PROJETS PRATIQUES
─────────────────────────────────────────────────────────────────────

NIVEAU DÉBUTANT :
  1. [Blog] Implémenter l'endpoint GET /api/v1/blog/categories
     qui retourne les catégories avec le nombre d'articles publiés
     pour chacune. Utiliser une projection Spring Data.

  2. [Notifications] Tester manuellement le SSE avec curl :
     curl -N -H "Authorization: Bearer <token>" \
          http://localhost:8080/api/v1/notifications/stream
     Observer le heartbeat toutes les 20s et créer une tâche
     pour déclencher une notification.

  3. [Blog] Ajouter un endpoint GET /api/v1/blog/tags qui retourne
     tous les tags avec leur nombre d'articles (seulement publiés).

NIVEAU INTERMÉDIAIRE :
  1. [Blog] Implémenter la pagination des commentaires côté
     racine uniquement (parent IS NULL), avec les réponses
     chargées dans la même requête via @EntityGraph.
     Retourner une structure arborescente : List<CommentWithReplies>.

  2. [Notifications] Ajouter la détection de mentions (@username)
     dans les commentaires TaskFlow. Quand un commentaire contient
     "@alice", Alice doit recevoir une notification "mention".

  3. [Blog] Écrire des tests d'intégration (Testcontainers) pour :
     - La recherche full-text (vérifier la pertinence des résultats)
     - Le toggle like (vérifie l'incrémentation et la décrémentation)
     - La contrainte de 2 niveaux max pour les commentaires

NIVEAU AVANCÉ :
  1. [Blog + Notifications] Intégrer le système de notifications
     dans le blog : notifier l'auteur quand son article reçoit
     un commentaire ou atteint 100 vues.

  2. [SSE] Gérer la reconnexion avec reprise de position :
     Le client envoie le "Last-Event-ID" dans ses headers lors
     de la reconnexion. Le serveur renvoie les notifications
     manquées depuis cet ID.

  3. [Notifications] Implémenter le système de préférences
     de notification : chaque utilisateur peut configurer quels
     types de notifications il veut recevoir (opt-in/out par type).
     Stocker dans une table notification_preferences(userId, type, enabled).

================================================================================
RÉSUMÉ PARTIES 18-19
================================================================================

CHAPITRE 50 — API DE BLOG :
  [OK] Modèle de données complet : articles, commentaires, tags, catégories, likes
  [OK] Migration Flyway avec index full-text (tsvector GENERATED ALWAYS)
  [OK] Entité BlogArticle : slug auto-généré, machine d'états DRAFT/PUBLISHED/ARCHIVED
  [OK] Repository avec recherche full-text PostgreSQL (to_tsquery + ts_rank)
  [OK] Likes polymorphiques (ARTICLE ou COMMENT) avec toggle
  [OK] Commentaires imbriqués 2 niveaux max (contrainte métier dans le service)
  [OK] Génération de slug avec accents et caractères spéciaux (fr)
  [OK] Controller REST complet (12 endpoints)
  [OK] Tests d'intégration Testcontainers (full-text, toggle like, contrainte nesting)

CHAPITRE 51 — NOTIFICATIONS SSE :
  [OK] Architecture complète : Domain Event -> Handler -> Service -> SSE -> Browser
  [OK] SseEmitterService : registry ConcurrentHashMap, multi-connexions par user
  [OK] Heartbeat toutes les 20s (anti-timeout proxy)
  [OK] Reconnexion automatique du client (reconnectTime=3000ms)
  [OK] NotificationService : persist + push best-effort
  [OK] Domain Event Handlers : @Async + @TransactionalEventListener(AFTER_COMMIT)
  [OK] Scheduleur TaskReminderScheduler : @Scheduled(cron) pour rappels due-soon
  [OK] Controller complet : stream SSE + CRUD notifications + markAllRead
  [OK] Exemple JavaScript complet côté client (EventSource API)

================================================================================
FIN PARTIES 18-19
Prochaine partie -> Projet Final — TaskFlow complet de A à Z
================================================================================

================================================================================
GUIDE SPRING BOOT ENTREPRISE — PARTIE 20
PROJET FINAL — TASKFLOW BACKEND COMPLET
Chapitre 52 : Synthèse, démarrage du projet et roadmap de développement
================================================================================

Cette dernière partie est un guide de synthèse complet.
Elle récapitule toutes les technologies abordées, fournit
le code de démarrage du projet TaskFlow dans son état final,
et trace une roadmap pour les prochaines fonctionnalités.

================================================================================
CHAPITRE 52 : PROJET FINAL TASKFLOW — SYNTHÈSE COMPLÈTE
================================================================================

52.1 VUE D'ENSEMBLE DU PROJET
───────────────────────────────

TECHNOLOGIES UTILISÉES DANS TASKFLOW BACKEND :
────────────────────────────────────────────────

  ┌─────────────────────────────────────────────────────────────────┐
  │  COUCHE APPLICATION                                             │
  │  Spring Boot 3.3  │  Java 21  │  Maven                        │
  ├─────────────────────────────────────────────────────────────────┤
  │  SÉCURITÉ                                                       │
  │  Spring Security  │  JWT (JJWT 0.12.3)  │  BCrypt (12 rounds) │
  ├─────────────────────────────────────────────────────────────────┤
  │  PERSISTANCE                                                    │
  │  PostgreSQL 16  │  Spring Data JPA  │  Flyway  │  HikariCP     │
  ├─────────────────────────────────────────────────────────────────┤
  │  CACHE                                                          │
  │  Redis (Spring Cache)  │  Caffeine (L1)  │  @Cacheable         │
  ├─────────────────────────────────────────────────────────────────┤
  │  MESSAGING                                                      │
  │  Apache Kafka  │  RabbitMQ (notifications email)               │
  ├─────────────────────────────────────────────────────────────────┤
  │  API & DOCS                                                     │
  │  REST  │  SpringDoc OpenAPI 2.5  │  SSE (notifications)        │
  ├─────────────────────────────────────────────────────────────────┤
  │  MONITORING                                                     │
  │  Micrometer  │  Prometheus  │  Grafana  │  OpenTelemetry       │
  ├─────────────────────────────────────────────────────────────────┤
  │  LOGGING                                                        │
  │  Logback  │  Logstash Encoder (JSON)  │  MDC  │  ELK Stack     │
  ├─────────────────────────────────────────────────────────────────┤
  │  TESTS                                                          │
  │  JUnit 5  │  Mockito  │  Testcontainers  │  @WebMvcTest        │
  ├─────────────────────────────────────────────────────────────────┤
  │  INFRASTRUCTURE                                                 │
  │  Docker  │  Kubernetes (Helm)  │  GitHub Actions  │  AWS ECS   │
  └─────────────────────────────────────────────────────────────────┘

52.2 STRUCTURE FINALE DU PROJET
─────────────────────────────────

taskflow-backend/
│
├── src/main/java/com/taskflow/backend/
│   │
│   ├── TaskFlowApplication.java          <- Point d'entrée Spring Boot
│   │
│   ├── config/                           <- Configurations globales
│   │   ├── SecurityConfig.java
│   │   ├── JpaConfig.java
│   │   ├── RedisConfig.java
│   │   ├── KafkaConfig.java
│   │   ├── OpenApiConfig.java
│   │   └── AsyncConfig.java
│   │
│   ├── domain/                           <- Modèle de domaine (Clean Arch)
│   │   ├── model/
│   │   │   ├── Task.java
│   │   │   ├── Project.java
│   │   │   └── User.java
│   │   ├── valueobject/
│   │   │   ├── TaskStatus.java
│   │   │   ├── TaskPriority.java
│   │   │   ├── UserRole.java
│   │   │   └── Email.java
│   │   ├── event/
│   │   │   ├── TaskCreatedEvent.java
│   │   │   ├── TaskStatusChangedEvent.java
│   │   │   └── ProjectMemberAddedEvent.java
│   │   ├── repository/                   <- Interfaces (ports)
│   │   │   ├── TaskRepository.java
│   │   │   ├── ProjectRepository.java
│   │   │   └── UserRepository.java
│   │   └── exception/
│   │       ├── TaskNotFoundException.java
│   │       ├── ProjectNotFoundException.java
│   │       └── ProjectAccessDeniedException.java
│   │
│   ├── application/                      <- Use Cases
│   │   ├── usecase/
│   │   │   ├── task/
│   │   │   │   ├── CreateTaskUseCase.java
│   │   │   │   ├── UpdateTaskStatusUseCase.java
│   │   │   │   └── DeleteTaskUseCase.java
│   │   │   ├── project/
│   │   │   │   ├── CreateProjectUseCase.java
│   │   │   │   └── AddProjectMemberUseCase.java
│   │   │   └── auth/
│   │   │       ├── LoginUseCase.java
│   │   │       └── RefreshTokenUseCase.java
│   │   └── port/
│   │       ├── out/
│   │       │   ├── EventPublisherPort.java
│   │       │   ├── CachePort.java
│   │       │   └── EmailPort.java
│   │       └── in/
│   │           └── CreateTaskPort.java
│   │
│   ├── infrastructure/                   <- Implémentations techniques
│   │   ├── persistence/
│   │   │   ├── entity/
│   │   │   │   ├── TaskJpaEntity.java
│   │   │   │   ├── ProjectJpaEntity.java
│   │   │   │   └── UserJpaEntity.java
│   │   │   ├── repository/               <- Spring Data JPA
│   │   │   │   ├── TaskJpaRepository.java
│   │   │   │   └── UserJpaRepository.java
│   │   │   ├── adapter/
│   │   │   │   ├── TaskRepositoryAdapter.java
│   │   │   │   └── UserRepositoryAdapter.java
│   │   │   └── mapper/
│   │   │       └── TaskPersistenceMapper.java
│   │   ├── messaging/
│   │   │   ├── KafkaEventPublisher.java
│   │   │   └── KafkaEventConsumer.java
│   │   ├── cache/
│   │   │   └── RedisCacheAdapter.java
│   │   └── event/
│   │       └── SpringEventPublisherAdapter.java
│   │
│   ├── adapter/                          <- Couche interface
│   │   └── web/
│   │       ├── TaskController.java
│   │       ├── ProjectController.java
│   │       ├── AuthController.java
│   │       ├── UserController.java
│   │       ├── AdminController.java
│   │       └── dto/
│   │           ├── request/
│   │           └── response/
│   │
│   ├── security/
│   │   ├── JwtService.java
│   │   ├── JwtAuthenticationFilter.java
│   │   ├── CustomUserDetailsService.java
│   │   ├── UserPrincipal.java
│   │   ├── ProjectSecurity.java
│   │   └── TaskSecurity.java
│   │
│   ├── notification/
│   │   ├── service/NotificationService.java
│   │   ├── sse/SseEmitterService.java
│   │   ├── controller/NotificationController.java
│   │   ├── handler/NotificationEventHandler.java
│   │   └── scheduler/TaskReminderScheduler.java
│   │
│   ├── blog/
│   │   ├── entity/BlogArticle.java
│   │   ├── service/BlogService.java
│   │   └── controller/BlogController.java
│   │
│   ├── common/
│   │   ├── entity/BaseEntity.java
│   │   ├── dto/ApiResponse.java
│   │   ├── dto/PagedResponse.java
│   │   ├── exception/GlobalExceptionHandler.java
│   │   └── logging/MdcLoggingFilter.java
│   │
│   └── health/
│       └── DatabaseHealthIndicator.java
│
├── src/main/resources/
│   ├── application.properties
│   ├── application-dev.properties
│   ├── application-prod.properties
│   ├── logback-spring.xml
│   └── db/migration/
│       ├── V1__init_schema.sql
│       ├── V2__insert_default_data.sql
│       ├── V3__add_soft_delete.sql
│       ├── ...
│       ├── V10__create_blog_tables.sql
│       └── V11__create_notifications_table.sql
│
├── src/test/java/com/taskflow/backend/
│   ├── unit/
│   │   ├── domain/TaskTest.java
│   │   ├── service/TaskServiceTest.java
│   │   └── security/JwtServiceTest.java
│   ├── integration/
│   │   ├── TaskControllerIT.java
│   │   ├── AuthControllerIT.java
│   │   └── NotificationIT.java
│   └── arch/
│       └── ArchitectureTest.java     <- ArchUnit tests
│
├── helm-charts/                      <- Kubernetes Helm Chart
├── infrastructure/                   <- Terraform AWS
├── monitoring/                       <- Prometheus + Grafana config
├── elk/                              <- Elasticsearch + Logstash config
├── docker-compose.yml
├── docker-compose-dev.yml
├── docker-compose-monitoring.yml
├── Dockerfile
├── pom.xml
└── README.md

52.3 TASKFLOW APPLICATION.JAVA
───────────────────────────────

package com.taskflow.backend;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

/**
 * Point d'entrée de TaskFlow Backend.
 *
 * Annotations activées :
 *   @EnableJpaAuditing -> createdAt/updatedAt remplis automatiquement
 *   @EnableCaching     -> @Cacheable, @CacheEvict activés
 *   @EnableAsync       -> @Async activé (pour les event handlers)
 *   @EnableScheduling  -> @Scheduled activé (pour les rappels de tâches)
 */
@SpringBootApplication
@EnableJpaAuditing
@EnableCaching
@EnableAsync
@EnableScheduling
public class TaskFlowApplication {

    public static void main(String[] args) {
        SpringApplication.run(TaskFlowApplication.class, args);
    }
}

52.4 POM.XML FINAL COMPLET
────────────────────────────

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
             https://maven.apache.org/xsd/maven-4.0.0.xsd">

    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.4</version>
    </parent>

    <groupId>com.taskflow</groupId>
    <artifactId>taskflow-backend</artifactId>
    <version>1.0.0</version>
    <name>TaskFlow Backend</name>
    <description>Task management SaaS platform — Spring Boot backend</description>

    <properties>
        <java.version>21</java.version>
        <mapstruct.version>1.6.2</mapstruct.version>
        <jjwt.version>0.12.3</jjwt.version>
        <springdoc.version>2.5.0</springdoc.version>
        <testcontainers.version>1.19.8</testcontainers.version>
        <logstash-encoder.version>7.4</logstash-encoder.version>
    </properties>

    <dependencies>

        <!-- ─── Spring Boot Core ────────────────────────────────── -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>

        <!-- ─── Sécurité ────────────────────────────────────────── -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>${jjwt.version}</version>
            <scope>runtime</scope>
        </dependency>

        <!-- ─── Persistance ─────────────────────────────────────── -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-core</artifactId>
        </dependency>
        <dependency>
            <groupId>org.flywaydb</groupId>
            <artifactId>flyway-database-postgresql</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- ─── Cache ───────────────────────────────────────────── -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>com.github.ben-manes.caffeine</groupId>
            <artifactId>caffeine</artifactId>
        </dependency>

        <!-- ─── Messaging ───────────────────────────────────────── -->
        <dependency>
            <groupId>org.springframework.kafka</groupId>
            <artifactId>spring-kafka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>

        <!-- ─── Email ───────────────────────────────────────────── -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-mail</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>

        <!-- ─── API Documentation ───────────────────────────────── -->
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>${springdoc.version}</version>
        </dependency>

        <!-- ─── Utilitaires ─────────────────────────────────────── -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct</artifactId>
            <version>${mapstruct.version}</version>
        </dependency>
        <dependency>
            <groupId>io.hypersistence</groupId>
            <artifactId>hypersistence-utils-hibernate-63</artifactId>
            <version>3.7.3</version>
        </dependency>

        <!-- ─── Monitoring ──────────────────────────────────────── -->
        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-registry-prometheus</artifactId>
        </dependency>
        <dependency>
            <groupId>io.micrometer</groupId>
            <artifactId>micrometer-tracing-bridge-otel</artifactId>
        </dependency>
        <dependency>
            <groupId>io.opentelemetry.instrumentation</groupId>
            <artifactId>opentelemetry-spring-boot-starter</artifactId>
            <version>2.4.0-alpha</version>
        </dependency>

        <!-- ─── Logging ─────────────────────────────────────────── -->
        <dependency>
            <groupId>net.logstash.logback</groupId>
            <artifactId>logstash-logback-encoder</artifactId>
            <version>${logstash-encoder.version}</version>
        </dependency>

        <!-- ─── Tests ───────────────────────────────────────────── -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>postgresql</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>kafka</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.tngtech.archunit</groupId>
            <artifactId>archunit-junit5</artifactId>
            <version>1.3.0</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <layers><enabled>true</enabled></layers>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>21</source>
                    <target>21</target>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </path>
                        <path>
                            <groupId>org.mapstruct</groupId>
                            <artifactId>mapstruct-processor</artifactId>
                            <version>${mapstruct.version}</version>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
            <!-- JaCoCo — couverture de code -->
            <plugin>
                <groupId>org.jacoco</groupId>
                <artifactId>jacoco-maven-plugin</artifactId>
                <version>0.8.12</version>
                <executions>
                    <execution>
                        <goals><goal>prepare-agent</goal></goals>
                    </execution>
                    <execution>
                        <id>report</id>
                        <phase>test</phase>
                        <goals><goal>report</goal></goals>
                    </execution>
                    <execution>
                        <id>check</id>
                        <goals><goal>check</goal></goals>
                        <configuration>
                            <rules>
                                <rule>
                                    <element>BUNDLE</element>
                                    <limits>
                                        <limit>
                                            <counter>LINE</counter>
                                            <value>COVEREDRATIO</value>
                                            <minimum>0.80</minimum>
                                        </limit>
                                    </limits>
                                </rule>
                            </rules>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

52.5 TESTS D'ARCHITECTURE AVEC ARCHUNIT
─────────────────────────────────────────

ArchUnit vérifie automatiquement que les règles de la Clean
Architecture sont respectées dans le code.

package com.taskflow.backend.arch;

import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import com.tngtech.archunit.library.Architectures;

import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
import static com.tngtech.archunit.library.Architectures.layeredArchitecture;

@AnalyzeClasses(packages = "com.taskflow.backend")
public class ArchitectureTest {

    /**
     * Règle 1 : La couche domaine ne doit dépendre d'aucune autre couche.
     */
    @ArchTest
    static final ArchRule domainHasNoDependencies =
        noClasses().that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAnyPackage("..infrastructure..", "..adapter..", "..application..");

    /**
     * Règle 2 : La couche application peut dépendre du domaine, pas de l'infra.
     */
    @ArchTest
    static final ArchRule applicationDependsOnlyOnDomain =
        noClasses().that().resideInAPackage("..application..")
            .should().dependOnClassesThat()
            .resideInAnyPackage("..infrastructure..", "..adapter..");

    /**
     * Règle 3 : Les controllers ne contiennent pas de logique métier.
     * Les controllers ne doivent pas appeler directement les repositories.
     */
    @ArchTest
    static final ArchRule controllersDoNotAccessRepositoriesDirectly =
        noClasses().that().resideInAPackage("..adapter.web..")
            .should().dependOnClassesThat()
            .resideInAPackage("..infrastructure.persistence.repository..");

    /**
     * Règle 4 : Toutes les classes de service sont annotées @Service.
     */
    @ArchTest
    static final ArchRule serviceClassesAreAnnotated =
        classes().that().resideInAPackage("..application.usecase..")
            .and().haveNameMatching(".*UseCase")
            .should().beAnnotatedWith(org.springframework.stereotype.Service.class);

    /**
     * Règle 5 : Les entités domaine n'ont pas d'annotations JPA.
     */
    @ArchTest
    static final ArchRule domainEntitiesHaveNoJpaAnnotations =
        noClasses().that().resideInAPackage("..domain.model..")
            .should().beAnnotatedWith(jakarta.persistence.Entity.class)
            .orShould().beAnnotatedWith(jakarta.persistence.Table.class);

    /**
     * Règle 6 : Architecture en couches — règle de dépendance.
     */
    @ArchTest
    static final ArchRule layeredArchitectureRule =
        layeredArchitecture().consideringOnlyDependenciesInLayers()
            .layer("Adapter").definedBy("..adapter..")
            .layer("Application").definedBy("..application..")
            .layer("Domain").definedBy("..domain..")
            .layer("Infrastructure").definedBy("..infrastructure..")
            .whereLayer("Domain").mayOnlyBeAccessedByLayers(
                "Application", "Infrastructure", "Adapter")
            .whereLayer("Application").mayOnlyBeAccessedByLayers(
                "Adapter", "Infrastructure")
            .whereLayer("Infrastructure").mayOnlyBeAccessedByLayers(
                "Adapter");
}

52.6 COMMANDES DE DÉMARRAGE RAPIDE
─────────────────────────────────────

# ─── 1. Cloner et initialiser ─────────────────────────────────────

git clone https://github.com/votre-org/taskflow-backend.git
cd taskflow-backend

# ─── 2. Démarrer les services externes (dev local) ────────────────

docker-compose -f docker-compose-dev.yml up -d
# Démarre : PostgreSQL, Redis, Kafka, RabbitMQ, Zipkin, MailHog

# ─── 3. Vérifier les services ─────────────────────────────────────

docker-compose -f docker-compose-dev.yml ps
# Tous les services doivent être "healthy"

# ─── 4. Lancer l'application en mode développement ────────────────

./mvnw spring-boot:run -Dspring-boot.run.profiles=dev

# Ou avec variables d'environnement personnalisées :
DB_URL=jdbc:postgresql://localhost:5432/taskflow_dev \
DB_USERNAME=taskflow_user \
DB_PASSWORD=taskflow_password \
JWT_SECRET=votre-secret-jwt-minimum-256-bits \
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev

# ─── 5. Accès aux interfaces ──────────────────────────────────────

echo "API:       http://localhost:8080"
echo "Swagger:   http://localhost:8080/swagger-ui.html"
echo "Actuator:  http://localhost:8080/actuator"
echo "MailHog:   http://localhost:8025"
echo "Zipkin:    http://localhost:9411"

# ─── 6. Exécuter les tests ────────────────────────────────────────

# Tests unitaires uniquement
./mvnw test -Dtest="*Test" -DfailIfNoTests=false

# Tests d'intégration (nécessitent Docker)
./mvnw test -Dtest="*IT" -DfailIfNoTests=false

# Tous les tests avec rapport JaCoCo
./mvnw verify

# ─── 7. Construire l'image Docker ─────────────────────────────────

./mvnw clean package -DskipTests
docker build -t taskflow-backend:latest .
docker run -p 8080:8080 \
  -e SPRING_PROFILES_ACTIVE=prod \
  -e DB_URL=jdbc:postgresql://host.docker.internal:5432/taskflow_prod \
  taskflow-backend:latest

# ─── 8. Déployer sur Kubernetes avec Helm ─────────────────────────

helm upgrade --install taskflow-api ./helm-charts \
  --namespace taskflow-prod \
  --create-namespace \
  --values helm-charts/values-prod.yaml \
  --set image.tag=latest \
  --wait

52.7 SCRIPT DE SEED — DONNÉES DE DÉMONSTRATION
─────────────────────────────────────────────────

-- V2__insert_default_data.sql

-- Admin par défaut (mot de passe : Admin123!)
-- BCrypt hash généré avec : BCryptPasswordEncoder(12).encode("Admin123!")
INSERT INTO users (uuid, email, password_hash, first_name, last_name, role, status)
VALUES (
    gen_random_uuid(),
    'admin@taskflow.io',
    '$2a$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQyCkbJgkyWxGCMaRjJ/SaT..',
    'Admin', 'TaskFlow',
    'ADMIN', 'ACTIVE'
);

-- Catégories de blog
INSERT INTO blog_categories (name, slug, description) VALUES
    ('Tutoriels',     'tutoriels',     'Guides et tutoriels techniques'),
    ('Annonces',      'annonces',      'Actualités et annonces du projet'),
    ('Bonnes pratiques', 'bonnes-pratiques', 'Best practices de développement');

-- Tags prédéfinis
INSERT INTO blog_tags (name, slug) VALUES
    ('Spring Boot', 'spring-boot'),
    ('Java 21',     'java-21'),
    ('Docker',      'docker'),
    ('Kubernetes',  'kubernetes'),
    ('API REST',    'api-rest'),
    ('Sécurité',    'securite');

52.8 CHECKLIST DE DÉPLOIEMENT EN PRODUCTION
─────────────────────────────────────────────

Avant chaque déploiement en production, vérifier les points suivants :

SÉCURITÉ :
  [WHITE_SQUARE] JWT_SECRET est un secret aléatoire de minimum 256 bits (32 octets)
  [WHITE_SQUARE] Les mots de passe de BD et Redis sont forts et uniques
  [WHITE_SQUARE] CORS est configuré avec les domaines exacts (pas de *)
  [WHITE_SQUARE] HTTPS est activé sur toute la stack (ALB -> Nginx -> App)
  [WHITE_SQUARE] Les headers de sécurité HTTP sont présents (HSTS, X-Frame-Options)
  [WHITE_SQUARE] Les endpoints Actuator sensibles sont protégés (/actuator/env, /heapdump)
  [WHITE_SQUARE] Pas de credentials en dur dans le code ou dans les images Docker

BASE DE DONNÉES :
  [WHITE_SQUARE] Multi-AZ activé sur RDS (PostgreSQL)
  [WHITE_SQUARE] Backups automatiques configurés (7 jours minimum)
  [WHITE_SQUARE] Les migrations Flyway sont testées sur une copie de prod avant déploiement
  [WHITE_SQUARE] HikariCP est configuré avec un pool approprié (selon le CPU du serveur)
  [WHITE_SQUARE] Slow query logging activé (> 1 seconde)

PERFORMANCE :
  [WHITE_SQUARE] Cache Redis configuré avec TTL approprié par type de données
  [WHITE_SQUARE] Les index DB sont en place pour les requêtes fréquentes
  [WHITE_SQUARE] Les endpoints de liste ont toujours une pagination (pas de findAll() illimité)
  [WHITE_SQUARE] Les requêtes N+1 sont éliminées (@EntityGraph ou JOIN FETCH)

MONITORING :
  [WHITE_SQUARE] Prometheus scrape l'application
  [WHITE_SQUARE] Les dashboards Grafana sont opérationnels
  [WHITE_SQUARE] Les alertes Alertmanager sont configurées et testées
  [WHITE_SQUARE] CloudWatch/ELK reçoit les logs applicatifs

INFRASTRUCTURE :
  [WHITE_SQUARE] Health checks configurés sur le load balancer (/actuator/health)
  [WHITE_SQUARE] PodDisruptionBudget configuré sur K8s (minAvailable >= 2)
  [WHITE_SQUARE] Auto-scaling configuré (CPU > 70%, Memory > 80%)
  [WHITE_SQUARE] Image Docker avec tag fixe (pas :latest)
  [WHITE_SQUARE] Resource requests ET limits définis sur les containers

TESTS :
  [WHITE_SQUARE] Couverture de code >= 80% (vérifiée par JaCoCo)
  [WHITE_SQUARE] Tests d'intégration passent sur un environnement de staging
  [WHITE_SQUARE] Smoke tests automatisés après déploiement
  [WHITE_SQUARE] Rollback plan en place (helm rollback ou kubectl rollout undo)

52.9 ROADMAP DES PROCHAINES FONCTIONNALITÉS
─────────────────────────────────────────────

VERSION 1.1 — COLLABORATION AVANCÉE :
  [WHITE_SQUARE] Sous-tâches (hiérarchie de tâches, 3 niveaux max)
  [WHITE_SQUARE] Dépendances entre tâches (task B bloquée par task A)
  [WHITE_SQUARE] Commentaires avec mentions (@username -> notification)
  [WHITE_SQUARE] Pièces jointes (upload vers S3, stockage de métadonnées en BD)
  [WHITE_SQUARE] Activité du projet (journal d'audit avec pagination)

VERSION 1.2 — ORGANISATION DU TRAVAIL :
  [WHITE_SQUARE] Sprints (Sprint avec dates de début/fin, velocity)
  [WHITE_SQUARE] Roadmap visuelle (Gantt chart — données API)
  [WHITE_SQUARE] Labels personnalisables par projet (couleur + icône)
  [WHITE_SQUARE] Filtres sauvegardés (chaque utilisateur peut créer ses filtres)
  [WHITE_SQUARE] Tri personnalisé par glisser-déposer (ordre manuel)

VERSION 1.3 — COLLABORATION EXTERNE :
  [WHITE_SQUARE] Invitations par lien (token d'invitation avec expiration)
  [WHITE_SQUARE] Projets publics (visibles sans authentification)
  [WHITE_SQUARE] Partage de tâches individuelles
  [WHITE_SQUARE] Webhooks (notifier des systèmes externes via HTTP POST)
  [WHITE_SQUARE] API GraphQL (en complément de l'API REST)

VERSION 2.0 — ARCHITECTURE MICROSERVICES :
  [WHITE_SQUARE] Découper en microservices : Identity, Projects, Tasks, Notifications
  [WHITE_SQUARE] API Gateway (Spring Cloud Gateway)
  [WHITE_SQUARE] Service Discovery (Eureka ou Kubernetes DNS)
  [WHITE_SQUARE] Distributed Tracing complet (Jaeger)
  [WHITE_SQUARE] Chaos Engineering (tests de résilience)

VERSION 2.1 — IA ET AUTOMATISATION :
  [WHITE_SQUARE] Suggestion de priorité automatique (ML sur les patterns historiques)
  [WHITE_SQUARE] Estimation automatique de durée (basée sur l'historique)
  [WHITE_SQUARE] Résumé automatique des activités de la journée
  [WHITE_SQUARE] Détection d'anomalies (tâches en retard récurrentes, surcharge)

52.10 QUIZ FINAL — VÉRIFICATION DES ACQUIS
────────────────────────────────────────────

Pour vérifier vos connaissances, répondez à ces 20 questions
sans consulter le guide. Les réponses sont dans le guide.

SPRING BOOT & CORE :
  1. Quelle annotation active l'injection de dépendances dans Spring Boot ?
  2. Quelle est la différence entre @Component, @Service, @Repository ?
  3. Comment Spring Boot sélectionne-t-il la configuration auto ?
  4. Qu'est-ce qu'un BeanScope ? Citez les 4 scopes principaux.
  5. Comment exécuter du code au démarrage de l'application ?

PERSISTANCE JPA :
  6. Quelle est la différence entre FetchType.LAZY et FetchType.EAGER ?
  7. Qu'est-ce que le problème N+1 ? Comment le résoudre ?
  8. Quand utiliser @Transactional(readOnly = true) ?
  9. Qu'est-ce que le soft delete ? Citez 2 implémentations possibles.
  10. Qu'est-ce qu'une migration Flyway ? Pourquoi ne jamais modifier V1 ?

SÉCURITÉ :
  11. Quelle est la différence entre Authentication et Authorization ?
  12. Pourquoi stocker un hash BCrypt et non le mot de passe en clair ?
  13. Qu'est-ce qu'un access token vs un refresh token ?
  14. Qu'est-ce que CSRF et pourquoi est-il désactivé dans une API stateless ?
  15. Comment fonctionne @PreAuthorize avec SpEL ?

ARCHITECTURE & PATTERNS :
  16. Qu'est-ce que la règle de dépendance de la Clean Architecture ?
  17. Quelle est la différence entre un Aggregate Root et une Entity en DDD ?
  18. Pourquoi les Domain Events sont publiés AFTER_COMMIT ?
  19. Qu'est-ce que CQRS ? Dans quel cas est-ce utile ?
  20. Quelle est la différence entre SSE et WebSocket ?

RÉPONSES RAPIDES :
  1.  @SpringBootApplication (via @ComponentScan)
  2.  Sémantique seulement. @Repository active la traduction d'exceptions JPA.
  3.  Conditions (@ConditionalOnClass, @ConditionalOnMissingBean, etc.)
  4.  singleton (défaut), prototype, request, session
  5.  @EventListener sur ApplicationReadyEvent, ou CommandLineRunner
  6.  LAZY : chargé à la demande. EAGER : chargé systématiquement avec le parent.
  7.  1 requête par relation pour N entités. Solution : JOIN FETCH / @EntityGraph.
  8.  Pour les lectures — meilleure performance (pas de flush, optimisations BD).
  9.  Champ deleted_at (null = actif) ou @SQLRestriction Hibernate.
  10. Fichier de migration versionnée. Modifié = hash change = erreur Flyway.
  11. Authentication = qui es-tu ? Authorization = que peux-tu faire ?
  12. Irréversible en cas de fuite de BD.
  13. Access = courte durée (15min) + stateless. Refresh = longue durée (7j) + BD.
  14. CSRF = attaque sur les sessions. JWT stateless -> pas de session -> pas de CSRF.
  15. SpEL = Spring Expression Language. Ex: @PreAuthorize("hasRole('ADMIN')")
  16. Les dépendances pointent vers l'intérieur (Domaine ne dépend de rien).
  17. Aggregate Root = entrée unique vers l'agrégat, garantit la cohérence.
  18. Éviter les effets de bord (email, Kafka) si la transaction rollback.
  19. Sépare lecture et écriture pour des besoins différents. Utile si lectures >>> écritures.
  20. SSE = serveur -> client (unidirectionnel). WebSocket = bidirectionnel.

================================================================================
RÉCAPITULATIF GÉNÉRAL DU GUIDE — 52 CHAPITRES
================================================================================

PARTIES 1-4 : FONDATIONS (Java 21, Spring Boot, IoC/DI, Controllers)
  [OK] Java 21 (records, sealed, switch expressions, virtual threads)
  [OK] Spring IoC, DI par constructeur, scopes, cycle de vie, events
  [OK] REST : 6 contraintes, versioning, ApiResponse<T>, HATEOAS
  [OK] Validation Jakarta, GlobalExceptionHandler, codes d'erreur structurés

PARTIES 5-8 : PERSISTANCE & SÉCURITÉ
  [OK] JPA/Hibernate : entités, relations, états, lazy loading
  [OK] Spring Data JPA : Query Methods, @Query, Specifications, soft delete
  [OK] CRUD complet + DTOs + MapStruct + pagination + filtres
  [OK] Spring Security + JWT (access + refresh) + @PreAuthorize + SpEL

PARTIES 9-11 : QUALITÉ & PERFORMANCE
  [OK] Gestion d'erreurs avancée (hiérarchie d'exceptions, codes d'erreur)
  [OK] Tests complets (JUnit 5, Mockito, Testcontainers, @WebMvcTest)
  [OK] Cache Caffeine L1 + Redis L2 + @Cacheable + @CacheEvict

PARTIES 12-14 : MICROSERVICES & MESSAGING
  [OK] Kafka : producers/consumers, partitions, groupes, retry, DLQ
  [OK] RabbitMQ : exchanges, queues, bindings, email asynchrone
  [OK] Docker multi-stage + docker-compose complet + GitHub Actions CI/CD

PARTIES 15-16 : INFRASTRUCTURE & OBSERVABILITÉ
  [OK] AWS ECS Fargate + Terraform + Secrets Manager + CloudWatch
  [OK] Kubernetes : Deployment, HPA, Helm Charts, GKE, rolling updates
  [OK] Logback JSON + MDC + ELK Stack + Prometheus + Grafana + Alertmanager

PARTIES 17-19 : ARCHITECTURE AVANCÉE & PROJETS
  [OK] Clean Architecture + Ports & Adapters (Hexagonal)
  [OK] DDD : Aggregates, Value Objects, Domain Events, CQRS
  [OK] Projet Blog : full-text search PostgreSQL, likes polymorphiques
  [OK] Projet Notifications : SSE temps réel, event handlers, schedulers

PARTIE 20 : PROJET FINAL
  [OK] Structure complète du projet TaskFlow
  [OK] pom.xml final avec toutes les dépendances
  [OK] Tests ArchUnit pour valider les règles d'architecture
  [OK] Checklist de déploiement production
  [OK] Roadmap des prochaines fonctionnalités

================================================================================
STATISTIQUES DU GUIDE
================================================================================

  Parties         : 20
  Chapitres       : 52
  Lignes de code  : ~18 000 (Java + YAML + SQL + XML)
  Exercices       : 156 (3 niveaux × 3 exercices × 17 chapitres exercisés)
  Technologies    : 35+
  Migrations SQL  : 11
  Entités JPA     : 15+
  Services        : 12+
  Endpoints REST  : 50+

================================================================================
FIN DU GUIDE — SPRING BOOT ENTREPRISE — TASKFLOW BACKEND
Merci d'avoir suivi ce guide jusqu'au bout !
Bonne chance dans votre carrière de développeur Spring Boot.
================================================================================