Working state

This commit is contained in:
julien
2026-03-16 13:40:18 +01:00
parent dec76fa2c7
commit 557360dfde
57 changed files with 1044 additions and 1668 deletions

View File

@@ -7,32 +7,17 @@ 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;
/**
* Contrôleur pour les articles.
*
* Gère les actions HTTP liées aux articles : affichage public et administration
* (liste, formulaire, création, modification, suppression).
* Délègue toute la logique métier à PostService et utilise FlashService
* pour transmettre les messages d'erreur entre redirections.
* L'identifiant de l'auteur est lu depuis SessionManager lors de la création.
* Les droits de modification et suppression sont vérifiés via canEditPost().
* CategoryService est injecté pour résoudre les slugs de catégorie
* en identifiants et fournir la liste des catégories aux vues.
*/
final class PostController
{
/**
* @param Twig $view Moteur de templates Twig
* @param PostServiceInterface $postService Service métier des articles
* @param CategoryServiceInterface $categoryService Service de gestion des catégories
* @param FlashServiceInterface $flash Service de messages flash
* @param SessionManagerInterface $sessionManager Gestionnaire de session
*/
private const PUBLIC_PER_PAGE = 6;
private const ADMIN_PER_PAGE = 12;
public function __construct(
private readonly Twig $view,
private readonly PostServiceInterface $postService,
@@ -42,24 +27,10 @@ final class PostController
) {
}
/**
* Affiche la page d'accueil avec la liste des articles.
*
* Accepte deux paramètres de requête cumulables :
* - `q` (string) : recherche plein texte FTS5 sur titre, contenu et auteur
* - `categorie` (string) : filtre par slug de catégorie
*
* Si `q` est fourni, les résultats sont triés par pertinence BM25.
* Sans `q`, les articles sont triés du plus récent au plus ancien.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response La vue de la page d'accueil
*/
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;
@@ -70,12 +41,14 @@ final class PostController
$categoryId = $activeCategory?->getId();
}
$posts = $searchQuery !== ''
? $this->postService->searchPosts($searchQuery, $categoryId)
: $this->postService->getAllPosts($categoryId);
$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' => $posts,
'posts' => $paginated->getItems(),
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
'totalPosts' => $paginated->getTotal(),
'categories' => $this->categoryService->findAll(),
'activeCategory' => $activeCategory,
'searchQuery' => $searchQuery,
@@ -83,19 +56,7 @@ final class PostController
}
/**
* Affiche le détail d'un article par son slug.
*
* Le contenu HTML est déjà sanitisé lors de la création/modification
* (via HtmlSanitizerInterface dans PostService) : aucun nettoyage supplémentaire
* n'est nécessaire à la lecture.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
* @param array<string, string> $args Les paramètres de route (slug)
*
* @return Response La vue de détail de l'article
*
* @throws HttpNotFoundException Si aucun article ne correspond au slug
* @param array<string, mixed> $args
*/
public function show(Request $req, Response $res, array $args): Response
{
@@ -108,30 +69,12 @@ final class PostController
return $this->view->render($res, 'pages/post/detail.twig', ['post' => $post]);
}
/**
* Affiche la liste des articles dans l'interface d'administration.
*
* Un administrateur ou un éditeur voit tous les articles.
* Un utilisateur normal voit uniquement ses propres articles.
*
* Accepte deux paramètres de requête cumulables :
* - `q` (string) : recherche plein texte FTS5 sur titre, contenu et auteur
* - `categorie` (string) : filtre par slug de catégorie
*
* Si `q` est fourni, les résultats sont triés par pertinence BM25.
* Sans `q`, les articles sont triés du plus récent au plus ancien.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response La vue d'administration des posts
*/
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;
@@ -143,16 +86,24 @@ final class PostController
}
if ($searchQuery !== '') {
$authorId = $isAdmin ? null : (int) $userId;
$posts = $this->postService->searchPosts($searchQuery, $categoryId, $authorId);
$authorId = $isAdmin ? null : (int) $userId;
$paginated = $this->postService->searchPostsPaginated(
$searchQuery,
$page,
self::ADMIN_PER_PAGE,
$categoryId,
$authorId,
);
} else {
$posts = $isAdmin
? $this->postService->getAllPosts($categoryId)
: $this->postService->getPostsByUserId((int) $userId, $categoryId);
$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' => $posts,
'posts' => $paginated->getItems(),
'pagination' => PaginationPresenter::fromRequest($req, $paginated),
'totalPosts' => $paginated->getTotal(),
'categories' => $this->categoryService->findAll(),
'activeCategory' => $activeCategory,
'searchQuery' => $searchQuery,
@@ -162,18 +113,7 @@ final class PostController
}
/**
* Affiche le formulaire de création (id=0) ou d'édition d'un article.
*
* L'accès en édition est refusé si l'utilisateur n'est pas l'auteur
* de l'article et n'a pas le rôle admin.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
* @param array<string, string> $args Les paramètres de route (id)
*
* @return Response Le formulaire ou une redirection
*
* @throws HttpNotFoundException Si l'article demandé n'existe pas
* @param array<string, mixed> $args
*/
public function form(Request $req, Response $res, array $args): Response
{
@@ -187,7 +127,6 @@ final class PostController
throw new HttpNotFoundException($req);
}
// Vérification des droits avant affichage du formulaire
if (!$this->canEditPost($post)) {
$this->flash->set('post_error', "Vous ne pouvez pas modifier un article dont vous n'êtes pas l'auteur");
@@ -203,24 +142,9 @@ final class PostController
]);
}
/**
* Traite la soumission du formulaire de création d'article.
*
* L'auteur est l'utilisateur connecté, lu depuis la session.
* Le slug est généré automatiquement depuis le titre par PostService —
* la valeur éventuellement saisie dans le formulaire est ignorée à la création
* (elle n'est prise en compte qu'à la modification via update()).
* En cas d'erreur de validation, redirige vers le formulaire avec un message flash.
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
*
* @return Response Une redirection vers /admin/posts ou /admin/posts/edit/0
*/
public function create(Request $req, Response $res): Response
{
['title' => $title, 'content' => $content, 'category_id' => $categoryId] =
$this->extractPostData($req);
['title' => $title, 'content' => $content, 'category_id' => $categoryId] = $this->extractPostData($req);
try {
$this->postService->createPost($title, $content, $this->sessionManager->getUserId() ?? 0, $categoryId);
@@ -239,27 +163,13 @@ final class PostController
}
/**
* Traite la soumission du formulaire de modification d'article.
*
* Vérifie les droits avant modification : seul l'auteur ou un admin peut modifier.
* Un second 404 est possible si l'article est supprimé entre la vérification
* des droits et l'UPDATE (race condition).
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
* @param array<string, string> $args Les paramètres de route (id)
*
* @return Response Une redirection vers /admin/posts ou vers le formulaire
*
* @throws HttpNotFoundException Si l'article n'existe pas ou a disparu (race condition)
* @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);
['title' => $title, 'content' => $content, 'slug' => $slug, 'category_id' => $categoryId] = $this->extractPostData($req);
// Récupération de l'article pour vérification des droits avant modification
try {
$post = $this->postService->getPostById($id);
} catch (NotFoundException) {
@@ -276,7 +186,6 @@ final class PostController
$this->postService->updatePost($id, $title, $content, $slug, $categoryId);
$this->flash->set('post_success', 'L\'article a été modifié avec succès');
} catch (NotFoundException) {
// L'article a disparu entre la vérification des droits et l'UPDATE (race condition)
throw new HttpNotFoundException($req);
} catch (\InvalidArgumentException $e) {
$this->flash->set('post_error', $e->getMessage());
@@ -292,23 +201,10 @@ final class PostController
}
/**
* Supprime un article.
*
* Vérifie les droits avant suppression : seul l'auteur ou un admin peut supprimer.
* Un second 404 est possible si l'article est supprimé entre la vérification
* des droits et le DELETE (race condition — cohérent avec update()).
*
* @param Request $req La requête HTTP
* @param Response $res La réponse HTTP
* @param array<string, string> $args Les paramètres de route (id)
*
* @return Response Une redirection vers /admin/posts
*
* @throws HttpNotFoundException Si l'article n'existe pas ou a disparu (race condition)
* @param array<string, mixed> $args
*/
public function delete(Request $req, Response $res, array $args): Response
{
// Récupération de l'article pour vérification des droits avant suppression
try {
$post = $this->postService->getPostById((int) $args['id']);
} catch (NotFoundException) {
@@ -324,7 +220,6 @@ final class PostController
try {
$this->postService->deletePost($post->getId());
} catch (NotFoundException) {
// L'article a disparu entre la vérification des droits et le DELETE (race condition)
throw new HttpNotFoundException($req);
}
@@ -333,41 +228,22 @@ final class PostController
return $res->withHeader('Location', '/admin/posts')->withStatus(302);
}
/**
* Vérifie si l'utilisateur connecté est autorisé à modifier ou supprimer un article.
*
* L'accès est accordé si l'utilisateur est l'auteur de l'article
* ou s'il a le rôle administrateur.
*
* @param Post $post L'article concerné
*
* @return bool True si l'action est autorisée
*/
private function canEditPost(Post $post): bool
{
// Un administrateur ou un éditeur a tous les droits sur tous les articles
if ($this->sessionManager->isAdmin() || $this->sessionManager->isEditor()) {
return true;
}
// Un utilisateur standard ne peut agir que sur ses propres articles
return $post->getAuthorId() === $this->sessionManager->getUserId();
}
/**
* Extrait et normalise les données d'article depuis le corps de la requête.
*
* @param Request $req La requête HTTP
*
* @return array{title: string, content: string, slug: string, category_id: int|null} Les données nettoyées
* @return array{title: string, content: string, slug: string, category_id: int|null}
*/
private function extractPostData(Request $req): array
{
/** @var array<string, mixed> $data */
$data = (array) $req->getParsedBody();
$categoryId = ($data['category_id'] ?? '') !== ''
? (int) $data['category_id']
: null;
$categoryId = ($data['category_id'] ?? '') !== '' ? (int) $data['category_id'] : null;
return [
'title' => trim((string) ($data['title'] ?? '')),

View File

@@ -5,20 +5,8 @@ namespace App\Post;
use PDO;
/**
* Dépôt pour la persistance des articles.
*
* Responsabilité unique : exécuter les requêtes SQL liées à la table `posts`
* et retourner des instances de Post hydratées.
* Chaque requête de lecture effectue un LEFT JOIN sur `users` pour charger
* le nom d'auteur, et un LEFT JOIN sur `categories` pour charger le nom et
* le slug de catégorie — sans requête supplémentaire.
*/
final class PostRepository implements PostRepositoryInterface
{
/**
* Fragment SELECT commun à toutes les requêtes de lecture (avec JOINs).
*/
private const SELECT = '
SELECT posts.id, posts.title, posts.content, posts.slug,
posts.author_id, posts.category_id, posts.created_at, posts.updated_at,
@@ -30,130 +18,150 @@ final class PostRepository implements PostRepositoryInterface
LEFT JOIN categories ON categories.id = posts.category_id
';
/**
* @param PDO $db Instance de connexion à la base de données
*/
public function __construct(private readonly PDO $db)
{
}
/**
* Retourne tous les articles triés du plus récent au plus ancien.
*
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
*
* @return Post[] La liste des articles
*/
public function findAll(?int $categoryId = null): array
{
if ($categoryId !== null) {
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.category_id = :category_id ORDER BY posts.id DESC');
$stmt->execute([':category_id' => $categoryId]);
} else {
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));
}
$rows = $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 array_map(fn ($row) => Post::fromArray($row), $rows);
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();
}
/**
* Retourne les N articles les plus récents, tous auteurs confondus.
*
* @param int $limit Nombre maximum d'articles à retourner
*
* @return Post[] Les articles les plus récents
*/
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();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map(fn ($row) => Post::fromArray($row), $rows);
return $this->hydratePosts($stmt->fetchAll(PDO::FETCH_ASSOC));
}
/**
* Retourne tous les articles d'un utilisateur donné, triés du plus récent au plus ancien.
*
* @param int $userId Identifiant de l'auteur
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
*
* @return Post[] La liste des articles de cet utilisateur
*/
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) {
$stmt = $this->db->prepare(
self::SELECT . ' WHERE posts.author_id = :author_id AND posts.category_id = :category_id ORDER BY posts.id DESC'
);
$stmt->execute([':author_id' => $userId, ':category_id' => $categoryId]);
} else {
$stmt = $this->db->prepare(self::SELECT . ' WHERE posts.author_id = :author_id ORDER BY posts.id DESC');
$stmt->execute([':author_id' => $userId]);
$sql .= ' AND posts.category_id = :category_id';
$params[':category_id'] = $categoryId;
}
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
$sql .= ' ORDER BY posts.id DESC';
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return array_map(fn ($row) => Post::fromArray($row), $rows);
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();
}
/**
* Trouve un article par son slug URL.
*
* @param string $slug Le slug URL de l'article
*
* @return Post|null L'article trouvé, ou null s'il n'existe pas
*/
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;
}
/**
* Trouve un article par son identifiant.
*
* @param int $id Identifiant de l'article
*
* @return Post|null L'article trouvé, ou null s'il n'existe pas
*/
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;
}
/**
* Persiste un nouvel article en base de données.
*
* @param Post $post L'article à créer
* @param string $slug Le slug unique généré pour cet article
* @param int $authorId Identifiant de l'auteur
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
*
* @return int L'identifiant généré par la base de données
*/
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 = $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(),
@@ -168,27 +176,14 @@ final class PostRepository implements PostRepositoryInterface
return (int) $this->db->lastInsertId();
}
/**
* Met à jour un article existant en base de données.
*
* Retourne le nombre de lignes affectées. Une valeur de 0 indique que
* l'article n'existe plus au moment de l'écriture (suppression concurrente).
*
* @param int $id Identifiant de l'article à modifier
* @param Post $post L'article avec les nouvelles données
* @param string $slug Le nouveau slug unique
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
*
* @return int Nombre de lignes affectées (0 si l'article n'existe plus)
*/
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 = $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(),
@@ -202,13 +197,6 @@ final class PostRepository implements PostRepositoryInterface
return $stmt->rowCount();
}
/**
* Supprime un article de la base de données.
*
* @param int $id Identifiant de l'article à supprimer
*
* @return int Nombre de lignes supprimées (0 si l'article n'existe plus)
*/
public function delete(int $id): int
{
$stmt = $this->db->prepare('DELETE FROM posts WHERE id = :id');
@@ -217,23 +205,6 @@ final class PostRepository implements PostRepositoryInterface
return $stmt->rowCount();
}
/**
* Recherche des articles en plein texte via l'index FTS5.
*
* La requête est tokenisée mot par mot : chaque terme est traité comme un
* préfixe (ex: "slim" correspond à "Slim", "Slimframework"…). Les termes
* sont combinés en AND implicite — tous doivent être présents dans le document.
* Les caractères spéciaux FTS5 sont échappés par guillemets doubles.
*
* Les résultats sont triés par pertinence BM25 (meilleur en premier).
* Filtrages optionnels disponibles : par catégorie et/ou par auteur.
*
* @param string $query La saisie utilisateur brute
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
* @param int|null $authorId Filtre optionnel par identifiant d'auteur (rôle user)
*
* @return Post[] Les articles correspondant à la recherche, triés par pertinence
*/
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array
{
$ftsQuery = $this->buildFtsQuery($query);
@@ -242,19 +213,50 @@ final class PostRepository implements PostRepositoryInterface
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 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
SELECT COUNT(*)
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
JOIN posts p ON p.id = f.rowid
WHERE posts_fts MATCH :query
';
$params = [':query' => $ftsQuery];
if ($categoryId !== null) {
@@ -267,27 +269,80 @@ final class PostRepository implements PostRepositoryInterface
$params[':author_id'] = $authorId;
}
$sql .= ' ORDER BY rank';
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
$this->bindParams($stmt, $params);
$stmt->execute();
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
return (int) $stmt->fetchColumn();
}
return array_map(fn ($row) => Post::fromArray($row), $rows);
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));
}
/**
* Construit une requête FTS5 sûre depuis la saisie utilisateur.
*
* Chaque mot est wrappé entre guillemets doubles (échappement interne
* des guillemets par doublement) et suivi d'un `*` pour la recherche
* par préfixe. Les mots sont joints par un espace (AND implicite FTS5).
*
* @param string $input La saisie brute de l'utilisateur
*
* @return string La requête FTS5 prête à l'emploi, ou '' si vide
* @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) ?: [];
@@ -305,30 +360,21 @@ final class PostRepository implements PostRepositoryInterface
}
/**
* Vérifie si un slug est déjà utilisé par un autre article.
*
* @param string $slug Le slug à vérifier
* @param int|null $excludeId Identifiant à exclure de la vérification (pour les mises à jour)
*
* @return bool True si le slug est déjà pris par un autre article
* @param array<string, mixed> $params
*/
public function slugExists(string $slug, ?int $excludeId = null): bool
private function bindParams(\PDOStatement $stmt, array $params): void
{
$stmt = $this->db->prepare('SELECT id FROM posts WHERE slug = :slug');
$stmt->execute([':slug' => $slug]);
$existingId = $stmt->fetchColumn();
if ($existingId === false) {
return false;
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
}
}
$existingId = (int) $existingId;
if ($excludeId !== null) {
return $existingId !== $excludeId;
}
return true;
/**
* @param array<int, array<string, mixed>> $rows
* @return Post[]
*/
private function hydratePosts(array $rows): array
{
return array_map(fn ($row) => Post::fromArray($row), $rows);
}
}

