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 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(); } }