From 0cc51ee54f57931c12f26558d8a011b72ec7d188 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Thu, 23 Jul 2026 22:21:14 +0330 Subject: [PATCH] feat(blog): add admin endpoint to list all blog posts with status filtering --- assets/admin/pages/BlogsPage.tsx | 4 ++- docs/api/blog.md | 19 ++++++++++++ src/Blog/Controller/BlogController.php | 31 ++++++++++++++++++++ src/Blog/Repository/BlogRepository.php | 34 ++++++++++++++++++++++ tests/Blog/BlogV2FieldsTest.php | 40 ++++++++++++++++++++++++++ 5 files changed, 127 insertions(+), 1 deletion(-) diff --git a/assets/admin/pages/BlogsPage.tsx b/assets/admin/pages/BlogsPage.tsx index ab887ff7..01f2bae7 100644 --- a/assets/admin/pages/BlogsPage.tsx +++ b/assets/admin/pages/BlogsPage.tsx @@ -15,6 +15,7 @@ const STATUS_FILTERS = [ { value: '', label: 'همه' }, { value: 'draft', label: 'پیش‌نویس' }, { value: 'published', label: 'منتشرشده' }, + { value: 'archived', label: 'آرشیو' }, ]; export default function BlogsPage() { @@ -32,7 +33,8 @@ export default function BlogsPage() { const params = new URLSearchParams({ page: String(page), limit: String(limit) }); if (search) params.set('search', search); if (statusFilter) params.set('status', statusFilter); - return api.get>(`/api/v1/blogs?${params}`); + // اندپوینت ادمین همهٔ وضعیت‌ها را برمی‌گرداند (اندپوینت عمومی فقط منتشرشده). + return api.get>(`/api/v1/admin/blogs?${params}`); }, }); diff --git a/docs/api/blog.md b/docs/api/blog.md index deba29ec..f12278cf 100644 --- a/docs/api/blog.md +++ b/docs/api/blog.md @@ -62,6 +62,25 @@ List published blog posts. --- +## GET `/api/v1/admin/blogs` + +List blog posts of **all** statuses for the admin panel. The public `GET /api/v1/blogs` only returns `published` posts, so the admin panel must use this endpoint to see drafts and archived posts. + +**Permission:** `ROLE_ADMIN` + +### Query Parameters +| Param | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `page` | integer | ❌ | 1 | Page number | +| `limit` | integer | ❌ | 20 (max 50) | Items per page | +| `status` | string | ❌ | — | Filter by `draft` / `published` / `archived`. Omit for all. | +| `search` | string | ❌ | — | Title search (LIKE). | + +### Response `200` +Paginated (`{ data:[...], meta:{...} }`), each item the blog list shape (includes `status`, `review_status`, `scheduled_at`, `representation`, `city`). + +--- + ## GET `/api/v1/blog/{slug}` Get a single blog post by slug. diff --git a/src/Blog/Controller/BlogController.php b/src/Blog/Controller/BlogController.php index a3810e3f..a8793605 100644 --- a/src/Blog/Controller/BlogController.php +++ b/src/Blog/Controller/BlogController.php @@ -149,6 +149,37 @@ class BlogController extends BaseController // ── Admin CRUD ──────────────────────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/admin/blogs', + summary: 'List blog posts of ALL statuses for the admin panel (draft/published/archived)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 20, maximum: 50)), + new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['draft', 'published', 'archived'])), + new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + ], + responses: [new OA\Response(response: 200, description: 'Paginated blog list (all statuses)')] + )] + #[IsGranted('ROLE_ADMIN')] + #[Route('/api/v1/admin/blogs', methods: ['GET'])] + public function adminList(Request $request): JsonResponse + { + $page = max(1, (int) $request->query->get('page', 1)); + $limit = min(50, max(1, (int) $request->query->get('limit', 20))); + $status = $request->query->get('status') ?: null; + $search = $request->query->get('search') ?: null; + + $items = array_map( + fn(Blog $b) => $b->toListArray(), + $this->blogRepo->findForAdmin($page, $limit, $status, $search) + ); + $total = $this->blogRepo->countForAdmin($status, $search); + + return $this->paginated($items, $total, $page, $limit); + } + + #[OA\Post( path: '/api/v1/blog', summary: 'Create a new blog post (admin only)', diff --git a/src/Blog/Repository/BlogRepository.php b/src/Blog/Repository/BlogRepository.php index 84660954..acae7f99 100644 --- a/src/Blog/Repository/BlogRepository.php +++ b/src/Blog/Repository/BlogRepository.php @@ -40,6 +40,40 @@ class BlogRepository extends ServiceEntityRepository ->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') + ->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[] diff --git a/tests/Blog/BlogV2FieldsTest.php b/tests/Blog/BlogV2FieldsTest.php index 3cb4ef3f..3b30e311 100644 --- a/tests/Blog/BlogV2FieldsTest.php +++ b/tests/Blog/BlogV2FieldsTest.php @@ -58,6 +58,46 @@ class BlogV2FieldsTest extends ApiTestCase $this->assertSame(6, $b['reading_time']); } + // ── Admin list shows drafts (public list does not) ──────────────────────── + + public function testAdminListReturnsDraftsThatPublicListHides(): void + { + $admin = $this->createUser(['ROLE_ADMIN']); + $tag = bin2hex(random_bytes(4)); + + $draft = new Blog($admin, "پیش‌نویس-$tag", 'متن آزمایشی مقاله برای تست'); + $draft->setStatus(Blog::STATUS_DRAFT); + $pub = new Blog($admin, "منتشر-$tag", 'متن آزمایشی مقاله برای تست'); + $pub->setStatus(Blog::STATUS_PUBLISHED); + $this->em->persist($draft); + $this->em->persist($pub); + $this->em->flush(); + + // admin list — sees both + $adminList = $this->authJson('GET', '/api/v1/admin/blogs?limit=50', $admin); + $titles = array_column($adminList['data'], 'title'); + $this->assertContains("پیش‌نویس-$tag", $titles, 'admin list must show drafts'); + $this->assertContains("منتشر-$tag", $titles); + + // status filter + $draftsOnly = $this->authJson('GET', '/api/v1/admin/blogs?limit=50&status=draft', $admin); + $dTitles = array_column($draftsOnly['data'], 'title'); + $this->assertContains("پیش‌نویس-$tag", $dTitles); + $this->assertNotContains("منتشر-$tag", $dTitles); + + // public list — drafts hidden + $this->client->request('GET', '/api/v1/blogs?limit=50'); + $publicTitles = array_column(json_decode($this->client->getResponse()->getContent(), true)['data'], 'title'); + $this->assertNotContains("پیش‌نویس-$tag", $publicTitles, 'public list must never expose drafts'); + } + + public function testAdminListRequiresAdmin(): void + { + $user = $this->createUser(['ROLE_USER']); + $this->authJson('GET', '/api/v1/admin/blogs', $user); + $this->assertSame(403, $this->responseCode()); + } + // ── Representative scope ────────────────────────────────────────────────── public function testRepresentativeCreatesPostInOwnCity(): void