first commit
This commit is contained in:
91
src/Category/Category.php
Normal file
91
src/Category/Category.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Category;
|
||||
|
||||
/**
|
||||
* Modèle représentant une catégorie d'articles.
|
||||
*
|
||||
* Ce modèle est immuable après construction.
|
||||
* La génération de slug est déléguée à SlugHelper::generate() dans CategoryService,
|
||||
* avant la construction de l'objet.
|
||||
*/
|
||||
final class Category
|
||||
{
|
||||
/**
|
||||
* @param int $id Identifiant en base (0 pour une nouvelle catégorie)
|
||||
* @param string $name Nom de la catégorie (1–100 caractères)
|
||||
* @param string $slug Slug URL de la catégorie
|
||||
*
|
||||
* @throws \InvalidArgumentException Si les données ne passent pas la validation
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly int $id,
|
||||
private readonly string $name,
|
||||
private readonly string $slug,
|
||||
) {
|
||||
$this->validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une instance depuis un tableau associatif (ligne de base de données).
|
||||
*
|
||||
* @param array<string, mixed> $data Données issues de la base de données
|
||||
*
|
||||
* @return self L'instance hydratée
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
id: (int) ($data['id'] ?? 0),
|
||||
name: (string) ($data['name'] ?? ''),
|
||||
slug: (string) ($data['slug'] ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'identifiant de la catégorie.
|
||||
*
|
||||
* @return int L'identifiant en base (0 si non encore persisté)
|
||||
*/
|
||||
public function getId(): int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le nom de la catégorie.
|
||||
*
|
||||
* @return string Le nom
|
||||
*/
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne le slug URL de la catégorie.
|
||||
*
|
||||
* @return string Le slug
|
||||
*/
|
||||
public function getSlug(): string
|
||||
{
|
||||
return $this->slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide les données de la catégorie.
|
||||
*
|
||||
* @throws \InvalidArgumentException Si le nom est vide ou dépasse 100 caractères
|
||||
*/
|
||||
private function validate(): void
|
||||
{
|
||||
if ($this->name === '') {
|
||||
throw new \InvalidArgumentException('Le nom de la catégorie ne peut pas être vide');
|
||||
}
|
||||
|
||||
if (mb_strlen($this->name) > 100) {
|
||||
throw new \InvalidArgumentException('Le nom de la catégorie ne peut pas dépasser 100 caractères');
|
||||
}
|
||||
}
|
||||
}
|
||||
114
src/Category/CategoryController.php
Normal file
114
src/Category/CategoryController.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Category;
|
||||
|
||||
use App\Shared\Http\FlashServiceInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
/**
|
||||
* Contrôleur pour la gestion des catégories.
|
||||
*
|
||||
* Accessible aux éditeurs et administrateurs (protégé par EditorMiddleware).
|
||||
* Gère la liste des catégories, leur création et leur suppression.
|
||||
* Toute la logique métier (génération de slug, validations, blocage de
|
||||
* suppression) est déléguée à CategoryService.
|
||||
*/
|
||||
final class CategoryController
|
||||
{
|
||||
/**
|
||||
* @param Twig $view Moteur de templates Twig
|
||||
* @param CategoryServiceInterface $categoryService Service de gestion des catégories
|
||||
* @param FlashServiceInterface $flash Service de messages flash
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly Twig $view,
|
||||
private readonly CategoryServiceInterface $categoryService,
|
||||
private readonly FlashServiceInterface $flash,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Affiche la liste des catégories avec le formulaire de création.
|
||||
*
|
||||
* @param Request $req La requête HTTP
|
||||
* @param Response $res La réponse HTTP
|
||||
*
|
||||
* @return Response La vue de gestion des catégories
|
||||
*/
|
||||
public function index(Request $req, Response $res): Response
|
||||
{
|
||||
return $this->view->render($res, 'admin/categories/index.twig', [
|
||||
'categories' => $this->categoryService->findAll(),
|
||||
'error' => $this->flash->get('category_error'),
|
||||
'success' => $this->flash->get('category_success'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Traite la création d'une catégorie.
|
||||
*
|
||||
* Délègue entièrement à CategoryService::create() qui gère la génération
|
||||
* du slug, la validation d'unicité et la validation du modèle.
|
||||
*
|
||||
* @param Request $req La requête HTTP
|
||||
* @param Response $res La réponse HTTP
|
||||
*
|
||||
* @return Response Une redirection vers /admin/categories
|
||||
*/
|
||||
public function create(Request $req, Response $res): Response
|
||||
{
|
||||
/** @var array<string, mixed> $data */
|
||||
$data = (array) $req->getParsedBody();
|
||||
$name = (string) ($data['name'] ?? '');
|
||||
|
||||
try {
|
||||
$this->categoryService->create($name);
|
||||
$trimmed = trim($name);
|
||||
$this->flash->set('category_success', "La catégorie « {$trimmed} » a été créée avec succès");
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->flash->set('category_error', $e->getMessage());
|
||||
} catch (\Throwable) {
|
||||
$this->flash->set('category_error', "Une erreur inattendue s'est produite");
|
||||
}
|
||||
|
||||
return $res->withHeader('Location', '/admin/categories')->withStatus(302);
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une catégorie.
|
||||
*
|
||||
* Délègue à CategoryService::delete() qui refuse la suppression si des
|
||||
* articles sont rattachés à la catégorie.
|
||||
*
|
||||
* @param Request $req La requête HTTP
|
||||
* @param Response $res La réponse HTTP
|
||||
* @param array<string, string> $args Paramètres de route (id)
|
||||
*
|
||||
* @return Response Une redirection vers /admin/categories
|
||||
*/
|
||||
public function delete(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
$id = (int) ($args['id'] ?? 0);
|
||||
$category = $this->categoryService->findById($id);
|
||||
|
||||
if ($category === null) {
|
||||
$this->flash->set('category_error', 'Catégorie introuvable');
|
||||
|
||||
return $res->withHeader('Location', '/admin/categories')->withStatus(302);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->categoryService->delete($category);
|
||||
$this->flash->set('category_success', "La catégorie « {$category->getName()} » a été supprimée");
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->flash->set('category_error', $e->getMessage());
|
||||
} catch (\Throwable) {
|
||||
$this->flash->set('category_error', "Une erreur inattendue s'est produite");
|
||||
}
|
||||
|
||||
return $res->withHeader('Location', '/admin/categories')->withStatus(302);
|
||||
}
|
||||
}
|
||||
134
src/Category/CategoryRepository.php
Normal file
134
src/Category/CategoryRepository.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Category;
|
||||
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Dépôt pour la persistance des catégories.
|
||||
*
|
||||
* Responsabilité unique : exécuter les requêtes SQL liées à la table `categories`
|
||||
* et retourner des instances de Category hydratées.
|
||||
*/
|
||||
final class CategoryRepository implements CategoryRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @param PDO $db Instance de connexion à la base de données
|
||||
*/
|
||||
public function __construct(private readonly PDO $db)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne toutes les catégories triées alphabétiquement.
|
||||
*
|
||||
* @return Category[] La liste des catégories
|
||||
*/
|
||||
public function findAll(): array
|
||||
{
|
||||
$stmt = $this->db->query('SELECT * FROM categories ORDER BY name ASC');
|
||||
if ($stmt === false) {
|
||||
throw new \RuntimeException('La requête SELECT sur categories a échoué.');
|
||||
}
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return array_map(fn ($row) => Category::fromArray($row), $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve une catégorie par son identifiant.
|
||||
*
|
||||
* @param int $id Identifiant de la catégorie
|
||||
*
|
||||
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
|
||||
*/
|
||||
public function findById(int $id): ?Category
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT * FROM categories WHERE id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row ? Category::fromArray($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve une catégorie par son slug URL.
|
||||
*
|
||||
* @param string $slug Le slug de la catégorie
|
||||
*
|
||||
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
|
||||
*/
|
||||
public function findBySlug(string $slug): ?Category
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT * FROM categories WHERE slug = :slug');
|
||||
$stmt->execute([':slug' => $slug]);
|
||||
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row ? Category::fromArray($row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persiste une nouvelle catégorie en base de données.
|
||||
*
|
||||
* @param Category $category La catégorie à créer
|
||||
*
|
||||
* @return int L'identifiant généré par la base de données
|
||||
*/
|
||||
public function create(Category $category): int
|
||||
{
|
||||
$stmt = $this->db->prepare('INSERT INTO categories (name, slug) VALUES (:name, :slug)');
|
||||
$stmt->execute([':name' => $category->getName(), ':slug' => $category->getSlug()]);
|
||||
|
||||
return (int) $this->db->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une catégorie de la base de données.
|
||||
*
|
||||
* @param int $id Identifiant de la catégorie à supprimer
|
||||
*
|
||||
* @return int Nombre de lignes supprimées (0 si la catégorie n'existe plus)
|
||||
*/
|
||||
public function delete(int $id): int
|
||||
{
|
||||
$stmt = $this->db->prepare('DELETE FROM categories WHERE id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si un nom est déjà utilisé par une catégorie existante.
|
||||
*
|
||||
* @param string $name Le nom à vérifier
|
||||
*
|
||||
* @return bool True si le nom est déjà pris
|
||||
*/
|
||||
public function nameExists(string $name): bool
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT 1 FROM categories WHERE name = :name');
|
||||
$stmt->execute([':name' => $name]);
|
||||
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si au moins un article est rattaché à cette catégorie.
|
||||
*
|
||||
* Utilisé avant suppression pour bloquer la suppression d'une catégorie non vide.
|
||||
*
|
||||
* @param int $id Identifiant de la catégorie
|
||||
*
|
||||
* @return bool True si au moins un article référence cette catégorie
|
||||
*/
|
||||
public function hasPost(int $id): bool
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT 1 FROM posts WHERE category_id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
|
||||
return $stmt->fetchColumn() !== false;
|
||||
}
|
||||
}
|
||||
74
src/Category/CategoryRepositoryInterface.php
Normal file
74
src/Category/CategoryRepositoryInterface.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Category;
|
||||
|
||||
/**
|
||||
* Contrat de persistance des catégories.
|
||||
*
|
||||
* Découple les services et contrôleurs de l'implémentation concrète PDO/SQLite,
|
||||
* facilitant les mocks dans les tests unitaires.
|
||||
*/
|
||||
interface CategoryRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* Retourne toutes les catégories triées alphabétiquement.
|
||||
*
|
||||
* @return Category[]
|
||||
*/
|
||||
public function findAll(): array;
|
||||
|
||||
/**
|
||||
* Trouve une catégorie par son identifiant.
|
||||
*
|
||||
* @param int $id Identifiant de la catégorie
|
||||
*
|
||||
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
|
||||
*/
|
||||
public function findById(int $id): ?Category;
|
||||
|
||||
/**
|
||||
* Trouve une catégorie par son slug URL.
|
||||
*
|
||||
* @param string $slug Le slug de la catégorie
|
||||
*
|
||||
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
|
||||
*/
|
||||
public function findBySlug(string $slug): ?Category;
|
||||
|
||||
/**
|
||||
* Persiste une nouvelle catégorie en base de données.
|
||||
*
|
||||
* @param Category $category La catégorie à créer
|
||||
*
|
||||
* @return int L'identifiant généré par la base de données
|
||||
*/
|
||||
public function create(Category $category): int;
|
||||
|
||||
/**
|
||||
* Supprime une catégorie de la base de données.
|
||||
*
|
||||
* @param int $id Identifiant de la catégorie à supprimer
|
||||
*
|
||||
* @return int Nombre de lignes supprimées
|
||||
*/
|
||||
public function delete(int $id): int;
|
||||
|
||||
/**
|
||||
* Vérifie si un nom est déjà utilisé par une catégorie existante.
|
||||
*
|
||||
* @param string $name Le nom à vérifier
|
||||
*
|
||||
* @return bool True si le nom est déjà pris
|
||||
*/
|
||||
public function nameExists(string $name): bool;
|
||||
|
||||
/**
|
||||
* Vérifie si au moins un article est rattaché à cette catégorie.
|
||||
*
|
||||
* @param int $id Identifiant de la catégorie
|
||||
*
|
||||
* @return bool True si au moins un article référence cette catégorie
|
||||
*/
|
||||
public function hasPost(int $id): bool;
|
||||
}
|
||||
120
src/Category/CategoryService.php
Normal file
120
src/Category/CategoryService.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Category;
|
||||
|
||||
use App\Shared\Util\SlugHelper;
|
||||
|
||||
/**
|
||||
* Service de gestion des catégories.
|
||||
*
|
||||
* Centralise la logique métier liée aux catégories :
|
||||
* - génération et validation du slug à la création
|
||||
* - vérification d'unicité du nom
|
||||
* - blocage de la suppression si des articles sont rattachés
|
||||
*
|
||||
* Les lectures (findAll, findById, findBySlug) sont exposées ici
|
||||
* pour que CategoryController et PostController n'injectent pas
|
||||
* directement le repository — cohérent avec le pattern des autres domaines.
|
||||
*/
|
||||
final class CategoryService implements CategoryServiceInterface
|
||||
{
|
||||
/**
|
||||
* @param CategoryRepositoryInterface $categoryRepository Dépôt de persistance des catégories
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly CategoryRepositoryInterface $categoryRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne toutes les catégories triées alphabétiquement.
|
||||
*
|
||||
* @return Category[]
|
||||
*/
|
||||
public function findAll(): array
|
||||
{
|
||||
return $this->categoryRepository->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve une catégorie par son identifiant.
|
||||
*
|
||||
* @param int $id Identifiant de la catégorie
|
||||
*
|
||||
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
|
||||
*/
|
||||
public function findById(int $id): ?Category
|
||||
{
|
||||
return $this->categoryRepository->findById($id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trouve une catégorie par son slug URL.
|
||||
*
|
||||
* @param string $slug Le slug de la catégorie
|
||||
*
|
||||
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
|
||||
*/
|
||||
public function findBySlug(string $slug): ?Category
|
||||
{
|
||||
return $this->categoryRepository->findBySlug($slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Crée une catégorie depuis un nom brut.
|
||||
*
|
||||
* Séquence :
|
||||
* 1. Trim du nom
|
||||
* 2. Génération du slug via SlugHelper
|
||||
* 3. Rejet si le slug est vide (nom sans caractère ASCII exploitable)
|
||||
* 4. Rejet si le nom est déjà utilisé
|
||||
* 5. Construction du modèle (déclenche la validation longueur/vide)
|
||||
* 6. Persistance
|
||||
*
|
||||
* @param string $name Nom brut de la catégorie (non encore trimmé)
|
||||
*
|
||||
* @return int L'identifiant de la catégorie créée
|
||||
*
|
||||
* @throws \InvalidArgumentException Si le slug est vide, le nom déjà pris,
|
||||
* ou si la validation du modèle échoue
|
||||
*/
|
||||
public function create(string $name): int
|
||||
{
|
||||
$name = trim($name);
|
||||
$slug = SlugHelper::generate($name);
|
||||
|
||||
if ($slug === '') {
|
||||
throw new \InvalidArgumentException('Le nom fourni ne peut pas générer un slug URL valide');
|
||||
}
|
||||
|
||||
if ($this->categoryRepository->nameExists($name)) {
|
||||
throw new \InvalidArgumentException('Ce nom de catégorie est déjà utilisé');
|
||||
}
|
||||
|
||||
// Le constructeur de Category valide le nom (vide, longueur max)
|
||||
return $this->categoryRepository->create(new Category(0, $name, $slug));
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime une catégorie.
|
||||
*
|
||||
* Refuse la suppression si au moins un article est rattaché à la catégorie,
|
||||
* afin d'éviter des articles sans catégorie de façon involontaire.
|
||||
*
|
||||
* @param Category $category La catégorie à supprimer
|
||||
*
|
||||
* @throws \InvalidArgumentException Si la catégorie contient des articles
|
||||
* @return void
|
||||
*/
|
||||
public function delete(Category $category): void
|
||||
{
|
||||
if ($this->categoryRepository->hasPost($category->getId())) {
|
||||
throw new \InvalidArgumentException(
|
||||
"La catégorie « {$category->getName()} » contient des articles et ne peut pas être supprimée"
|
||||
);
|
||||
}
|
||||
|
||||
$this->categoryRepository->delete($category->getId());
|
||||
}
|
||||
}
|
||||
64
src/Category/CategoryServiceInterface.php
Normal file
64
src/Category/CategoryServiceInterface.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Category;
|
||||
|
||||
/**
|
||||
* Contrat du service de gestion des catégories.
|
||||
*
|
||||
* Permet de mocker le service dans les tests unitaires sans dépendre
|
||||
* de la classe concrète finale CategoryService.
|
||||
*/
|
||||
interface CategoryServiceInterface
|
||||
{
|
||||
/**
|
||||
* Retourne toutes les catégories triées alphabétiquement.
|
||||
*
|
||||
* @return Category[]
|
||||
*/
|
||||
public function findAll(): array;
|
||||
|
||||
/**
|
||||
* Trouve une catégorie par son identifiant.
|
||||
*
|
||||
* @param int $id Identifiant de la catégorie
|
||||
*
|
||||
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
|
||||
*/
|
||||
public function findById(int $id): ?Category;
|
||||
|
||||
/**
|
||||
* Trouve une catégorie par son slug URL.
|
||||
*
|
||||
* @param string $slug Le slug de la catégorie
|
||||
*
|
||||
* @return Category|null La catégorie trouvée, ou null si elle n'existe pas
|
||||
*/
|
||||
public function findBySlug(string $slug): ?Category;
|
||||
|
||||
/**
|
||||
* Crée une catégorie depuis un nom brut.
|
||||
*
|
||||
* Génère le slug, valide l'unicité du nom et délègue la construction
|
||||
* du modèle au constructeur de Category (qui valide taille et contenu).
|
||||
*
|
||||
* @param string $name Nom brut de la catégorie (non encore trimmé)
|
||||
*
|
||||
* @return int L'identifiant de la catégorie créée
|
||||
*
|
||||
* @throws \InvalidArgumentException Si le nom produit un slug vide ou est déjà utilisé
|
||||
*/
|
||||
public function create(string $name): int;
|
||||
|
||||
/**
|
||||
* Supprime une catégorie.
|
||||
*
|
||||
* Refuse la suppression si des articles sont rattachés à la catégorie.
|
||||
*
|
||||
* @param Category $category La catégorie à supprimer
|
||||
*
|
||||
* @throws \InvalidArgumentException Si la catégorie contient des articles
|
||||
* @return void
|
||||
*/
|
||||
public function delete(Category $category): void;
|
||||
}
|
||||
Reference in New Issue
Block a user