feat(blog): add admin endpoint to list all blog posts with status filtering
This commit is contained in:
@@ -15,6 +15,7 @@ const STATUS_FILTERS = [
|
|||||||
{ value: '', label: 'همه' },
|
{ value: '', label: 'همه' },
|
||||||
{ value: 'draft', label: 'پیشنویس' },
|
{ value: 'draft', label: 'پیشنویس' },
|
||||||
{ value: 'published', label: 'منتشرشده' },
|
{ value: 'published', label: 'منتشرشده' },
|
||||||
|
{ value: 'archived', label: 'آرشیو' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function BlogsPage() {
|
export default function BlogsPage() {
|
||||||
@@ -32,7 +33,8 @@ export default function BlogsPage() {
|
|||||||
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
const params = new URLSearchParams({ page: String(page), limit: String(limit) });
|
||||||
if (search) params.set('search', search);
|
if (search) params.set('search', search);
|
||||||
if (statusFilter) params.set('status', statusFilter);
|
if (statusFilter) params.set('status', statusFilter);
|
||||||
return api.get<PaginatedResponse<Blog>>(`/api/v1/blogs?${params}`);
|
// اندپوینت ادمین همهٔ وضعیتها را برمیگرداند (اندپوینت عمومی فقط منتشرشده).
|
||||||
|
return api.get<PaginatedResponse<Blog>>(`/api/v1/admin/blogs?${params}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 `/api/v1/blog/{slug}`
|
||||||
|
|
||||||
Get a single blog post by slug.
|
Get a single blog post by slug.
|
||||||
|
|||||||
@@ -149,6 +149,37 @@ class BlogController extends BaseController
|
|||||||
|
|
||||||
// ── Admin CRUD ────────────────────────────────────────────────────────────
|
// ── 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(
|
#[OA\Post(
|
||||||
path: '/api/v1/blog',
|
path: '/api/v1/blog',
|
||||||
summary: 'Create a new blog post (admin only)',
|
summary: 'Create a new blog post (admin only)',
|
||||||
|
|||||||
@@ -40,6 +40,40 @@ class BlogRepository extends ServiceEntityRepository
|
|||||||
->getQuery()->getSingleScalarResult();
|
->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.
|
* A representative's own posts, newest first, optionally filtered by status.
|
||||||
* @return Blog[]
|
* @return Blog[]
|
||||||
|
|||||||
@@ -58,6 +58,46 @@ class BlogV2FieldsTest extends ApiTestCase
|
|||||||
$this->assertSame(6, $b['reading_time']);
|
$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 ──────────────────────────────────────────────────
|
// ── Representative scope ──────────────────────────────────────────────────
|
||||||
|
|
||||||
public function testRepresentativeCreatesPostInOwnCity(): void
|
public function testRepresentativeCreatesPostInOwnCity(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user