Refatoring : Working state
This commit is contained in:
@@ -21,18 +21,22 @@ use App\Auth\PasswordResetRepository;
|
||||
use App\Auth\PasswordResetRepositoryInterface;
|
||||
use App\Auth\PasswordResetService;
|
||||
use App\Auth\PasswordResetServiceInterface;
|
||||
use App\Category\Application\CategoryApplicationService;
|
||||
use App\Category\CategoryRepository;
|
||||
use App\Category\CategoryRepositoryInterface;
|
||||
use App\Category\CategoryService;
|
||||
use App\Category\CategoryServiceInterface;
|
||||
use App\Category\Infrastructure\PdoCategoryRepository;
|
||||
use App\Media\MediaRepository;
|
||||
use App\Media\MediaRepositoryInterface;
|
||||
use App\Media\MediaService;
|
||||
use App\Media\MediaServiceInterface;
|
||||
use App\Post\Application\PostApplicationService;
|
||||
use App\Post\PostRepository;
|
||||
use App\Post\PostRepositoryInterface;
|
||||
use App\Post\PostService;
|
||||
use App\Post\PostServiceInterface;
|
||||
use App\Post\Infrastructure\PdoPostRepository;
|
||||
use App\Post\RssController;
|
||||
use App\Shared\Config;
|
||||
use App\Shared\Extension\AppExtension;
|
||||
@@ -65,12 +69,12 @@ return [
|
||||
// ── Bindings interface → implémentation ──────────────────────────────────
|
||||
|
||||
AuthServiceInterface::class => autowire(AuthService::class),
|
||||
PostServiceInterface::class => autowire(PostService::class),
|
||||
PostServiceInterface::class => autowire(PostApplicationService::class),
|
||||
UserServiceInterface::class => autowire(UserService::class),
|
||||
CategoryServiceInterface::class => autowire(CategoryService::class),
|
||||
CategoryRepositoryInterface::class => autowire(CategoryRepository::class),
|
||||
CategoryServiceInterface::class => autowire(CategoryApplicationService::class),
|
||||
CategoryRepositoryInterface::class => autowire(PdoCategoryRepository::class),
|
||||
MediaRepositoryInterface::class => autowire(MediaRepository::class),
|
||||
PostRepositoryInterface::class => autowire(PostRepository::class),
|
||||
PostRepositoryInterface::class => autowire(PdoPostRepository::class),
|
||||
UserRepositoryInterface::class => autowire(UserRepository::class),
|
||||
LoginAttemptRepositoryInterface::class => autowire(LoginAttemptRepository::class),
|
||||
PasswordResetRepositoryInterface::class => autowire(PasswordResetRepository::class),
|
||||
|
||||
0
database/.provision.lock
Normal file
0
database/.provision.lock
Normal file
@@ -1,5 +1,13 @@
|
||||
# Architecture
|
||||
|
||||
> **Refactor DDD légère — lot 1**
|
||||
>
|
||||
> `Post/` et `Category/` introduisent maintenant une organisation verticale
|
||||
> `Application / Infrastructure / Http / Domain` pour alléger la lecture et préparer
|
||||
> un découpage plus fin par cas d'usage. Les classes historiques à la racine du domaine
|
||||
> sont conservées comme **ponts de compatibilité** afin de préserver les routes, le conteneur
|
||||
> DI et la suite de tests pendant la transition.
|
||||
|
||||
## Domaines PHP
|
||||
|
||||
Chaque domaine dans `src/` est autonome : modèle, interface de dépôt, implémentation du dépôt,
|
||||
|
||||
75
src/Category/Application/CategoryApplicationService.php
Normal file
75
src/Category/Application/CategoryApplicationService.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Category\Application;
|
||||
|
||||
use App\Category\Category;
|
||||
use App\Category\CategoryRepositoryInterface;
|
||||
use App\Category\CategoryServiceInterface;
|
||||
use App\Category\Domain\CategorySlugGenerator;
|
||||
use App\Shared\Pagination\PaginatedResult;
|
||||
|
||||
class CategoryApplicationService implements CategoryServiceInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CategoryRepositoryInterface $categoryRepository,
|
||||
private readonly CategorySlugGenerator $slugGenerator = new CategorySlugGenerator(),
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return Category[] */
|
||||
public function findAll(): array
|
||||
{
|
||||
return $this->categoryRepository->findAll();
|
||||
}
|
||||
|
||||
/** @return PaginatedResult<Category> */
|
||||
public function findPaginated(int $page, int $perPage): PaginatedResult
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$total = $this->categoryRepository->countAll();
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return new PaginatedResult(
|
||||
$this->categoryRepository->findPage($perPage, $offset),
|
||||
$total,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Category
|
||||
{
|
||||
return $this->categoryRepository->findById($id);
|
||||
}
|
||||
|
||||
public function findBySlug(string $slug): ?Category
|
||||
{
|
||||
return $this->categoryRepository->findBySlug($slug);
|
||||
}
|
||||
|
||||
public function create(string $name): int
|
||||
{
|
||||
$name = trim($name);
|
||||
$slug = $this->slugGenerator->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é');
|
||||
}
|
||||
|
||||
return $this->categoryRepository->create(new Category(0, $name, $slug));
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -3,76 +3,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Category;
|
||||
|
||||
use App\Shared\Http\FlashServiceInterface;
|
||||
use App\Shared\Pagination\PaginationPresenter;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
final class CategoryController
|
||||
{
|
||||
private const PER_PAGE = 20;
|
||||
|
||||
public function __construct(
|
||||
private readonly Twig $view,
|
||||
private readonly CategoryServiceInterface $categoryService,
|
||||
private readonly FlashServiceInterface $flash,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $req, Response $res): Response
|
||||
{
|
||||
$page = PaginationPresenter::resolvePage($req->getQueryParams());
|
||||
$paginated = $this->categoryService->findPaginated($page, self::PER_PAGE);
|
||||
|
||||
return $this->view->render($res, 'admin/categories/index.twig', [
|
||||
'categories' => $paginated->getItems(),
|
||||
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
|
||||
'error' => $this->flash->get('category_error'),
|
||||
'success' => $this->flash->get('category_success'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $req, Response $res): Response
|
||||
{
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $args
|
||||
/**
|
||||
* Pont de compatibilité : le contrôleur HTTP principal vit désormais dans
|
||||
* App\Category\Http\CategoryController.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
final class CategoryController extends Http\CategoryController
|
||||
{
|
||||
}
|
||||
|
||||
@@ -3,91 +3,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Category;
|
||||
|
||||
use PDO;
|
||||
use App\Category\Infrastructure\PdoCategoryRepository;
|
||||
|
||||
final class CategoryRepository implements CategoryRepositoryInterface
|
||||
/**
|
||||
* Pont de compatibilité : l'implémentation PDO principale vit désormais dans
|
||||
* App\Category\Infrastructure\PdoCategoryRepository.
|
||||
*/
|
||||
final class CategoryRepository extends PdoCategoryRepository implements CategoryRepositoryInterface
|
||||
{
|
||||
public function __construct(private readonly PDO $db)
|
||||
{
|
||||
}
|
||||
|
||||
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é.');
|
||||
}
|
||||
|
||||
return array_map(fn ($row) => Category::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function findPage(int $limit, int $offset): array
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT * FROM categories ORDER BY name ASC LIMIT :limit OFFSET :offset');
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return array_map(fn ($row) => Category::fromArray($row), $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countAll(): int
|
||||
{
|
||||
$stmt = $this->db->query('SELECT COUNT(*) FROM categories');
|
||||
if ($stmt === false) {
|
||||
throw new \RuntimeException('La requête COUNT sur categories a échoué.');
|
||||
}
|
||||
|
||||
return (int) ($stmt->fetchColumn() ?: 0);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public function delete(int $id): int
|
||||
{
|
||||
$stmt = $this->db->prepare('DELETE FROM categories WHERE id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,72 +3,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Category;
|
||||
|
||||
use App\Shared\Pagination\PaginatedResult;
|
||||
use App\Shared\Util\SlugHelper;
|
||||
use App\Category\Application\CategoryApplicationService;
|
||||
|
||||
final class CategoryService implements CategoryServiceInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly CategoryRepositoryInterface $categoryRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
public function findAll(): array
|
||||
{
|
||||
return $this->categoryRepository->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PaginatedResult<Category>
|
||||
/**
|
||||
* Pont de compatibilité : l'implémentation métier principale vit désormais dans
|
||||
* App\Category\Application\CategoryApplicationService.
|
||||
*/
|
||||
public function findPaginated(int $page, int $perPage): PaginatedResult
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$total = $this->categoryRepository->countAll();
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return new PaginatedResult(
|
||||
$this->categoryRepository->findPage($perPage, $offset),
|
||||
$total,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Category
|
||||
{
|
||||
return $this->categoryRepository->findById($id);
|
||||
}
|
||||
|
||||
public function findBySlug(string $slug): ?Category
|
||||
{
|
||||
return $this->categoryRepository->findBySlug($slug);
|
||||
}
|
||||
|
||||
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é');
|
||||
}
|
||||
|
||||
return $this->categoryRepository->create(new Category(0, $name, $slug));
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
final class CategoryService extends CategoryApplicationService implements CategoryServiceInterface
|
||||
{
|
||||
}
|
||||
|
||||
14
src/Category/Domain/CategorySlugGenerator.php
Normal file
14
src/Category/Domain/CategorySlugGenerator.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Category\Domain;
|
||||
|
||||
use App\Shared\Util\SlugHelper;
|
||||
|
||||
final class CategorySlugGenerator
|
||||
{
|
||||
public function generate(string $name): string
|
||||
{
|
||||
return SlugHelper::generate($name);
|
||||
}
|
||||
}
|
||||
77
src/Category/Http/CategoryController.php
Normal file
77
src/Category/Http/CategoryController.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Category\Http;
|
||||
|
||||
use App\Category\CategoryServiceInterface;
|
||||
use App\Shared\Http\FlashServiceInterface;
|
||||
use App\Shared\Pagination\PaginationPresenter;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
class CategoryController
|
||||
{
|
||||
private const PER_PAGE = 20;
|
||||
|
||||
public function __construct(
|
||||
private readonly Twig $view,
|
||||
private readonly CategoryServiceInterface $categoryService,
|
||||
private readonly FlashServiceInterface $flash,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $req, Response $res): Response
|
||||
{
|
||||
$page = PaginationPresenter::resolvePage($req->getQueryParams());
|
||||
$paginated = $this->categoryService->findPaginated($page, self::PER_PAGE);
|
||||
|
||||
return $this->view->render($res, 'admin/categories/index.twig', [
|
||||
'categories' => $paginated->getItems(),
|
||||
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
|
||||
'error' => $this->flash->get('category_error'),
|
||||
'success' => $this->flash->get('category_success'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $req, Response $res): Response
|
||||
{
|
||||
$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);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $args */
|
||||
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);
|
||||
}
|
||||
}
|
||||
106
src/Category/Infrastructure/PdoCategoryRepository.php
Normal file
106
src/Category/Infrastructure/PdoCategoryRepository.php
Normal file
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Category\Infrastructure;
|
||||
|
||||
use App\Category\Category;
|
||||
use App\Category\CategoryRepositoryInterface;
|
||||
use PDO;
|
||||
|
||||
class PdoCategoryRepository implements CategoryRepositoryInterface
|
||||
{
|
||||
public function __construct(private readonly PDO $db)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return Category[] */
|
||||
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é.');
|
||||
}
|
||||
|
||||
return array_map(
|
||||
static fn (array $row): Category => Category::fromArray($row),
|
||||
$stmt->fetchAll(PDO::FETCH_ASSOC)
|
||||
);
|
||||
}
|
||||
|
||||
/** @return Category[] */
|
||||
public function findPage(int $limit, int $offset): array
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT * FROM categories ORDER BY name ASC LIMIT :limit OFFSET :offset');
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return array_map(
|
||||
static fn (array $row): Category => Category::fromArray($row),
|
||||
$stmt->fetchAll(PDO::FETCH_ASSOC)
|
||||
);
|
||||
}
|
||||
|
||||
public function countAll(): int
|
||||
{
|
||||
$stmt = $this->db->query('SELECT COUNT(*) FROM categories');
|
||||
if ($stmt === false) {
|
||||
throw new \RuntimeException('Le comptage des catégories a échoué.');
|
||||
}
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public function delete(int $id): int
|
||||
{
|
||||
$stmt = $this->db->prepare('DELETE FROM categories WHERE id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
public function nameExists(string $name): bool
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM categories WHERE name = :name');
|
||||
$stmt->execute([':name' => $name]);
|
||||
|
||||
return (int) $stmt->fetchColumn() > 0;
|
||||
}
|
||||
|
||||
public function hasPost(int $id): bool
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE category_id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
|
||||
return (int) $stmt->fetchColumn() > 0;
|
||||
}
|
||||
}
|
||||
174
src/Post/Application/PostApplicationService.php
Normal file
174
src/Post/Application/PostApplicationService.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Application;
|
||||
|
||||
use App\Post\Domain\PostSlugGenerator;
|
||||
use App\Post\Post;
|
||||
use App\Post\PostRepositoryInterface;
|
||||
use App\Post\PostServiceInterface;
|
||||
use App\Shared\Exception\NotFoundException;
|
||||
use App\Shared\Html\HtmlSanitizerInterface;
|
||||
use App\Shared\Pagination\PaginatedResult;
|
||||
|
||||
class PostApplicationService implements PostServiceInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PostRepositoryInterface $postRepository,
|
||||
private readonly HtmlSanitizerInterface $htmlSanitizer,
|
||||
private readonly PostSlugGenerator $slugGenerator = new PostSlugGenerator(),
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function getAllPosts(?int $categoryId = null): array
|
||||
{
|
||||
return $this->postRepository->findAll($categoryId);
|
||||
}
|
||||
|
||||
/** @return PaginatedResult<Post> */
|
||||
public function getAllPostsPaginated(int $page, int $perPage, ?int $categoryId = null): PaginatedResult
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$total = $this->postRepository->countAll($categoryId);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return new PaginatedResult(
|
||||
$this->postRepository->findPage($perPage, $offset, $categoryId),
|
||||
$total,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function getRecentPosts(int $limit = 20): array
|
||||
{
|
||||
return $this->postRepository->findRecent($limit);
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function getPostsByUserId(int $userId, ?int $categoryId = null): array
|
||||
{
|
||||
return $this->postRepository->findByUserId($userId, $categoryId);
|
||||
}
|
||||
|
||||
/** @return PaginatedResult<Post> */
|
||||
public function getPostsByUserIdPaginated(int $userId, int $page, int $perPage, ?int $categoryId = null): PaginatedResult
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$total = $this->postRepository->countByUserId($userId, $categoryId);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return new PaginatedResult(
|
||||
$this->postRepository->findByUserPage($userId, $perPage, $offset, $categoryId),
|
||||
$total,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
}
|
||||
|
||||
public function getPostBySlug(string $slug): Post
|
||||
{
|
||||
$post = $this->postRepository->findBySlug($slug);
|
||||
|
||||
if ($post === null) {
|
||||
throw new NotFoundException('Article', $slug);
|
||||
}
|
||||
|
||||
return $post;
|
||||
}
|
||||
|
||||
public function getPostById(int $id): Post
|
||||
{
|
||||
$post = $this->postRepository->findById($id);
|
||||
|
||||
if ($post === null) {
|
||||
throw new NotFoundException('Article', $id);
|
||||
}
|
||||
|
||||
return $post;
|
||||
}
|
||||
|
||||
public function createPost(string $title, string $content, int $authorId, ?int $categoryId = null): int
|
||||
{
|
||||
$sanitizedContent = $this->htmlSanitizer->sanitize($content);
|
||||
$post = new Post(0, $title, $sanitizedContent);
|
||||
$slug = $this->generateUniqueSlug($post->generateSlug());
|
||||
|
||||
return $this->postRepository->create($post, $slug, $authorId, $categoryId);
|
||||
}
|
||||
|
||||
public function updatePost(int $id, string $title, string $content, string $newSlugInput = '', ?int $categoryId = null): void
|
||||
{
|
||||
$current = $this->postRepository->findById($id);
|
||||
|
||||
if ($current === null) {
|
||||
throw new NotFoundException('Article', $id);
|
||||
}
|
||||
|
||||
$sanitizedContent = $this->htmlSanitizer->sanitize($content);
|
||||
$post = new Post($id, $title, $sanitizedContent);
|
||||
$slugToUse = $current->getStoredSlug();
|
||||
$cleanSlugInput = $this->slugGenerator->normalize(trim($newSlugInput));
|
||||
|
||||
if ($cleanSlugInput !== '' && $cleanSlugInput !== $current->getStoredSlug()) {
|
||||
$slugToUse = $this->generateUniqueSlug($cleanSlugInput, $id);
|
||||
}
|
||||
|
||||
$affected = $this->postRepository->update($id, $post, $slugToUse, $categoryId);
|
||||
|
||||
if ($affected === 0) {
|
||||
throw new NotFoundException('Article', $id);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function searchPosts(string $query, ?int $categoryId = null, ?int $authorId = null): array
|
||||
{
|
||||
return $this->postRepository->search($query, $categoryId, $authorId);
|
||||
}
|
||||
|
||||
/** @return PaginatedResult<Post> */
|
||||
public function searchPostsPaginated(string $query, int $page, int $perPage, ?int $categoryId = null, ?int $authorId = null): PaginatedResult
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$total = $this->postRepository->countSearch($query, $categoryId, $authorId);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return new PaginatedResult(
|
||||
$this->postRepository->searchPage($query, $perPage, $offset, $categoryId, $authorId),
|
||||
$total,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
}
|
||||
|
||||
public function countMediaUsages(string $url): int
|
||||
{
|
||||
return $this->postRepository->countByEmbeddedMediaUrl($url);
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findMediaUsages(string $url, int $limit = 5): array
|
||||
{
|
||||
return $this->postRepository->findByEmbeddedMediaUrl($url, $limit);
|
||||
}
|
||||
|
||||
public function deletePost(int $id): void
|
||||
{
|
||||
$affected = $this->postRepository->delete($id);
|
||||
|
||||
if ($affected === 0) {
|
||||
throw new NotFoundException('Article', $id);
|
||||
}
|
||||
}
|
||||
|
||||
private function generateUniqueSlug(string $baseSlug, ?int $excludeId = null): string
|
||||
{
|
||||
return $this->slugGenerator->unique(
|
||||
$baseSlug,
|
||||
fn (string $slug): bool => $this->postRepository->slugExists($slug, $excludeId),
|
||||
);
|
||||
}
|
||||
}
|
||||
27
src/Post/Domain/PostSlugGenerator.php
Normal file
27
src/Post/Domain/PostSlugGenerator.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Domain;
|
||||
|
||||
use App\Shared\Util\SlugHelper;
|
||||
|
||||
final class PostSlugGenerator
|
||||
{
|
||||
public function normalize(string $input): string
|
||||
{
|
||||
return SlugHelper::generate($input);
|
||||
}
|
||||
|
||||
public function unique(string $baseSlug, callable $exists): string
|
||||
{
|
||||
$slug = $baseSlug;
|
||||
$counter = 1;
|
||||
|
||||
while ($exists($slug)) {
|
||||
$slug = $baseSlug . '-' . $counter;
|
||||
++$counter;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
246
src/Post/Http/PostController.php
Normal file
246
src/Post/Http/PostController.php
Normal file
@@ -0,0 +1,246 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Http;
|
||||
|
||||
use App\Category\CategoryServiceInterface;
|
||||
use App\Post\Post;
|
||||
use App\Post\PostServiceInterface;
|
||||
use App\Shared\Exception\NotFoundException;
|
||||
use App\Shared\Http\FlashServiceInterface;
|
||||
use App\Shared\Http\SessionManagerInterface;
|
||||
use App\Shared\Pagination\PaginationPresenter;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\Exception\HttpNotFoundException;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
class PostController
|
||||
{
|
||||
private const PUBLIC_PER_PAGE = 6;
|
||||
private const ADMIN_PER_PAGE = 12;
|
||||
|
||||
public function __construct(
|
||||
private readonly Twig $view,
|
||||
private readonly PostServiceInterface $postService,
|
||||
private readonly CategoryServiceInterface $categoryService,
|
||||
private readonly FlashServiceInterface $flash,
|
||||
private readonly SessionManagerInterface $sessionManager,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $req, Response $res): Response
|
||||
{
|
||||
$params = $req->getQueryParams();
|
||||
$page = PaginationPresenter::resolvePage($params);
|
||||
$searchQuery = trim((string) ($params['q'] ?? ''));
|
||||
$categorySlug = (string) ($params['categorie'] ?? '');
|
||||
$activeCategory = null;
|
||||
$categoryId = null;
|
||||
|
||||
if ($categorySlug !== '') {
|
||||
$activeCategory = $this->categoryService->findBySlug($categorySlug);
|
||||
$categoryId = $activeCategory?->getId();
|
||||
}
|
||||
|
||||
$paginated = $searchQuery !== ''
|
||||
? $this->postService->searchPostsPaginated($searchQuery, $page, self::PUBLIC_PER_PAGE, $categoryId)
|
||||
: $this->postService->getAllPostsPaginated($page, self::PUBLIC_PER_PAGE, $categoryId);
|
||||
|
||||
return $this->view->render($res, 'pages/home.twig', [
|
||||
'posts' => $paginated->getItems(),
|
||||
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
|
||||
'totalPosts' => $paginated->getTotal(),
|
||||
'categories' => $this->categoryService->findAll(),
|
||||
'activeCategory' => $activeCategory,
|
||||
'searchQuery' => $searchQuery,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $args */
|
||||
public function show(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
try {
|
||||
$post = $this->postService->getPostBySlug((string) ($args['slug'] ?? ''));
|
||||
} catch (NotFoundException) {
|
||||
throw new HttpNotFoundException($req);
|
||||
}
|
||||
|
||||
return $this->view->render($res, 'pages/post/detail.twig', ['post' => $post]);
|
||||
}
|
||||
|
||||
public function admin(Request $req, Response $res): Response
|
||||
{
|
||||
$isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor();
|
||||
$userId = $this->sessionManager->getUserId();
|
||||
$params = $req->getQueryParams();
|
||||
$page = PaginationPresenter::resolvePage($params);
|
||||
$searchQuery = trim((string) ($params['q'] ?? ''));
|
||||
$categorySlug = (string) ($params['categorie'] ?? '');
|
||||
$activeCategory = null;
|
||||
$categoryId = null;
|
||||
|
||||
if ($categorySlug !== '') {
|
||||
$activeCategory = $this->categoryService->findBySlug($categorySlug);
|
||||
$categoryId = $activeCategory?->getId();
|
||||
}
|
||||
|
||||
if ($searchQuery !== '') {
|
||||
$authorId = $isAdmin ? null : (int) $userId;
|
||||
$paginated = $this->postService->searchPostsPaginated(
|
||||
$searchQuery,
|
||||
$page,
|
||||
self::ADMIN_PER_PAGE,
|
||||
$categoryId,
|
||||
$authorId,
|
||||
);
|
||||
} else {
|
||||
$paginated = $isAdmin
|
||||
? $this->postService->getAllPostsPaginated($page, self::ADMIN_PER_PAGE, $categoryId)
|
||||
: $this->postService->getPostsByUserIdPaginated((int) $userId, $page, self::ADMIN_PER_PAGE, $categoryId);
|
||||
}
|
||||
|
||||
return $this->view->render($res, 'admin/posts/index.twig', [
|
||||
'posts' => $paginated->getItems(),
|
||||
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
|
||||
'totalPosts' => $paginated->getTotal(),
|
||||
'categories' => $this->categoryService->findAll(),
|
||||
'activeCategory' => $activeCategory,
|
||||
'searchQuery' => $searchQuery,
|
||||
'error' => $this->flash->get('post_error'),
|
||||
'success' => $this->flash->get('post_success'),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $args */
|
||||
public function form(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
$id = (int) ($args['id'] ?? 0);
|
||||
$post = null;
|
||||
|
||||
if ($id > 0) {
|
||||
try {
|
||||
$post = $this->postService->getPostById($id);
|
||||
} catch (NotFoundException) {
|
||||
throw new HttpNotFoundException($req);
|
||||
}
|
||||
|
||||
if (!$this->canEditPost($post)) {
|
||||
$this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur");
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->view->render($res, 'admin/posts/form.twig', [
|
||||
'post' => $post,
|
||||
'categories' => $this->categoryService->findAll(),
|
||||
'action' => $id > 0 ? "/admin/posts/edit/{$id}" : '/admin/posts/create',
|
||||
'error' => $this->flash->get('post_error'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $req, Response $res): Response
|
||||
{
|
||||
['title' => $title, 'content' => $content, 'category_id' => $categoryId] = $this->extractPostData($req);
|
||||
|
||||
try {
|
||||
$this->postService->createPost($title, $content, $this->sessionManager->getUserId() ?? 0, $categoryId);
|
||||
$this->flash->set('post_success', 'L\'article a été créé avec succès');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->flash->set('post_error', $e->getMessage());
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts/edit/0')->withStatus(302);
|
||||
} catch (\Throwable) {
|
||||
$this->flash->set('post_error', 'Une erreur inattendue s\'est produite');
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts/edit/0')->withStatus(302);
|
||||
}
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $args */
|
||||
public function update(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
$id = (int) $args['id'];
|
||||
['title' => $title, 'content' => $content, 'slug' => $slug, 'category_id' => $categoryId] = $this->extractPostData($req);
|
||||
|
||||
try {
|
||||
$post = $this->postService->getPostById($id);
|
||||
} catch (NotFoundException) {
|
||||
throw new HttpNotFoundException($req);
|
||||
}
|
||||
|
||||
if (!$this->canEditPost($post)) {
|
||||
$this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur");
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->postService->updatePost($id, $title, $content, $slug, $categoryId);
|
||||
$this->flash->set('post_success', 'L\'article a été modifié avec succès');
|
||||
} catch (NotFoundException) {
|
||||
throw new HttpNotFoundException($req);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->flash->set('post_error', $e->getMessage());
|
||||
|
||||
return $res->withHeader('Location', "/admin/posts/edit/{$id}")->withStatus(302);
|
||||
} catch (\Throwable) {
|
||||
$this->flash->set('post_error', 'Une erreur inattendue s\'est produite');
|
||||
|
||||
return $res->withHeader('Location', "/admin/posts/edit/{$id}")->withStatus(302);
|
||||
}
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $args */
|
||||
public function delete(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
try {
|
||||
$post = $this->postService->getPostById((int) $args['id']);
|
||||
} catch (NotFoundException) {
|
||||
throw new HttpNotFoundException($req);
|
||||
}
|
||||
|
||||
if (!$this->canEditPost($post)) {
|
||||
$this->flash->set('post_error', "Vous ne pouvez pas supprimer un article dont vous n'êtes pas l'auteur");
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->postService->deletePost($post->getId());
|
||||
} catch (NotFoundException) {
|
||||
throw new HttpNotFoundException($req);
|
||||
}
|
||||
|
||||
$this->flash->set('post_success', "L'article « {$post->getTitle()} » a été supprimé avec succès");
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
|
||||
private function canEditPost(Post $post): bool
|
||||
{
|
||||
if ($this->sessionManager->isAdmin() || $this->sessionManager->isEditor()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $post->getAuthorId() === $this->sessionManager->getUserId();
|
||||
}
|
||||
|
||||
/** @return array{title: string, content: string, slug: string, category_id: ?int} */
|
||||
private function extractPostData(Request $req): array
|
||||
{
|
||||
$data = (array) $req->getParsedBody();
|
||||
|
||||
return [
|
||||
'title' => trim((string) ($data['title'] ?? '')),
|
||||
'content' => (string) ($data['content'] ?? ''),
|
||||
'slug' => trim((string) ($data['slug'] ?? '')),
|
||||
'category_id' => isset($data['category_id']) && $data['category_id'] !== '' ? (int) $data['category_id'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
57
src/Post/Http/RssController.php
Normal file
57
src/Post/Http/RssController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Http;
|
||||
|
||||
use App\Post\PostServiceInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
|
||||
class RssController
|
||||
{
|
||||
private const FEED_LIMIT = 20;
|
||||
|
||||
public function __construct(
|
||||
private readonly PostServiceInterface $postService,
|
||||
private readonly string $appUrl,
|
||||
private readonly string $appName,
|
||||
) {
|
||||
}
|
||||
|
||||
public function feed(Request $req, Response $res): Response
|
||||
{
|
||||
$posts = $this->postService->getRecentPosts(self::FEED_LIMIT);
|
||||
$baseUrl = $this->appUrl;
|
||||
|
||||
$xml = new \SimpleXMLElement('<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"></rss>');
|
||||
$channel = $xml->addChild('channel');
|
||||
$channel->addChild('title', htmlspecialchars($this->appName));
|
||||
$channel->addChild('link', $baseUrl . '/');
|
||||
$channel->addChild('description', htmlspecialchars($this->appName . ' — flux RSS'));
|
||||
$channel->addChild('language', 'fr-FR');
|
||||
$channel->addChild('lastBuildDate', (new \DateTime())->format(\DateTime::RSS));
|
||||
|
||||
foreach ($posts as $post) {
|
||||
$item = $channel->addChild('item');
|
||||
$item->addChild('title', htmlspecialchars($post->getTitle()));
|
||||
$postUrl = $baseUrl . '/article/' . $post->getStoredSlug();
|
||||
$item->addChild('link', $postUrl);
|
||||
$item->addChild('guid', $postUrl);
|
||||
$excerpt = strip_tags($post->getContent());
|
||||
$excerpt = mb_strlen($excerpt) > 300 ? mb_substr($excerpt, 0, 300) . '…' : $excerpt;
|
||||
$item->addChild('description', htmlspecialchars($excerpt));
|
||||
$item->addChild('pubDate', $post->getCreatedAt()->format(\DateTime::RSS));
|
||||
if ($post->getAuthorUsername() !== null) {
|
||||
$item->addChild('author', htmlspecialchars($post->getAuthorUsername()));
|
||||
}
|
||||
if ($post->getCategoryName() !== null) {
|
||||
$item->addChild('category', htmlspecialchars($post->getCategoryName()));
|
||||
}
|
||||
}
|
||||
|
||||
$body = $xml->asXML();
|
||||
$res->getBody()->write($body !== false ? $body : '');
|
||||
|
||||
return $res->withHeader('Content-Type', 'application/rss+xml; charset=utf-8');
|
||||
}
|
||||
}
|
||||
332
src/Post/Infrastructure/PdoPostRepository.php
Normal file
332
src/Post/Infrastructure/PdoPostRepository.php
Normal file
@@ -0,0 +1,332 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Infrastructure;
|
||||
|
||||
use App\Post\Post;
|
||||
use App\Post\PostRepositoryInterface;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
|
||||
class PdoPostRepository implements PostRepositoryInterface
|
||||
{
|
||||
private const SELECT = '
|
||||
SELECT posts.id, posts.title, posts.content, posts.slug,
|
||||
posts.author_id, posts.category_id, posts.created_at, posts.updated_at,
|
||||
users.username AS author_username,
|
||||
categories.name AS category_name,
|
||||
categories.slug AS category_slug
|
||||
FROM posts
|
||||
LEFT JOIN users ON users.id = posts.author_id
|
||||
LEFT JOIN categories ON categories.id = posts.category_id
|
||||
';
|
||||
|
||||
public function __construct(private readonly PDO $db)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findAll(?int $categoryId = null): array
|
||||
{
|
||||
if ($categoryId === null) {
|
||||
$stmt = $this->db->query(self::SELECT . ' ORDER BY posts.id DESC');
|
||||
if ($stmt === false) {
|
||||
throw new \RuntimeException('La requête SELECT sur posts a échoué.');
|
||||
}
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.category_id = :category_id ORDER BY posts.id DESC');
|
||||
$stmt->execute([':category_id' => $categoryId]);
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findPage(int $limit, int $offset, ?int $categoryId = null): array
|
||||
{
|
||||
$sql = self::SELECT;
|
||||
$params = [];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' WHERE posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countAll(?int $categoryId = null): int
|
||||
{
|
||||
if ($categoryId === null) {
|
||||
$stmt = $this->db->query('SELECT COUNT(*) FROM posts');
|
||||
if ($stmt === false) {
|
||||
throw new \RuntimeException('Le comptage des posts a échoué.');
|
||||
}
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE category_id = :category_id');
|
||||
$stmt->execute([':category_id' => $categoryId]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findRecent(int $limit): array
|
||||
{
|
||||
$stmt = $this->db->prepare(self::SELECT . ' ORDER BY posts.created_at DESC LIMIT :limit');
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findByUserId(int $userId, ?int $categoryId = null): array
|
||||
{
|
||||
$sql = self::SELECT . ' WHERE posts.author_id = :author_id';
|
||||
$params = [':author_id' => $userId];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY posts.id DESC';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findByUserPage(int $userId, int $limit, int $offset, ?int $categoryId = null): array
|
||||
{
|
||||
$sql = self::SELECT . ' WHERE posts.author_id = :author_id';
|
||||
$params = [':author_id' => $userId];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countByUserId(int $userId, ?int $categoryId = null): int
|
||||
{
|
||||
$sql = 'SELECT COUNT(*) FROM posts WHERE author_id = :author_id';
|
||||
$params = [':author_id' => $userId];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public function findBySlug(string $slug): ?Post
|
||||
{
|
||||
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.slug = :slug');
|
||||
$stmt->execute([':slug' => $slug]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row ? Post::fromArray($row) : null;
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Post
|
||||
{
|
||||
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row ? Post::fromArray($row) : null;
|
||||
}
|
||||
|
||||
public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'INSERT INTO posts (title, content, slug, author_id, category_id, created_at, updated_at)
|
||||
VALUES (:title, :content, :slug, :author_id, :category_id, :created_at, :updated_at)'
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
':title' => $post->getTitle(),
|
||||
':content' => $post->getContent(),
|
||||
':slug' => $slug,
|
||||
':author_id' => $authorId,
|
||||
':category_id' => $categoryId,
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
':updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return (int) $this->db->lastInsertId();
|
||||
}
|
||||
|
||||
public function update(int $id, Post $post, string $slug, ?int $categoryId): int
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'UPDATE posts
|
||||
SET title = :title, content = :content, slug = :slug,
|
||||
category_id = :category_id, updated_at = :updated_at
|
||||
WHERE id = :id'
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
':title' => $post->getTitle(),
|
||||
':content' => $post->getContent(),
|
||||
':slug' => $slug,
|
||||
':category_id' => $categoryId,
|
||||
':updated_at' => date('Y-m-d H:i:s'),
|
||||
':id' => $id,
|
||||
]);
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
public function delete(int $id): int
|
||||
{
|
||||
$stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array
|
||||
{
|
||||
$params = [':q' => $query];
|
||||
$sql = $this->buildSearchSql($query, $params, $categoryId, $authorId);
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array
|
||||
{
|
||||
$params = [':q' => $query];
|
||||
$sql = $this->buildSearchSql($query, $params, $categoryId, $authorId);
|
||||
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countSearch(string $query, ?int $categoryId = null, ?int $authorId = null): int
|
||||
{
|
||||
$params = [':q' => $query];
|
||||
$sql = 'SELECT COUNT(*) FROM posts WHERE id IN (SELECT rowid FROM posts_fts WHERE posts_fts MATCH :q)';
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
if ($authorId !== null) {
|
||||
$sql .= ' AND author_id = :author_id';
|
||||
$params[':author_id'] = $authorId;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public function slugExists(string $slug, ?int $excludeId = null): bool
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT id FROM posts WHERE slug = :slug LIMIT 1');
|
||||
$stmt->execute([':slug' => $slug]);
|
||||
|
||||
$existingId = $stmt->fetchColumn();
|
||||
|
||||
if ($existingId === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($excludeId !== null && (int) $existingId === $excludeId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function countByEmbeddedMediaUrl(string $url): int
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE content LIKE :pattern');
|
||||
$stmt->execute([':pattern' => '%' . $url . '%']);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
/** @return Post[] */
|
||||
public function findByEmbeddedMediaUrl(string $url, int $limit = 5): array
|
||||
{
|
||||
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.content LIKE :pattern ORDER BY posts.id DESC LIMIT :limit');
|
||||
$stmt->bindValue(':pattern', '%' . $url . '%', PDO::PARAM_STR);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $params */
|
||||
private function buildSearchSql(string $query, array &$params, ?int $categoryId = null, ?int $authorId = null): string
|
||||
{
|
||||
$sql = self::SELECT . ' WHERE posts.id IN (SELECT rowid FROM posts_fts WHERE posts_fts MATCH :q)';
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
if ($authorId !== null) {
|
||||
$sql .= ' AND posts.author_id = :author_id';
|
||||
$params[':author_id'] = $authorId;
|
||||
}
|
||||
|
||||
return $sql;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $params */
|
||||
private function bindParams(PDOStatement $stmt, array $params): void
|
||||
{
|
||||
foreach ($params as $name => $value) {
|
||||
$stmt->bindValue($name, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $rows
|
||||
* @return Post[] */
|
||||
private function hydratePosts(array $rows): array
|
||||
{
|
||||
return array_map(static fn (array $row): Post => Post::fromArray($row), $rows);
|
||||
}
|
||||
}
|
||||
98
src/Post/Infrastructure/TwigPostExtension.php
Normal file
98
src/Post/Infrastructure/TwigPostExtension.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Post\Infrastructure;
|
||||
|
||||
use App\Post\Post;
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
class TwigPostExtension extends AbstractExtension
|
||||
{
|
||||
/** @return TwigFunction[] */
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction('post_excerpt', fn (Post $post, int $length = 400) => self::excerpt($post, $length), ['is_safe' => ['html']]),
|
||||
new TwigFunction('post_url', fn (Post $post) => '/article/' . $post->getStoredSlug()),
|
||||
new TwigFunction('post_thumbnail', fn (Post $post) => self::thumbnail($post)),
|
||||
new TwigFunction('post_initials', fn (Post $post) => self::initials($post)),
|
||||
];
|
||||
}
|
||||
|
||||
private static function excerpt(Post $post, int $length): string
|
||||
{
|
||||
$allowed = '<ul><ol><li><strong><em><b><i>';
|
||||
$html = strip_tags($post->getContent(), $allowed);
|
||||
|
||||
if (mb_strlen(strip_tags($html)) <= $length) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
$truncated = '';
|
||||
$count = 0;
|
||||
$inTag = false;
|
||||
|
||||
for ($i = 0, $len = mb_strlen($html); $i < $len && $count < $length; $i++) {
|
||||
$char = mb_substr($html, $i, 1);
|
||||
if ($char === '<') {
|
||||
$inTag = true;
|
||||
}
|
||||
$truncated .= $char;
|
||||
if ($inTag) {
|
||||
if ($char === '>') {
|
||||
$inTag = false;
|
||||
}
|
||||
} else {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['li', 'ul', 'ol', 'em', 'strong', 'b', 'i'] as $tag) {
|
||||
$opens = substr_count($truncated, "<{$tag}>") + substr_count($truncated, "<{$tag} ");
|
||||
$closes = substr_count($truncated, "</{$tag}>");
|
||||
for ($j = $closes; $j < $opens; $j++) {
|
||||
$truncated .= "</{$tag}>";
|
||||
}
|
||||
}
|
||||
|
||||
return $truncated . '…';
|
||||
}
|
||||
|
||||
private static function thumbnail(Post $post): ?string
|
||||
{
|
||||
if (preg_match('/<img[^>]+src=["\']([^"\']+)["\']/', $post->getContent(), $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function initials(Post $post): string
|
||||
{
|
||||
$stopWords = ['a', 'au', 'aux', 'd', 'de', 'des', 'du', 'en', 'et', 'l', 'la', 'le', 'les', 'of', 'the', 'un', 'une'];
|
||||
$words = array_filter(
|
||||
preg_split('/\s+/', trim($post->getTitle())) ?: [],
|
||||
static function (string $w) use ($stopWords): bool {
|
||||
$normalized = mb_strtolower(trim($w, " \t\n\r\0\x0B'\"’`.-_"));
|
||||
|
||||
return $normalized !== '' && mb_strlen($normalized) > 1 && !in_array($normalized, $stopWords, true);
|
||||
}
|
||||
);
|
||||
|
||||
if (empty($words)) {
|
||||
$first = mb_substr(trim($post->getTitle()), 0, 1);
|
||||
|
||||
return $first !== '' ? mb_strtoupper($first) : '?';
|
||||
}
|
||||
|
||||
$words = array_values($words);
|
||||
$initials = mb_strtoupper(mb_substr($words[0], 0, 1));
|
||||
|
||||
if (isset($words[1])) {
|
||||
$initials .= mb_strtoupper(mb_substr($words[1], 0, 1));
|
||||
}
|
||||
|
||||
return $initials;
|
||||
}
|
||||
}
|
||||
@@ -3,253 +3,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Post;
|
||||
|
||||
use App\Category\CategoryServiceInterface;
|
||||
use App\Shared\Exception\NotFoundException;
|
||||
use App\Shared\Http\FlashServiceInterface;
|
||||
use App\Shared\Http\SessionManagerInterface;
|
||||
use App\Shared\Pagination\PaginationPresenter;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Slim\Exception\HttpNotFoundException;
|
||||
use Slim\Views\Twig;
|
||||
|
||||
final class PostController
|
||||
/**
|
||||
* Pont de compatibilité : le contrôleur HTTP principal vit désormais dans
|
||||
* App\Post\Http\PostController.
|
||||
*/
|
||||
final class PostController extends Http\PostController
|
||||
{
|
||||
private const PUBLIC_PER_PAGE = 6;
|
||||
private const ADMIN_PER_PAGE = 12;
|
||||
|
||||
public function __construct(
|
||||
private readonly Twig $view,
|
||||
private readonly PostServiceInterface $postService,
|
||||
private readonly CategoryServiceInterface $categoryService,
|
||||
private readonly FlashServiceInterface $flash,
|
||||
private readonly SessionManagerInterface $sessionManager,
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $req, Response $res): Response
|
||||
{
|
||||
$params = $req->getQueryParams();
|
||||
$page = PaginationPresenter::resolvePage($params);
|
||||
$searchQuery = trim((string) ($params['q'] ?? ''));
|
||||
$categorySlug = (string) ($params['categorie'] ?? '');
|
||||
$activeCategory = null;
|
||||
$categoryId = null;
|
||||
|
||||
if ($categorySlug !== '') {
|
||||
$activeCategory = $this->categoryService->findBySlug($categorySlug);
|
||||
$categoryId = $activeCategory?->getId();
|
||||
}
|
||||
|
||||
$paginated = $searchQuery !== ''
|
||||
? $this->postService->searchPostsPaginated($searchQuery, $page, self::PUBLIC_PER_PAGE, $categoryId)
|
||||
: $this->postService->getAllPostsPaginated($page, self::PUBLIC_PER_PAGE, $categoryId);
|
||||
|
||||
return $this->view->render($res, 'pages/home.twig', [
|
||||
'posts' => $paginated->getItems(),
|
||||
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
|
||||
'totalPosts' => $paginated->getTotal(),
|
||||
'categories' => $this->categoryService->findAll(),
|
||||
'activeCategory' => $activeCategory,
|
||||
'searchQuery' => $searchQuery,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $args
|
||||
*/
|
||||
public function show(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
try {
|
||||
$post = $this->postService->getPostBySlug((string) ($args['slug'] ?? ''));
|
||||
} catch (NotFoundException) {
|
||||
throw new HttpNotFoundException($req);
|
||||
}
|
||||
|
||||
return $this->view->render($res, 'pages/post/detail.twig', ['post' => $post]);
|
||||
}
|
||||
|
||||
public function admin(Request $req, Response $res): Response
|
||||
{
|
||||
$isAdmin = $this->sessionManager->isAdmin() || $this->sessionManager->isEditor();
|
||||
$userId = $this->sessionManager->getUserId();
|
||||
$params = $req->getQueryParams();
|
||||
$page = PaginationPresenter::resolvePage($params);
|
||||
$searchQuery = trim((string) ($params['q'] ?? ''));
|
||||
$categorySlug = (string) ($params['categorie'] ?? '');
|
||||
$activeCategory = null;
|
||||
$categoryId = null;
|
||||
|
||||
if ($categorySlug !== '') {
|
||||
$activeCategory = $this->categoryService->findBySlug($categorySlug);
|
||||
$categoryId = $activeCategory?->getId();
|
||||
}
|
||||
|
||||
if ($searchQuery !== '') {
|
||||
$authorId = $isAdmin ? null : (int) $userId;
|
||||
$paginated = $this->postService->searchPostsPaginated(
|
||||
$searchQuery,
|
||||
$page,
|
||||
self::ADMIN_PER_PAGE,
|
||||
$categoryId,
|
||||
$authorId,
|
||||
);
|
||||
} else {
|
||||
$paginated = $isAdmin
|
||||
? $this->postService->getAllPostsPaginated($page, self::ADMIN_PER_PAGE, $categoryId)
|
||||
: $this->postService->getPostsByUserIdPaginated((int) $userId, $page, self::ADMIN_PER_PAGE, $categoryId);
|
||||
}
|
||||
|
||||
return $this->view->render($res, 'admin/posts/index.twig', [
|
||||
'posts' => $paginated->getItems(),
|
||||
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
|
||||
'totalPosts' => $paginated->getTotal(),
|
||||
'categories' => $this->categoryService->findAll(),
|
||||
'activeCategory' => $activeCategory,
|
||||
'searchQuery' => $searchQuery,
|
||||
'error' => $this->flash->get('post_error'),
|
||||
'success' => $this->flash->get('post_success'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $args
|
||||
*/
|
||||
public function form(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
$id = (int) ($args['id'] ?? 0);
|
||||
$post = null;
|
||||
|
||||
if ($id > 0) {
|
||||
try {
|
||||
$post = $this->postService->getPostById($id);
|
||||
} catch (NotFoundException) {
|
||||
throw new HttpNotFoundException($req);
|
||||
}
|
||||
|
||||
if (!$this->canEditPost($post)) {
|
||||
$this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur");
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->view->render($res, 'admin/posts/form.twig', [
|
||||
'post' => $post,
|
||||
'categories' => $this->categoryService->findAll(),
|
||||
'action' => $id > 0 ? "/admin/posts/edit/{$id}" : '/admin/posts/create',
|
||||
'error' => $this->flash->get('post_error'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(Request $req, Response $res): Response
|
||||
{
|
||||
['title' => $title, 'content' => $content, 'category_id' => $categoryId] = $this->extractPostData($req);
|
||||
|
||||
try {
|
||||
$this->postService->createPost($title, $content, $this->sessionManager->getUserId() ?? 0, $categoryId);
|
||||
$this->flash->set('post_success', 'L\'article a été créé avec succès');
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->flash->set('post_error', $e->getMessage());
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts/edit/0')->withStatus(302);
|
||||
} catch (\Throwable) {
|
||||
$this->flash->set('post_error', 'Une erreur inattendue s\'est produite');
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts/edit/0')->withStatus(302);
|
||||
}
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $args
|
||||
*/
|
||||
public function update(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
$id = (int) $args['id'];
|
||||
['title' => $title, 'content' => $content, 'slug' => $slug, 'category_id' => $categoryId] = $this->extractPostData($req);
|
||||
|
||||
try {
|
||||
$post = $this->postService->getPostById($id);
|
||||
} catch (NotFoundException) {
|
||||
throw new HttpNotFoundException($req);
|
||||
}
|
||||
|
||||
if (!$this->canEditPost($post)) {
|
||||
$this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur");
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->postService->updatePost($id, $title, $content, $slug, $categoryId);
|
||||
$this->flash->set('post_success', 'L\'article a été modifié avec succès');
|
||||
} catch (NotFoundException) {
|
||||
throw new HttpNotFoundException($req);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->flash->set('post_error', $e->getMessage());
|
||||
|
||||
return $res->withHeader('Location', "/admin/posts/edit/{$id}")->withStatus(302);
|
||||
} catch (\Throwable) {
|
||||
$this->flash->set('post_error', 'Une erreur inattendue s\'est produite');
|
||||
|
||||
return $res->withHeader('Location', "/admin/posts/edit/{$id}")->withStatus(302);
|
||||
}
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $args
|
||||
*/
|
||||
public function delete(Request $req, Response $res, array $args): Response
|
||||
{
|
||||
try {
|
||||
$post = $this->postService->getPostById((int) $args['id']);
|
||||
} catch (NotFoundException) {
|
||||
throw new HttpNotFoundException($req);
|
||||
}
|
||||
|
||||
if (!$this->canEditPost($post)) {
|
||||
$this->flash->set('post_error', "Vous ne pouvez pas supprimer un article dont vous n'êtes pas l'auteur");
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->postService->deletePost($post->getId());
|
||||
} catch (NotFoundException) {
|
||||
throw new HttpNotFoundException($req);
|
||||
}
|
||||
|
||||
$this->flash->set('post_success', "L'article « {$post->getTitle()} » a été supprimé avec succès");
|
||||
|
||||
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
|
||||
}
|
||||
|
||||
private function canEditPost(Post $post): bool
|
||||
{
|
||||
if ($this->sessionManager->isAdmin() || $this->sessionManager->isEditor()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $post->getAuthorId() === $this->sessionManager->getUserId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{title: string, content: string, slug: string, category_id: int|null}
|
||||
*/
|
||||
private function extractPostData(Request $req): array
|
||||
{
|
||||
$data = (array) $req->getParsedBody();
|
||||
$categoryId = ($data['category_id'] ?? '') !== '' ? (int) $data['category_id'] : null;
|
||||
|
||||
return [
|
||||
'title' => trim((string) ($data['title'] ?? '')),
|
||||
'content' => trim((string) ($data['content'] ?? '')),
|
||||
'slug' => trim((string) ($data['slug'] ?? '')),
|
||||
'category_id' => $categoryId,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,203 +3,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Post;
|
||||
|
||||
use Twig\Extension\AbstractExtension;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
/**
|
||||
* Extension Twig pour la présentation des articles.
|
||||
*
|
||||
* Expose des fonctions utilitaires dans les templates Twig
|
||||
* afin d'éviter d'appeler de la logique de présentation directement
|
||||
* sur le modèle Post depuis les vues.
|
||||
*
|
||||
* Fonctions disponibles dans les templates :
|
||||
*
|
||||
* @example {{ post_excerpt(post) }} — extrait de 400 caractères par défaut
|
||||
* @example {{ post_excerpt(post, 600) }} — extrait personnalisé de 600 caractères
|
||||
* @example {{ post_url(post) }} — URL publique de l'article (/article/{slug})
|
||||
* @example {{ post_thumbnail(post) }} — URL de la première image, ou null si aucune image
|
||||
* @example {{ post_initials(post) }} — initiales du titre (ex: "AB" pour "Article de Blog")
|
||||
* Pont de compatibilité : l'extension Twig principale vit désormais dans
|
||||
* App\Post\Infrastructure\TwigPostExtension.
|
||||
*/
|
||||
final class PostExtension extends AbstractExtension
|
||||
final class PostExtension extends Infrastructure\TwigPostExtension
|
||||
{
|
||||
/**
|
||||
* Déclare les fonctions Twig exposées aux templates.
|
||||
*
|
||||
* @return TwigFunction[] Les fonctions enregistrées dans l'environnement Twig
|
||||
*/
|
||||
public function getFunctions(): array
|
||||
{
|
||||
return [
|
||||
new TwigFunction(
|
||||
'post_excerpt',
|
||||
fn (Post $post, int $length = 400) => self::excerpt($post, $length),
|
||||
['is_safe' => ['html']]
|
||||
),
|
||||
new TwigFunction(
|
||||
'post_url',
|
||||
fn (Post $post) => '/article/'.$post->getStoredSlug()
|
||||
),
|
||||
new TwigFunction(
|
||||
'post_thumbnail',
|
||||
fn (Post $post) => self::thumbnail($post)
|
||||
),
|
||||
new TwigFunction(
|
||||
'post_initials',
|
||||
fn (Post $post) => self::initials($post)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un extrait HTML formaté du contenu de l'article.
|
||||
*
|
||||
* Conserve uniquement les balises sûres et porteuses de sens visuel
|
||||
* (<ul>, <ol>, <li>, <strong>, <em>, <b>, <i>) afin que le formatage
|
||||
* soit perceptible dans l'aperçu (listes à puces, gras, italique…).
|
||||
* Toutes les autres balises sont supprimées par strip_tags().
|
||||
*
|
||||
* La hauteur de l'aperçu est contrainte côté CSS (max-height sur .card__body +
|
||||
* dégradé de fondu sur .card__excerpt) — c'est CSS qui tronque visuellement,
|
||||
* pas cette méthode. Le paramètre $length sert uniquement de garde-fou serveur :
|
||||
* il évite d'envoyer l'intégralité d'un long article au navigateur. La valeur
|
||||
* par défaut de 400 caractères est volontairement généreuse pour ne jamais
|
||||
* couper un contenu que CSS aurait affiché en entier.
|
||||
*
|
||||
* La troncature opère sur le HTML filtré (pas sur le texte brut) afin de
|
||||
* conserver le formatage de façon cohérente, quelle que soit la longueur
|
||||
* du contenu. Le comptage de caractères ignore les balises.
|
||||
*
|
||||
* Le HTML retourné provient de HTMLPurifier (appliqué à l'écriture) —
|
||||
* strip_tags() avec liste blanche élimine tout balisage résiduel non désiré.
|
||||
* La fonction est déclarée is_safe => ['html'] : Twig ne l'échappe pas
|
||||
* automatiquement, le |raw est inutile dans les templates.
|
||||
*
|
||||
* @param Post $post L'article dont générer l'extrait
|
||||
* @param int $length Longueur maximale en caractères visibles (défaut : 400)
|
||||
*
|
||||
* @return string L'extrait en HTML partiel, tronqué si nécessaire
|
||||
*/
|
||||
private static function excerpt(Post $post, int $length): string
|
||||
{
|
||||
// Balises conservées : structurantes pour les listes, sémantiques pour le gras/italique.
|
||||
// Toutes les autres (p, div, h1-h6, img, a, table…) sont supprimées pour
|
||||
// garder un aperçu compact.
|
||||
$allowed = '<ul><ol><li><strong><em><b><i>';
|
||||
$html = strip_tags($post->getContent(), $allowed);
|
||||
|
||||
// Mesurer sur le texte brut : les balises ne comptent pas dans la limite visible
|
||||
if (mb_strlen(strip_tags($html)) <= $length) {
|
||||
return $html;
|
||||
}
|
||||
|
||||
// Tronquer en avançant caractère par caractère dans le HTML, en ignorant
|
||||
// les balises dans le comptage — le formatage est ainsi conservé dans la
|
||||
// portion visible, de façon cohérente avec les articles courts.
|
||||
$truncated = '';
|
||||
$count = 0;
|
||||
$inTag = false;
|
||||
|
||||
for ($i = 0, $len = mb_strlen($html); $i < $len && $count < $length; $i++) {
|
||||
$char = mb_substr($html, $i, 1);
|
||||
|
||||
if ($char === '<') {
|
||||
$inTag = true;
|
||||
}
|
||||
|
||||
$truncated .= $char;
|
||||
|
||||
if ($inTag) {
|
||||
if ($char === '>') {
|
||||
$inTag = false;
|
||||
}
|
||||
} else {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
// Fermer proprement les balises laissées ouvertes par la troncature
|
||||
foreach (['li', 'ul', 'ol', 'em', 'strong', 'b', 'i'] as $tag) {
|
||||
$opens = substr_count($truncated, "<{$tag}>") + substr_count($truncated, "<{$tag} ");
|
||||
$closes = substr_count($truncated, "</{$tag}>");
|
||||
for ($j = $closes; $j < $opens; $j++) {
|
||||
$truncated .= "</{$tag}>";
|
||||
}
|
||||
}
|
||||
|
||||
return $truncated . '…';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait l'URL de la première image présente dans le contenu de l'article.
|
||||
*
|
||||
* Utilise une regex sur l'attribut src de la première balise <img> trouvée.
|
||||
* Le contenu étant sanitisé par HTMLPurifier, seuls les schémas http/https
|
||||
* sont présents — aucun risque XSS via cet attribut.
|
||||
* L'échappement de l'URL est délégué à Twig (auto-escape activé).
|
||||
*
|
||||
* @param Post $post L'article dont extraire la vignette
|
||||
*
|
||||
* @return string|null L'URL de la première image, ou null si aucune image
|
||||
*/
|
||||
private static function thumbnail(Post $post): ?string
|
||||
{
|
||||
if (preg_match('/<img[^>]+src=["\']([^"\']+)["\']/', $post->getContent(), $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère les initiales du titre de l'article (1 à 2 caractères).
|
||||
*
|
||||
* Extrait la première lettre de chaque mot, conserve les deux premières,
|
||||
* et retourne le résultat en majuscules. Les mots vides (articles, prépositions
|
||||
* d'une lettre) sont ignorés pour favoriser les mots porteurs de sens.
|
||||
*
|
||||
* Exemples :
|
||||
* "Article de Blog" → "AB"
|
||||
* "Été en forêt" → "EF"
|
||||
* "PHP" → "P"
|
||||
* "" → "?"
|
||||
*
|
||||
* L'échappement HTML est délégué à Twig (auto-escape activé).
|
||||
*
|
||||
* @param Post $post L'article dont générer les initiales
|
||||
*
|
||||
* @return string Les initiales en majuscules (1–2 caractères), ou "?" si le titre est vide
|
||||
*/
|
||||
private static function initials(Post $post): string
|
||||
{
|
||||
// Filtrer les mots vides fréquents (articles, prépositions, coordinations)
|
||||
// pour favoriser les mots porteurs de sens : "Article de Blog" → ["Article", "Blog"] → "AB"
|
||||
$stopWords = ['a', 'au', 'aux', 'd', 'de', 'des', 'du', 'en', 'et', 'l', 'la', 'le', 'les', 'of', 'the', 'un', 'une'];
|
||||
$words = array_filter(
|
||||
preg_split('/\s+/', trim($post->getTitle())) ?: [],
|
||||
static function (string $w) use ($stopWords): bool {
|
||||
$normalized = mb_strtolower(trim($w, " \t\n\r\0\x0B'\"’`.-_"));
|
||||
|
||||
return $normalized !== ''
|
||||
&& mb_strlen($normalized) > 1
|
||||
&& !in_array($normalized, $stopWords, true);
|
||||
}
|
||||
);
|
||||
|
||||
if (empty($words)) {
|
||||
// Repli sur le premier caractère du titre brut si tous les mots font 1 lettre
|
||||
$first = mb_substr(trim($post->getTitle()), 0, 1);
|
||||
|
||||
return $first !== '' ? mb_strtoupper($first) : '?';
|
||||
}
|
||||
|
||||
$words = array_values($words);
|
||||
$initials = mb_strtoupper(mb_substr($words[0], 0, 1));
|
||||
|
||||
if (isset($words[1])) {
|
||||
$initials .= mb_strtoupper(mb_substr($words[1], 0, 1));
|
||||
}
|
||||
|
||||
return $initials;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,378 +3,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Post;
|
||||
|
||||
use PDO;
|
||||
use App\Post\Infrastructure\PdoPostRepository;
|
||||
|
||||
final class PostRepository implements PostRepositoryInterface
|
||||
/**
|
||||
* Pont de compatibilité : l'implémentation PDO principale vit désormais dans
|
||||
* App\Post\Infrastructure\PdoPostRepository.
|
||||
*/
|
||||
final class PostRepository extends PdoPostRepository implements PostRepositoryInterface
|
||||
{
|
||||
private const SELECT = '
|
||||
SELECT posts.id, posts.title, posts.content, posts.slug,
|
||||
posts.author_id, posts.category_id, posts.created_at, posts.updated_at,
|
||||
users.username AS author_username,
|
||||
categories.name AS category_name,
|
||||
categories.slug AS category_slug
|
||||
FROM posts
|
||||
LEFT JOIN users ON users.id = posts.author_id
|
||||
LEFT JOIN categories ON categories.id = posts.category_id
|
||||
';
|
||||
|
||||
public function __construct(private readonly PDO $db)
|
||||
{
|
||||
}
|
||||
|
||||
public function findAll(?int $categoryId = null): array
|
||||
{
|
||||
if ($categoryId === null) {
|
||||
$stmt = $this->db->query(self::SELECT . ' ORDER BY posts.id DESC');
|
||||
if ($stmt === false) {
|
||||
throw new \RuntimeException('La requête SELECT sur posts a échoué.');
|
||||
}
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.category_id = :category_id ORDER BY posts.id DESC');
|
||||
$stmt->execute([':category_id' => $categoryId]);
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function findPage(int $limit, int $offset, ?int $categoryId = null): array
|
||||
{
|
||||
$sql = self::SELECT;
|
||||
$params = [];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' WHERE posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countAll(?int $categoryId = null): int
|
||||
{
|
||||
if ($categoryId === null) {
|
||||
$stmt = $this->db->query('SELECT COUNT(*) FROM posts');
|
||||
if ($stmt === false) {
|
||||
throw new \RuntimeException('La requête COUNT sur posts a échoué.');
|
||||
}
|
||||
|
||||
return (int) ($stmt->fetchColumn() ?: 0);
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE category_id = :category_id');
|
||||
$stmt->execute([':category_id' => $categoryId]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public function findRecent(int $limit): array
|
||||
{
|
||||
$stmt = $this->db->prepare(self::SELECT . ' ORDER BY posts.id DESC LIMIT :limit');
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function findByUserId(int $userId, ?int $categoryId = null): array
|
||||
{
|
||||
$sql = self::SELECT . ' WHERE posts.author_id = :author_id';
|
||||
$params = [':author_id' => $userId];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY posts.id DESC';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function findByUserPage(int $userId, int $limit, int $offset, ?int $categoryId = null): array
|
||||
{
|
||||
$sql = self::SELECT . ' WHERE posts.author_id = :author_id';
|
||||
$params = [':author_id' => $userId];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND posts.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY posts.id DESC LIMIT :limit OFFSET :offset';
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countByUserId(int $userId, ?int $categoryId = null): int
|
||||
{
|
||||
$sql = 'SELECT COUNT(*) FROM posts WHERE author_id = :author_id';
|
||||
$params = [':author_id' => $userId];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public function findBySlug(string $slug): ?Post
|
||||
{
|
||||
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.slug = :slug');
|
||||
$stmt->execute([':slug' => $slug]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row ? Post::fromArray($row) : null;
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Post
|
||||
{
|
||||
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
return $row ? Post::fromArray($row) : null;
|
||||
}
|
||||
|
||||
public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'INSERT INTO posts (title, content, slug, author_id, category_id, created_at, updated_at)
|
||||
VALUES (:title, :content, :slug, :author_id, :category_id, :created_at, :updated_at)'
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
':title' => $post->getTitle(),
|
||||
':content' => $post->getContent(),
|
||||
':slug' => $slug,
|
||||
':author_id' => $authorId,
|
||||
':category_id' => $categoryId,
|
||||
':created_at' => date('Y-m-d H:i:s'),
|
||||
':updated_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
|
||||
return (int) $this->db->lastInsertId();
|
||||
}
|
||||
|
||||
public function update(int $id, Post $post, string $slug, ?int $categoryId): int
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
'UPDATE posts
|
||||
SET title = :title, content = :content, slug = :slug,
|
||||
category_id = :category_id, updated_at = :updated_at
|
||||
WHERE id = :id'
|
||||
);
|
||||
|
||||
$stmt->execute([
|
||||
':title' => $post->getTitle(),
|
||||
':content' => $post->getContent(),
|
||||
':slug' => $slug,
|
||||
':category_id' => $categoryId,
|
||||
':updated_at' => date('Y-m-d H:i:s'),
|
||||
':id' => $id,
|
||||
]);
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
public function delete(int $id): int
|
||||
{
|
||||
$stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id');
|
||||
$stmt->execute([':id' => $id]);
|
||||
|
||||
return $stmt->rowCount();
|
||||
}
|
||||
|
||||
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array
|
||||
{
|
||||
$ftsQuery = $this->buildFtsQuery($query);
|
||||
|
||||
if ($ftsQuery === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
[$sql, $params] = $this->buildSearchSql($ftsQuery, $categoryId, $authorId);
|
||||
$sql .= ' ORDER BY rank';
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array
|
||||
{
|
||||
$ftsQuery = $this->buildFtsQuery($query);
|
||||
|
||||
if ($ftsQuery === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
[$sql, $params] = $this->buildSearchSql($ftsQuery, $categoryId, $authorId);
|
||||
$sql .= ' ORDER BY rank LIMIT :limit OFFSET :offset';
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
public function countSearch(string $query, ?int $categoryId = null, ?int $authorId = null): int
|
||||
{
|
||||
$ftsQuery = $this->buildFtsQuery($query);
|
||||
|
||||
if ($ftsQuery === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$sql = '
|
||||
SELECT COUNT(*)
|
||||
FROM posts_fts f
|
||||
JOIN posts p ON p.id = f.rowid
|
||||
WHERE posts_fts MATCH :query
|
||||
';
|
||||
$params = [':query' => $ftsQuery];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND p.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
if ($authorId !== null) {
|
||||
$sql .= ' AND p.author_id = :author_id';
|
||||
$params[':author_id'] = $authorId;
|
||||
}
|
||||
|
||||
$stmt = $this->db->prepare($sql);
|
||||
$this->bindParams($stmt, $params);
|
||||
$stmt->execute();
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public function slugExists(string $slug, ?int $excludeId = null): bool
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT id FROM posts WHERE slug = :slug');
|
||||
$stmt->execute([':slug' => $slug]);
|
||||
$existingId = $stmt->fetchColumn();
|
||||
|
||||
if ($existingId === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$existingId = (int) $existingId;
|
||||
|
||||
return $excludeId !== null ? $existingId !== $excludeId : true;
|
||||
}
|
||||
|
||||
public function countByEmbeddedMediaUrl(string $url): int
|
||||
{
|
||||
$stmt = $this->db->prepare('SELECT COUNT(*) FROM posts WHERE instr(content, :url) > 0');
|
||||
$stmt->execute([':url' => $url]);
|
||||
|
||||
return (int) $stmt->fetchColumn();
|
||||
}
|
||||
|
||||
public function findByEmbeddedMediaUrl(string $url, int $limit = 5): array
|
||||
{
|
||||
$stmt = $this->db->prepare(
|
||||
self::SELECT . ' WHERE instr(posts.content, :url) > 0 ORDER BY posts.updated_at DESC LIMIT :limit'
|
||||
);
|
||||
$stmt->bindValue(':url', $url, PDO::PARAM_STR);
|
||||
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:array<string,mixed>}
|
||||
*/
|
||||
private function buildSearchSql(string $ftsQuery, ?int $categoryId = null, ?int $authorId = null): array
|
||||
{
|
||||
$sql = '
|
||||
SELECT p.id, p.title, p.content, p.slug,
|
||||
p.author_id, p.category_id, p.created_at, p.updated_at,
|
||||
u.username AS author_username,
|
||||
c.name AS category_name,
|
||||
c.slug AS category_slug
|
||||
FROM posts_fts f
|
||||
JOIN posts p ON p.id = f.rowid
|
||||
LEFT JOIN users u ON u.id = p.author_id
|
||||
LEFT JOIN categories c ON c.id = p.category_id
|
||||
WHERE posts_fts MATCH :query
|
||||
';
|
||||
$params = [':query' => $ftsQuery];
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$sql .= ' AND p.category_id = :category_id';
|
||||
$params[':category_id'] = $categoryId;
|
||||
}
|
||||
|
||||
if ($authorId !== null) {
|
||||
$sql .= ' AND p.author_id = :author_id';
|
||||
$params[':author_id'] = $authorId;
|
||||
}
|
||||
|
||||
return [$sql, $params];
|
||||
}
|
||||
|
||||
private function buildFtsQuery(string $input): string
|
||||
{
|
||||
$words = preg_split('/\s+/', trim($input), -1, PREG_SPLIT_NO_EMPTY) ?: [];
|
||||
|
||||
if (empty($words)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$terms = array_map(
|
||||
fn ($w) => '"' . str_replace('"', '""', $w) . '"*',
|
||||
$words
|
||||
);
|
||||
|
||||
return implode(' ', $terms);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
private function bindParams(\PDOStatement $stmt, array $params): void
|
||||
{
|
||||
foreach ($params as $key => $value) {
|
||||
$stmt->bindValue($key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return Post[]
|
||||
*/
|
||||
private function hydratePosts(array $rows): array
|
||||
{
|
||||
return array_map(fn ($row) => Post::fromArray($row), $rows);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,180 +3,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Post;
|
||||
|
||||
use App\Shared\Exception\NotFoundException;
|
||||
use App\Shared\Html\HtmlSanitizerInterface;
|
||||
use App\Shared\Pagination\PaginatedResult;
|
||||
use App\Shared\Util\SlugHelper;
|
||||
use App\Post\Application\PostApplicationService;
|
||||
|
||||
final class PostService implements PostServiceInterface
|
||||
/**
|
||||
* Pont de compatibilité : l'implémentation métier principale vit désormais dans
|
||||
* App\Post\Application\PostApplicationService.
|
||||
*/
|
||||
final class PostService extends PostApplicationService implements PostServiceInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PostRepositoryInterface $postRepository,
|
||||
private readonly HtmlSanitizerInterface $htmlSanitizer,
|
||||
) {
|
||||
}
|
||||
|
||||
public function getAllPosts(?int $categoryId = null): array
|
||||
{
|
||||
return $this->postRepository->findAll($categoryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PaginatedResult<Post>
|
||||
*/
|
||||
public function getAllPostsPaginated(int $page, int $perPage, ?int $categoryId = null): PaginatedResult
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$total = $this->postRepository->countAll($categoryId);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return new PaginatedResult(
|
||||
$this->postRepository->findPage($perPage, $offset, $categoryId),
|
||||
$total,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
}
|
||||
|
||||
public function getRecentPosts(int $limit = 20): array
|
||||
{
|
||||
return $this->postRepository->findRecent($limit);
|
||||
}
|
||||
|
||||
public function getPostsByUserId(int $userId, ?int $categoryId = null): array
|
||||
{
|
||||
return $this->postRepository->findByUserId($userId, $categoryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PaginatedResult<Post>
|
||||
*/
|
||||
public function getPostsByUserIdPaginated(int $userId, int $page, int $perPage, ?int $categoryId = null): PaginatedResult
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$total = $this->postRepository->countByUserId($userId, $categoryId);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return new PaginatedResult(
|
||||
$this->postRepository->findByUserPage($userId, $perPage, $offset, $categoryId),
|
||||
$total,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
}
|
||||
|
||||
public function getPostBySlug(string $slug): Post
|
||||
{
|
||||
$post = $this->postRepository->findBySlug($slug);
|
||||
|
||||
if ($post === null) {
|
||||
throw new NotFoundException('Article', $slug);
|
||||
}
|
||||
|
||||
return $post;
|
||||
}
|
||||
|
||||
public function getPostById(int $id): Post
|
||||
{
|
||||
$post = $this->postRepository->findById($id);
|
||||
|
||||
if ($post === null) {
|
||||
throw new NotFoundException('Article', $id);
|
||||
}
|
||||
|
||||
return $post;
|
||||
}
|
||||
|
||||
public function createPost(string $title, string $content, int $authorId, ?int $categoryId = null): int
|
||||
{
|
||||
$sanitizedContent = $this->htmlSanitizer->sanitize($content);
|
||||
$post = new Post(0, $title, $sanitizedContent);
|
||||
$slug = $this->generateUniqueSlug($post->generateSlug());
|
||||
|
||||
return $this->postRepository->create($post, $slug, $authorId, $categoryId);
|
||||
}
|
||||
|
||||
public function updatePost(int $id, string $title, string $content, string $newSlugInput = '', ?int $categoryId = null): void
|
||||
{
|
||||
$current = $this->postRepository->findById($id);
|
||||
|
||||
if ($current === null) {
|
||||
throw new NotFoundException('Article', $id);
|
||||
}
|
||||
|
||||
$sanitizedContent = $this->htmlSanitizer->sanitize($content);
|
||||
$post = new Post($id, $title, $sanitizedContent);
|
||||
$slugToUse = $current->getStoredSlug();
|
||||
$newSlugInput = trim($newSlugInput);
|
||||
$cleanSlugInput = $this->normalizeSlugInput($newSlugInput);
|
||||
|
||||
if ($cleanSlugInput !== '' && $cleanSlugInput !== $current->getStoredSlug()) {
|
||||
$slugToUse = $this->generateUniqueSlug($cleanSlugInput, $id);
|
||||
}
|
||||
|
||||
$affected = $this->postRepository->update($id, $post, $slugToUse, $categoryId);
|
||||
|
||||
if ($affected === 0) {
|
||||
throw new NotFoundException('Article', $id);
|
||||
}
|
||||
}
|
||||
|
||||
public function searchPosts(string $query, ?int $categoryId = null, ?int $authorId = null): array
|
||||
{
|
||||
return $this->postRepository->search($query, $categoryId, $authorId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PaginatedResult<Post>
|
||||
*/
|
||||
public function searchPostsPaginated(string $query, int $page, int $perPage, ?int $categoryId = null, ?int $authorId = null): PaginatedResult
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$total = $this->postRepository->countSearch($query, $categoryId, $authorId);
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
return new PaginatedResult(
|
||||
$this->postRepository->searchPage($query, $perPage, $offset, $categoryId, $authorId),
|
||||
$total,
|
||||
$page,
|
||||
$perPage,
|
||||
);
|
||||
}
|
||||
|
||||
public function countMediaUsages(string $url): int
|
||||
{
|
||||
return $this->postRepository->countByEmbeddedMediaUrl($url);
|
||||
}
|
||||
|
||||
public function findMediaUsages(string $url, int $limit = 5): array
|
||||
{
|
||||
return $this->postRepository->findByEmbeddedMediaUrl($url, $limit);
|
||||
}
|
||||
|
||||
public function deletePost(int $id): void
|
||||
{
|
||||
$affected = $this->postRepository->delete($id);
|
||||
|
||||
if ($affected === 0) {
|
||||
throw new NotFoundException('Article', $id);
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeSlugInput(string $input): string
|
||||
{
|
||||
return SlugHelper::generate($input);
|
||||
}
|
||||
|
||||
private function generateUniqueSlug(string $baseSlug, ?int $excludeId = null): string
|
||||
{
|
||||
$slug = $baseSlug;
|
||||
$counter = 1;
|
||||
|
||||
while ($this->postRepository->slugExists($slug, $excludeId)) {
|
||||
$slug = $baseSlug . '-' . $counter;
|
||||
++$counter;
|
||||
}
|
||||
|
||||
return $slug;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,92 +3,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Post;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
|
||||
/**
|
||||
* Contrôleur du flux RSS.
|
||||
*
|
||||
* Expose un flux RSS 2.0 des 20 articles les plus récents à l'URL /rss.xml.
|
||||
* Le contenu HTML des articles est strippé pour le champ <description> afin
|
||||
* de produire un résumé texte brut compatible avec tous les lecteurs RSS.
|
||||
*
|
||||
* Pas de vue Twig — le XML est généré directement via SimpleXMLElement
|
||||
* pour rester indépendant du moteur de templates.
|
||||
* Pont de compatibilité : le contrôleur RSS principal vit désormais dans
|
||||
* App\Post\Http\RssController.
|
||||
*/
|
||||
final class RssController
|
||||
final class RssController extends Http\RssController
|
||||
{
|
||||
/**
|
||||
* Nombre maximum d'articles inclus dans le flux RSS.
|
||||
*/
|
||||
private const FEED_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* @param PostServiceInterface $postService Service de récupération des articles
|
||||
* @param string $appUrl URL de base de l'application (depuis APP_URL dans .env)
|
||||
* @param string $appName Nom du blog affiché dans le flux
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly PostServiceInterface $postService,
|
||||
private readonly string $appUrl,
|
||||
private readonly string $appName,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère et retourne le flux RSS 2.0.
|
||||
*
|
||||
* @param Request $req La requête HTTP
|
||||
* @param Response $res La réponse HTTP
|
||||
*
|
||||
* @return Response Le flux RSS en XML (application/rss+xml; charset=utf-8)
|
||||
*/
|
||||
public function feed(Request $req, Response $res): Response
|
||||
{
|
||||
$posts = $this->postService->getRecentPosts(self::FEED_LIMIT);
|
||||
$baseUrl = $this->appUrl;
|
||||
|
||||
$xml = new \SimpleXMLElement(
|
||||
'<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"></rss>'
|
||||
);
|
||||
|
||||
$channel = $xml->addChild('channel');
|
||||
$channel->addChild('title', htmlspecialchars($this->appName));
|
||||
$channel->addChild('link', $baseUrl . '/');
|
||||
$channel->addChild('description', htmlspecialchars($this->appName . ' — flux RSS'));
|
||||
$channel->addChild('language', 'fr-FR');
|
||||
$channel->addChild('lastBuildDate', (new \DateTime())->format(\DateTime::RSS));
|
||||
|
||||
foreach ($posts as $post) {
|
||||
$item = $channel->addChild('item');
|
||||
$item->addChild('title', htmlspecialchars($post->getTitle()));
|
||||
|
||||
$postUrl = $baseUrl . '/article/' . $post->getStoredSlug();
|
||||
$item->addChild('link', $postUrl);
|
||||
$item->addChild('guid', $postUrl);
|
||||
|
||||
// Extrait texte brut : strip_tags + truncature à 300 caractères
|
||||
$excerpt = strip_tags($post->getContent());
|
||||
$excerpt = mb_strlen($excerpt) > 300
|
||||
? mb_substr($excerpt, 0, 300) . '…'
|
||||
: $excerpt;
|
||||
$item->addChild('description', htmlspecialchars($excerpt));
|
||||
|
||||
$item->addChild('pubDate', $post->getCreatedAt()->format(\DateTime::RSS));
|
||||
|
||||
if ($post->getAuthorUsername() !== null) {
|
||||
$item->addChild('author', htmlspecialchars($post->getAuthorUsername()));
|
||||
}
|
||||
|
||||
if ($post->getCategoryName() !== null) {
|
||||
$item->addChild('category', htmlspecialchars($post->getCategoryName()));
|
||||
}
|
||||
}
|
||||
|
||||
$body = $xml->asXML();
|
||||
|
||||
$res->getBody()->write($body !== false ? $body : '');
|
||||
|
||||
return $res->withHeader('Content-Type', 'application/rss+xml; charset=utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user