View File

@@ -3,111 +3,49 @@ declare(strict_types=1);
namespace App\Post;
/**
* Contrat de persistance des articles.
*
* Découple PostService de l'implémentation concrète PDO/SQLite,
* facilitant les mocks dans les tests unitaires.
*/
interface PostRepositoryInterface
{
/**
* Retourne tous les articles triés du plus récent au plus ancien.
*
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
*
* @return Post[]
*/
/** @return Post[] */
public function findAll(?int $categoryId = null): array;
/**
* Retourne les N articles les plus récents (flux RSS).
*
* @param int $limit Nombre maximum d'articles à retourner
*
* @return Post[]
*/
/** @return Post[] */
public function findPage(int $limit, int $offset, ?int $categoryId = null): array;
public function countAll(?int $categoryId = null): int;
/** @return Post[] */
public function findRecent(int $limit): array;
/**
* Retourne tous les articles d'un utilisateur donné.
*
* @param int $userId Identifiant de l'auteur
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
*
* @return Post[]
*/
/** @return Post[] */
public function findByUserId(int $userId, ?int $categoryId = null): array;
/**
* Trouve un article par son slug URL.
*
* @param string $slug Le slug URL de l'article
*
* @return Post|null L'article trouvé, ou null s'il n'existe pas
*/
/** @return Post[] */
public function findByUserPage(int $userId, int $limit, int $offset, ?int $categoryId = null): array;
public function countByUserId(int $userId, ?int $categoryId = null): int;
public function findBySlug(string $slug): ?Post;
/**
* Trouve un article par son identifiant.
*
* @param int $id Identifiant de l'article
*
* @return Post|null L'article trouvé, ou null s'il n'existe pas
*/
public function findById(int $id): ?Post;
/**
* Persiste un nouvel article en base de données.
*
* @param Post $post L'article à créer
* @param string $slug Le slug unique généré pour cet article
* @param int $authorId Identifiant de l'auteur
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
*
* @return int L'identifiant généré par la base de données
*/
public function create(Post $post, string $slug, int $authorId, ?int $categoryId): int;
/**
* Met à jour un article existant.
*
* @param int $id Identifiant de l'article à modifier
* @param Post $post L'article avec les nouvelles données
* @param string $slug Le nouveau slug unique
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
*
* @return int Nombre de lignes affectées
*/
public function update(int $id, Post $post, string $slug, ?int $categoryId): int;
/**
* Supprime un article de la base de données.
*
* @param int $id Identifiant de l'article à supprimer
*
* @return int Nombre de lignes supprimées
*/
public function delete(int $id): int;
/**
* Recherche des articles en plein texte via FTS5.
*
* @param string $query La saisie utilisateur brute
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
* @param int|null $authorId Filtre optionnel par identifiant d'auteur
*
* @return Post[]
*/
/** @return Post[] */
public function search(string $query, ?int $categoryId = null, ?int $authorId = null): array;
/**
* Vérifie si un slug est déjà utilisé par un autre article.
*
* @param string $slug Le slug à vérifier
* @param int|null $excludeId Identifiant à exclure (mise à jour)
*
* @return bool True si le slug est déjà pris
*/
/** @return Post[] */
public function searchPage(string $query, int $limit, int $offset, ?int $categoryId = null, ?int $authorId = null): array;
public function countSearch(string $query, ?int $categoryId = null, ?int $authorId = null): int;
public function slugExists(string $slug, ?int $excludeId = null): bool;
public function countByEmbeddedMediaUrl(string $url): int;
/** @return Post[] */
public function findByEmbeddedMediaUrl(string $url, int $limit = 5): array;
}

