feat(blog): allow ROLE_IMPORTER to create drafts and access review queue
This commit is contained in:
+7
-2
@@ -130,7 +130,12 @@ Get a single blog post by slug.
|
|||||||
|
|
||||||
Create a new blog post.
|
Create a new blog post.
|
||||||
|
|
||||||
**Permission:** `ROLE_ADMIN`
|
**Permission:** `ROLE_ADMIN`, or `ROLE_IMPORTER` (the content pipeline's system user).
|
||||||
|
|
||||||
|
> A caller that is **not** `ROLE_ADMIN` always produces a review-gated draft: whatever
|
||||||
|
> `status` / `review_status` it sends, the post is stored as `status=draft`,
|
||||||
|
> `review_status=pending_review`. Approving/publishing stays `ROLE_ADMIN`-only via
|
||||||
|
> `POST /api/v1/admin/blog/{uuid}/review`.
|
||||||
|
|
||||||
### Request Body (`application/json`)
|
### Request Body (`application/json`)
|
||||||
```json
|
```json
|
||||||
@@ -294,7 +299,7 @@ The content pipeline (`clinicpro-crawler/content/`) generates Persian health art
|
|||||||
|
|
||||||
List blog drafts awaiting review, newest first.
|
List blog drafts awaiting review, newest first.
|
||||||
|
|
||||||
**Permission:** `ROLE_ADMIN`
|
**Permission:** `ROLE_ADMIN` or `ROLE_IMPORTER` (read-only; the crawler panel shows this queue).
|
||||||
|
|
||||||
### Query Parameters
|
### Query Parameters
|
||||||
| Param | Type | Required | Default | Description |
|
| Param | Type | Required | Default | Description |
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ class BlogController extends BaseController
|
|||||||
|
|
||||||
#[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 (ROLE_ADMIN, or ROLE_IMPORTER as a pending-review draft)',
|
||||||
security: [['bearerAuth' => []]],
|
security: [['bearerAuth' => []]],
|
||||||
requestBody: new OA\RequestBody(
|
requestBody: new OA\RequestBody(
|
||||||
required: true,
|
required: true,
|
||||||
@@ -239,7 +239,7 @@ class BlogController extends BaseController
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
new OA\Response(response: 401, description: 'Unauthorized'),
|
new OA\Response(response: 401, description: 'Unauthorized'),
|
||||||
new OA\Response(response: 403, description: 'Forbidden — admin role required'),
|
new OA\Response(response: 403, description: 'Forbidden — ROLE_ADMIN or ROLE_IMPORTER required'),
|
||||||
new OA\Response(
|
new OA\Response(
|
||||||
response: 422,
|
response: 422,
|
||||||
description: 'Validation error',
|
description: 'Validation error',
|
||||||
@@ -261,10 +261,17 @@ class BlogController extends BaseController
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
|
||||||
#[Route('/api/v1/blog', methods: ['POST'])]
|
#[Route('/api/v1/blog', methods: ['POST'])]
|
||||||
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
|
public function create(Request $request, #[CurrentUser] User $user): JsonResponse
|
||||||
{
|
{
|
||||||
|
// ROLE_IMPORTER هم مجاز است: خط تولید محتوا (content/ در کرالر) با همان کاربر
|
||||||
|
// سیستمیِ ایمپورت لاگین میکند و پیشنویس میسازد. پیشنویس هیچوقت منتشر
|
||||||
|
// نمیشود — تأیید (review) همچنان فقط ROLE_ADMIN است.
|
||||||
|
$isAdmin = $this->isGranted('ROLE_ADMIN');
|
||||||
|
if (!$isAdmin && !$this->isGranted('ROLE_IMPORTER')) {
|
||||||
|
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی به این منبع مجاز نیست', 403);
|
||||||
|
}
|
||||||
|
|
||||||
$data = json_decode($request->getContent(), true) ?? [];
|
$data = json_decode($request->getContent(), true) ?? [];
|
||||||
if ((isset($data['title']) && !is_string($data['title'])) || (isset($data['body']) && !is_string($data['body']))) {
|
if ((isset($data['title']) && !is_string($data['title'])) || (isset($data['body']) && !is_string($data['body']))) {
|
||||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'title و body باید رشته باشند', 422);
|
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'title و body باید رشته باشند', 422);
|
||||||
@@ -296,6 +303,13 @@ class BlogController extends BaseController
|
|||||||
// review_status: null means "manual admin post". The pipeline sends
|
// review_status: null means "manual admin post". The pipeline sends
|
||||||
// "pending_review" so the post enters the doctor review queue.
|
// "pending_review" so the post enters the doctor review queue.
|
||||||
if (!empty($data['review_status'])) $blog->setReviewStatus($data['review_status']);
|
if (!empty($data['review_status'])) $blog->setReviewStatus($data['review_status']);
|
||||||
|
// ROLE_IMPORTER هرگز نباید بتواند مستقیم منتشر کند: هرچه در بدنه بفرستد،
|
||||||
|
// خروجیاش پیشنویسِ در انتظار بررسی است. بدون این، مجازکردن کرالر روی این
|
||||||
|
// اندپوینت یعنی دور زدن کاملِ گیتِ تأیید پزشک.
|
||||||
|
if (!$isAdmin) {
|
||||||
|
$blog->setStatus(Blog::STATUS_DRAFT);
|
||||||
|
$blog->setReviewStatus(Blog::REVIEW_PENDING);
|
||||||
|
}
|
||||||
// نبودِ city_id یعنی سراسری — پس همیشه اعمال میشود، نه فقط وقتی مقدار دارد.
|
// نبودِ city_id یعنی سراسری — پس همیشه اعمال میشود، نه فقط وقتی مقدار دارد.
|
||||||
$blog->setCity($this->resolveCity($data['city_id'] ?? null));
|
$blog->setCity($this->resolveCity($data['city_id'] ?? null));
|
||||||
$this->blogWriter->applySeoFields($blog, $data);
|
$this->blogWriter->applySeoFields($blog, $data);
|
||||||
@@ -386,7 +400,7 @@ class BlogController extends BaseController
|
|||||||
|
|
||||||
#[OA\Get(
|
#[OA\Get(
|
||||||
path: '/api/v1/admin/blog/review-queue',
|
path: '/api/v1/admin/blog/review-queue',
|
||||||
summary: 'List blog drafts awaiting doctor review (admin only)',
|
summary: 'List blog drafts awaiting doctor review (ROLE_ADMIN or ROLE_IMPORTER)',
|
||||||
security: [['bearerAuth' => []]],
|
security: [['bearerAuth' => []]],
|
||||||
parameters: [
|
parameters: [
|
||||||
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
|
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
|
||||||
@@ -395,13 +409,18 @@ class BlogController extends BaseController
|
|||||||
responses: [
|
responses: [
|
||||||
new OA\Response(response: 200, description: 'Paginated review queue'),
|
new OA\Response(response: 200, description: 'Paginated review queue'),
|
||||||
new OA\Response(response: 401, description: 'Unauthorized'),
|
new OA\Response(response: 401, description: 'Unauthorized'),
|
||||||
new OA\Response(response: 403, description: 'Forbidden — admin role required'),
|
new OA\Response(response: 403, description: 'Forbidden — ROLE_ADMIN or ROLE_IMPORTER required'),
|
||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
#[IsGranted('ROLE_ADMIN')]
|
|
||||||
#[Route('/api/v1/admin/blog/review-queue', methods: ['GET'])]
|
#[Route('/api/v1/admin/blog/review-queue', methods: ['GET'])]
|
||||||
public function reviewQueue(Request $request): JsonResponse
|
public function reviewQueue(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
// خواندنی است، پس ROLE_IMPORTER هم مجاز است تا پنل کرالر صف را نشان دهد.
|
||||||
|
// تصمیم تأیید/رد (review) همچنان فقط ROLE_ADMIN.
|
||||||
|
if (!$this->isGranted('ROLE_ADMIN') && !$this->isGranted('ROLE_IMPORTER')) {
|
||||||
|
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی به این منبع مجاز نیست', 403);
|
||||||
|
}
|
||||||
|
|
||||||
$page = max(1, (int) $request->query->get('page', 1));
|
$page = max(1, (int) $request->query->get('page', 1));
|
||||||
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
|
$limit = min(50, max(1, (int) $request->query->get('limit', 20)));
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,46 @@ class BlogReviewGateTest extends ApiTestCase
|
|||||||
$this->assertSame(403, $this->responseCode());
|
$this->assertSame(403, $this->responseCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testReviewQueueIsReadableByImporter(): void
|
||||||
|
{
|
||||||
|
// پنل کرالر با کاربر سیستمیِ ROLE_IMPORTER لاگین میکند؛ بدون این، صف
|
||||||
|
// همیشه «review-queue failed (403)» میداد.
|
||||||
|
$importer = $this->createUser(['ROLE_IMPORTER']);
|
||||||
|
$this->authJson('GET', '/api/v1/admin/blog/review-queue', $importer);
|
||||||
|
$this->assertSame(200, $this->responseCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── who may create a draft ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testImporterCreatesDraftForcedIntoTheReviewGate(): void
|
||||||
|
{
|
||||||
|
$importer = $this->createUser(['ROLE_IMPORTER']);
|
||||||
|
|
||||||
|
$res = $this->authJson('POST', '/api/v1/blog', $importer, [
|
||||||
|
'title' => 'مقاله خط تولید ' . bin2hex(random_bytes(3)),
|
||||||
|
'body' => 'متن آزمایشی مقاله برای تست',
|
||||||
|
'topic_slug' => 'importer-' . bin2hex(random_bytes(4)),
|
||||||
|
// تلاش برای انتشار مستقیم — باید نادیده گرفته شود
|
||||||
|
'status' => Blog::STATUS_PUBLISHED,
|
||||||
|
'review_status' => Blog::REVIEW_APPROVED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(201, $this->responseCode());
|
||||||
|
$this->assertSame(Blog::STATUS_DRAFT, $res['data']['data']['status'],
|
||||||
|
'importer must never publish directly');
|
||||||
|
$this->assertSame(Blog::REVIEW_PENDING, $res['data']['data']['review_status'],
|
||||||
|
'importer draft must enter the doctor review gate');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPlainUserCannotCreateBlog(): void
|
||||||
|
{
|
||||||
|
$user = $this->createUser(['ROLE_USER']);
|
||||||
|
$this->authJson('POST', '/api/v1/blog', $user, [
|
||||||
|
'title' => 'عنوان', 'body' => 'متن آزمایشی مقاله برای تست',
|
||||||
|
]);
|
||||||
|
$this->assertSame(403, $this->responseCode());
|
||||||
|
}
|
||||||
|
|
||||||
// ── approve ───────────────────────────────────────────────────────────────
|
// ── approve ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function testApprovePublishesAndRecordsReviewer(): void
|
public function testApprovePublishesAndRecordsReviewer(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user