Files
clinicpro/src/Blog/Repository/BlogRepository.php
T
hamedandClaude Opus 5 ed2d2ec74a fix(blog): order every blog list by id as well as created_at
created_at is a second-resolution integer, so dozens of posts routinely share
one value and MySQL is free to return tied rows in any order. Two consecutive
pages of the same list could hand back one post twice and never show another,
and the admin-list tests failed at random on whichever post got shuffled past
the page boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 08:43:11 +03:30

270 lines
11 KiB
PHP

<?php
namespace App\Blog\Repository;
use App\Blog\Entity\Blog;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class BlogRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Blog::class); }
public function findByUuid(string $uuid): ?Blog { return $this->findOneBy(['uuid' => $uuid]); }
public function findBySlug(string $slug): ?Blog { return $this->findOneBy(['slug' => $slug]); }
public function findByTopicSlug(string $topicSlug): ?Blog { return $this->findOneBy(['topicSlug' => $topicSlug]); }
/**
* Resolve a post from any public identifier that has ever addressed it.
*
* A post's slug is derived from its title, so editing the title changes the URL
* and every previously published link 404s. The stable parts survive: the uuid,
* the pipeline's topic_slug, and the 8-char uuid prefix that every generated
* slug carries as its suffix. Callers compare the returned slug with the
* requested one and redirect when they differ.
*/
public function findByPublicIdentifier(string $identifier): ?Blog
{
return $this->findBySlug($identifier)
?? $this->findByUuid($identifier)
?? $this->findByTopicSlug($identifier)
?? $this->findBySlugUuidSuffix($identifier);
}
/**
* Match on the 8-char uuid prefix that Blog::generateSlug() appends to every slug,
* so a renamed post is still reachable through its old URL.
*/
private function findBySlugUuidSuffix(string $slug): ?Blog
{
$suffix = substr($slug, strrpos($slug, '-') === false ? 0 : strrpos($slug, '-') + 1);
if (!preg_match('/^[0-9a-f]{8}$/i', $suffix)) {
return null;
}
return $this->createQueryBuilder('b')
->where('b.uuid LIKE :prefix')
->setParameter('prefix', $suffix . '-%')
->setMaxResults(1)
->getQuery()->getOneOrNullResult();
}
/**
* The admin review queue: posts awaiting a doctor's decision, newest first.
* @return Blog[]
*/
/*
* هر چهار فهرست روی `id` هم مرتب می‌شوند، نه فقط `createdAt`.
*
* `created_at` ثانیه‌ای است و ده‌ها مقاله می‌توانند دقیقاً یک مقدار داشته باشند؛
* ترتیب بین ردیف‌های برابر در MySQL تضمین‌شده نیست. بدون این tiebreaker، دو صفحهٔ
* پیاپیِ همان فهرست می‌توانند یک مقاله را دوبار بدهند و مقالهٔ دیگری را هرگز.
*/
public function findByReviewStatus(string $reviewStatus, int $page = 1, int $limit = 20): array
{
return $this->createQueryBuilder('b')
->leftJoin('b.city', 'c')->addSelect('c')
->leftJoin('b.reviewer', 'r')->addSelect('r')
->where('b.reviewStatus = :rs')
->setParameter('rs', $reviewStatus)
->orderBy('b.createdAt', 'DESC')
->addOrderBy('b.id', 'DESC')
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit)
->getQuery()->getResult();
}
public function countByReviewStatus(string $reviewStatus): int
{
return (int) $this->createQueryBuilder('b')
->select('COUNT(b.id)')
->where('b.reviewStatus = :rs')
->setParameter('rs', $reviewStatus)
->getQuery()->getSingleScalarResult();
}
/**
* Admin list — ALL statuses (draft/published/archived), newest first, with
* optional status + title search. The public list only returns published, so
* the admin panel must use this to see drafts.
* @return Blog[]
*/
public function findForAdmin(int $page, int $limit, ?string $status = null, ?string $search = null): array
{
$qb = $this->adminQb($status, $search)
->leftJoin('b.city', 'c')->addSelect('c')
->orderBy('b.createdAt', 'DESC')
->addOrderBy('b.id', 'DESC')
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit);
return $qb->getQuery()->getResult();
}
public function countForAdmin(?string $status = null, ?string $search = null): int
{
return (int) $this->adminQb($status, $search)
->select('COUNT(b.id)')->getQuery()->getSingleScalarResult();
}
private function adminQb(?string $status, ?string $search): \Doctrine\ORM\QueryBuilder
{
$qb = $this->createQueryBuilder('b');
if ($status !== null && $status !== '') {
$qb->andWhere('b.status = :status')->setParameter('status', $status);
}
if ($search !== null && $search !== '') {
$qb->andWhere('b.title LIKE :q')->setParameter('q', '%' . $search . '%');
}
return $qb;
}
/**
* A representative's own posts, newest first, optionally filtered by status.
* @return Blog[]
*/
public function findByRepresentation(\App\Representation\Entity\Representation $rep, int $page = 1, int $limit = 20, ?string $status = null): array
{
$qb = $this->createQueryBuilder('b')
->leftJoin('b.city', 'c')->addSelect('c')
->where('b.representation = :rep')
->setParameter('rep', $rep)
->orderBy('b.createdAt', 'DESC')
->addOrderBy('b.id', 'DESC')
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit);
if ($status !== null && $status !== '') {
$qb->andWhere('b.status = :status')->setParameter('status', $status);
}
return $qb->getQuery()->getResult();
}
public function countByRepresentation(\App\Representation\Entity\Representation $rep, ?string $status = null): int
{
$qb = $this->createQueryBuilder('b')
->select('COUNT(b.id)')
->where('b.representation = :rep')
->setParameter('rep', $rep);
if ($status !== null && $status !== '') {
$qb->andWhere('b.status = :status')->setParameter('status', $status);
}
return (int) $qb->getQuery()->getSingleScalarResult();
}
/**
* Draft posts due for automatic publish: scheduled_at reached, and either not
* in the review workflow or already approved (the review gate wins).
* @return Blog[]
*/
public function findDueForPublish(int $now): array
{
return $this->createQueryBuilder('b')
->where('b.status = :draft')
->andWhere('b.scheduledAt IS NOT NULL AND b.scheduledAt <= :now')
->andWhere('b.reviewStatus IS NULL OR b.reviewStatus = :approved')
->setParameter('draft', Blog::STATUS_DRAFT)
->setParameter('now', $now)
->setParameter('approved', Blog::REVIEW_APPROVED)
->getQuery()->getResult();
}
/** @return Blog[] published, newest first */
public function findPublished(int $page = 1, int $limit = 20, ?string $tag = null, ?int $cityId = null): array
{
$qb = $this->createQueryBuilder('b')
->leftJoin('b.city', 'c')
->addSelect('c')
->where('b.status = :status')
->setParameter('status', Blog::STATUS_PUBLISHED)
->orderBy('b.createdAt', 'DESC')
->addOrderBy('b.id', 'DESC')
->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit);
$this->applyTagFilter($qb, $tag);
$this->applyCityFilter($qb, $cityId);
return $qb->getQuery()->getResult();
}
public function countPublished(?string $tag = null, ?int $cityId = null): int
{
$qb = $this->createQueryBuilder('b')
->select('COUNT(b.id)')
->where('b.status = :status')
->setParameter('status', Blog::STATUS_PUBLISHED);
$this->applyTagFilter($qb, $tag);
$this->applyCityFilter($qb, $cityId);
return (int) $qb->getQuery()->getSingleScalarResult();
}
/**
* Distinct tag names across published posts with their post counts, honouring
* the same city scope as the public list. Blog tags are free-form strings in a
* JSON column, so they are counted in PHP — DQL cannot unnest a JSON array.
*
* @return list<array{name: string, count: int}> count DESC, then name ASC
*/
public function tagFacets(?int $cityId = null): array
{
$qb = $this->createQueryBuilder('b')
->select('b.tags')
->where('b.status = :status')
->setParameter('status', Blog::STATUS_PUBLISHED);
$this->applyCityFilter($qb, $cityId);
$counts = [];
foreach ($qb->getQuery()->getArrayResult() as $row) {
foreach ($row['tags'] ?? [] as $name) {
if (!is_string($name) || $name === '') {
continue;
}
$counts[$name] = ($counts[$name] ?? 0) + 1;
}
}
// Ties keep a stable, human-predictable order instead of insertion order.
uksort($counts, static fn(string $a, string $b) => [$counts[$b], $a] <=> [$counts[$a], $b]);
return array_map(
static fn(string $name, int $count) => ['name' => $name, 'count' => $count],
array_keys($counts),
array_values($counts)
);
}
/**
* دامنهٔ یک شهر باید پست‌های همان شهر **و** پست‌های سراسری را ببیند — پست
* سراسری (city NULL) روی همهٔ دامنه‌ها منتشر است، فقط canonicalش روی دامنهٔ اصلی
* می‌نشیند. بدون شرط NULL، دامنه‌های شهری محتوای عمومی را از دست می‌دادند.
*/
private function applyCityFilter(\Doctrine\ORM\QueryBuilder $qb, ?int $cityId): void
{
if ($cityId === null) {
return;
}
$qb->andWhere('b.city = :cityId OR b.city IS NULL')
->setParameter('cityId', $cityId);
}
private function applyTagFilter(\Doctrine\ORM\QueryBuilder $qb, ?string $tag): void
{
if ($tag === null || $tag === '') {
return;
}
// Blog.tags is a JSON array of tag names; match exact name membership.
// Doctrine's JsonType writes this column with plain json_encode, so Persian
// names are stored \uXXXX-escaped. MariaDB's JSON_CONTAINS compares the two
// documents without normalizing those escapes, so the candidate must be
// encoded identically — JSON_UNESCAPED_UNICODE never matched a single row.
$qb->andWhere('JSON_CONTAINS(b.tags, :tag) = 1')
->setParameter('tag', json_encode($tag));
}
public function save(Blog $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); }
public function remove(Blog $e, bool $flush = true): void { $this->getEntityManager()->remove($e); if ($flush) $this->getEntityManager()->flush(); }
}