View File

@@ -5,82 +5,66 @@ namespace App\Post;
use App\Shared\Exception\NotFoundException;
use App\Shared\Html\HtmlSanitizerInterface;
use App\Shared\Pagination\PaginatedResult;
use App\Shared\Util\SlugHelper;
/**
* Service métier pour les articles.
*
* Centralise toute la logique qui ne relève ni du stockage (PostRepository)
* ni de la présentation (PostController / PostExtension) :
* - génération et unicité des slugs
* - sanitisation du contenu HTML à l'écriture
* - orchestration des opérations create / update / delete
*
* Flux de sanitisation :
* 1. L'utilisateur saisit du HTML via Trumbowyg
* 2. createPost() / updatePost() passent le contenu brut à HtmlSanitizerInterface
* 3. HtmlSanitizerInterface (implémentée par HtmlSanitizer) délègue à HTMLPurifier, configuré pour n'autoriser
* que les balises produites par Trumbowyg
* 4. Le contenu purifié est stocké en base — le filtre |raw dans Twig est sûr
*/
final class PostService implements PostServiceInterface
{
/**
* @param PostRepositoryInterface $postRepository Dépôt de persistance des articles
* @param HtmlSanitizerInterface $htmlSanitizer Service de sanitisation HTML
*/
public function __construct(
private readonly PostRepositoryInterface $postRepository,
private readonly HtmlSanitizerInterface $htmlSanitizer,
) {
}
/**
* Retourne tous les articles triés du plus récent au plus ancien.
*
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
*
* @return Post[]
*/
public function getAllPosts(?int $categoryId = null): array
{
return $this->postRepository->findAll($categoryId);
}
/**
* Retourne les N articles les plus récents pour le flux RSS.
*
* @param int $limit Nombre maximum d'articles à retourner (défaut : 20)
*
* @return Post[]
* @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);
}
/**
* Retourne tous les articles d'un utilisateur donné.
*
* @param int $userId Identifiant de l'auteur
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
*
* @return Post[]
*/
public function getPostsByUserId(int $userId, ?int $categoryId = null): array
{
return $this->postRepository->findByUserId($userId, $categoryId);
}
/**
* Retourne un article par son slug URL.
*
* @param string $slug Le slug URL de l'article
*
* @return Post L'article avec contenu sûr
*
* @throws NotFoundException Si aucun article ne correspond au slug
* @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);
@@ -92,15 +76,6 @@ final class PostService implements PostServiceInterface
return $post;
}
/**
* Retourne un article par son identifiant.
*
* @param int $id Identifiant de l'article
*
* @return Post L'article avec son contenu
*
* @throws NotFoundException Si aucun article ne correspond à cet identifiant
*/
public function getPostById(int $id): Post
{
$post = $this->postRepository->findById($id);
@@ -112,22 +87,6 @@ final class PostService implements PostServiceInterface
return $post;
}
/**
* Crée un nouvel article et retourne son identifiant.
*
* Un slug unique est généré à partir du titre. Si le slug existe déjà,
* un suffixe numérique est ajouté (ex: "mon-article-2").
* Le contenu HTML est sanitisé avant stockage.
*
* @param string $title Titre de l'article
* @param string $content Contenu HTML brut (sera sanitisé)
* @param int $authorId Identifiant de l'auteur
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
*
* @return int L'identifiant de l'article créé
*
* @throws \InvalidArgumentException Si le titre ou le contenu sont invalides
*/
public function createPost(string $title, string $content, int $authorId, ?int $categoryId = null): int
{
$sanitizedContent = $this->htmlSanitizer->sanitize($content);
@@ -137,29 +96,8 @@ final class PostService implements PostServiceInterface
return $this->postRepository->create($post, $slug, $authorId, $categoryId);
}
/**
* Met à jour un article existant.
*
* Le slug est préservé par défaut. Si $newSlugInput est fourni et différent
* du slug actuel, il est nettoyé puis rendu unique avant d'être appliqué.
*
* @param int $id Identifiant de l'article à modifier
* @param string $title Nouveau titre
* @param string $content Nouveau contenu HTML brut (sera sanitisé)
* @param string $newSlugInput Nouveau slug souhaité (vide = conserver l'actuel)
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
*
* @throws NotFoundException Si l'article n'existe plus
* @throws \InvalidArgumentException Si le titre ou le contenu sont invalides
* @return void
*/
public function updatePost(
int $id,
string $title,
string $content,
string $newSlugInput = '',
?int $categoryId = null,
): void {
public function updatePost(int $id, string $title, string $content, string $newSlugInput = '', ?int $categoryId = null): void
{
$current = $this->postRepository->findById($id);
if ($current === null) {
@@ -168,10 +106,9 @@ final class PostService implements PostServiceInterface
$sanitizedContent = $this->htmlSanitizer->sanitize($content);
$post = new Post($id, $title, $sanitizedContent);
$slugToUse = $current->getStoredSlug();
$newSlugInput = trim($newSlugInput);
$cleanSlugInput = $this->normalizeSlugInput($newSlugInput);
$slugToUse = $current->getStoredSlug();
$newSlugInput = trim($newSlugInput);
$cleanSlugInput = $this->normalizeSlugInput($newSlugInput);
if ($cleanSlugInput !== '' && $cleanSlugInput !== $current->getStoredSlug()) {
$slugToUse = $this->generateUniqueSlug($cleanSlugInput, $id);
@@ -184,27 +121,38 @@ final class PostService implements PostServiceInterface
}
}
/**
* Recherche des articles en plein texte via FTS5.
*
* @param string $query La saisie utilisateur brute
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
* @param int|null $authorId Filtre optionnel par identifiant d'auteur
*
* @return Post[]
*/
public function searchPosts(string $query, ?int $categoryId = null, ?int $authorId = null): array
{
return $this->postRepository->search($query, $categoryId, $authorId);
}
/**
* Supprime un article.
*
* @param int $id Identifiant de l'article à supprimer
*
* @throws NotFoundException Si l'article n'existe plus au moment de la suppression
* @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);
@@ -214,29 +162,11 @@ final class PostService implements PostServiceInterface
}
}
/**
* Nettoie une saisie utilisateur pour en faire un slug valide.
*
* Délègue à SlugHelper::generate() — voir sa documentation pour le détail
* de l'algorithme.
*
* @param string $input La valeur brute saisie par l'utilisateur
*
* @return string Le slug nettoyé, ou '' si invalide
*/
private function normalizeSlugInput(string $input): string
{
return SlugHelper::generate($input);
}
/**
* Génère un slug unique en ajoutant un suffixe numérique si nécessaire.
*
* @param string $baseSlug Le slug de base généré depuis le titre
* @param int|null $excludeId Identifiant à exclure lors de la vérification (mise à jour)
*
* @return string Le slug garanti unique
*/
private function generateUniqueSlug(string $baseSlug, ?int $excludeId = null): string
{
$slug = $baseSlug;

View File

@@ -4,116 +4,49 @@ declare(strict_types=1);
namespace App\Post;
use App\Shared\Exception\NotFoundException;
use App\Shared\Pagination\PaginatedResult;
/**
* Contrat du service de gestion des articles.
*
* Permet de mocker le service dans les tests unitaires sans dépendre
* de la classe concrète finale PostService.
*/
interface PostServiceInterface
{
/**
* Retourne tous les articles publiés, avec un filtre optionnel par catégorie.
*
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
*
* @return Post[]
*/
/** @return Post[] */
public function getAllPosts(?int $categoryId = null): array;
/**
* Retourne les articles les plus récents.
*
* @param int $limit Nombre maximum d'articles à retourner (défaut : 20)
*
* @return Post[]
* @return PaginatedResult<Post>
*/
public function getAllPostsPaginated(int $page, int $perPage, ?int $categoryId = null): PaginatedResult;
/** @return Post[] */
public function getRecentPosts(int $limit = 20): array;
/**
* Retourne les articles d'un auteur donné.
*
* @param int $userId Identifiant de l'auteur
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
*
* @return Post[]
*/
/** @return Post[] */
public function getPostsByUserId(int $userId, ?int $categoryId = null): array;
/**
* Retourne un article par son slug URL.
*
* @param string $slug Le slug URL de l'article
*
* @return Post L'article avec contenu sûr
*
* @throws NotFoundException Si aucun article ne correspond au slug
* @return PaginatedResult<Post>
*/
public function getPostsByUserIdPaginated(int $userId, int $page, int $perPage, ?int $categoryId = null): PaginatedResult;
public function getPostBySlug(string $slug): Post;
/**
* Retourne un article par son identifiant.
*
* @param int $id Identifiant de l'article
*
* @return Post L'article avec son contenu
*
* @throws NotFoundException Si aucun article ne correspond à cet identifiant
*/
public function getPostById(int $id): Post;
/**
* Crée un nouvel article.
*
* @param string $title Titre de l'article
* @param string $content Contenu HTML brut (sera sanitisé)
* @param int $authorId Identifiant de l'auteur
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
*
* @return int L'identifiant de l'article créé
*
* @throws \InvalidArgumentException Si le titre ou le contenu sont invalides
*/
public function createPost(string $title, string $content, int $authorId, ?int $categoryId = null): int;
/**
* Met à jour un article existant.
*
* @param int $id Identifiant de l'article à modifier
* @param string $title Nouveau titre
* @param string $content Nouveau contenu HTML brut (sera sanitisé)
* @param string $newSlugInput Nouveau slug souhaité (vide = conserver l'actuel)
* @param int|null $categoryId Identifiant de la catégorie (null si sans catégorie)
*
* @throws NotFoundException Si l'article n'existe plus
* @throws \InvalidArgumentException Si le titre ou le contenu sont invalides
*/
public function updatePost(
int $id,
string $title,
string $content,
string $newSlugInput = '',
?int $categoryId = null,
): void;
public function updatePost(int $id, string $title, string $content, string $newSlugInput = '', ?int $categoryId = null): void;
/**
* Recherche des articles par mots-clés dans le titre, le contenu et l'auteur.
*
* @param string $query La saisie utilisateur brute
* @param int|null $categoryId Filtre optionnel par identifiant de catégorie
* @param int|null $authorId Filtre optionnel par identifiant d'auteur
*
* @return Post[]
*/
/** @return Post[] */
public function searchPosts(string $query, ?int $categoryId = null, ?int $authorId = null): array;
/**
* Supprime un article.
*
* @param int $id Identifiant de l'article à supprimer
*
* @throws NotFoundException Si l'article n'existe plus au moment de la suppression
* @return PaginatedResult<Post>
*/
public function searchPostsPaginated(string $query, int $page, int $perPage, ?int $categoryId = null, ?int $authorId = null): PaginatedResult;
public function countMediaUsages(string $url): int;
/** @return Post[] */
public function findMediaUsages(string $url, int $limit = 5): array;
public function deletePost(int $id): void;
}