From 1a9eda3576a366bddcaeb6fda877c45f1c143ced Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 24 Jul 2026 20:19:25 +0330 Subject: [PATCH] feat(blog): allow ROLE_IMPORTER to create drafts and access review queue --- docs/api/blog.md | 9 ++++-- src/Blog/Controller/BlogController.php | 31 ++++++++++++++++---- tests/Blog/BlogReviewGateTest.php | 40 ++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/docs/api/blog.md b/docs/api/blog.md index 3535d82c..937663b0 100644 --- a/docs/api/blog.md +++ b/docs/api/blog.md @@ -130,7 +130,12 @@ Get a single blog post by slug. 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`) ```json @@ -294,7 +299,7 @@ The content pipeline (`clinicpro-crawler/content/`) generates Persian health art 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 | Param | Type | Required | Default | Description | diff --git a/src/Blog/Controller/BlogController.php b/src/Blog/Controller/BlogController.php index 75918bd0..b7202666 100644 --- a/src/Blog/Controller/BlogController.php +++ b/src/Blog/Controller/BlogController.php @@ -205,7 +205,7 @@ class BlogController extends BaseController #[OA\Post( 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' => []]], requestBody: new OA\RequestBody( required: true, @@ -239,7 +239,7 @@ class BlogController extends BaseController ) ), 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( response: 422, description: 'Validation error', @@ -261,10 +261,17 @@ class BlogController extends BaseController ), ] )] - #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/blog', methods: ['POST'])] 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) ?? []; 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); @@ -296,6 +303,13 @@ class BlogController extends BaseController // review_status: null means "manual admin post". The pipeline sends // "pending_review" so the post enters the doctor review queue. 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 یعنی سراسری — پس همیشه اعمال می‌شود، نه فقط وقتی مقدار دارد. $blog->setCity($this->resolveCity($data['city_id'] ?? null)); $this->blogWriter->applySeoFields($blog, $data); @@ -386,7 +400,7 @@ class BlogController extends BaseController #[OA\Get( 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' => []]], parameters: [ 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: [ new OA\Response(response: 200, description: 'Paginated review queue'), 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'])] 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)); $limit = min(50, max(1, (int) $request->query->get('limit', 20))); diff --git a/tests/Blog/BlogReviewGateTest.php b/tests/Blog/BlogReviewGateTest.php index 6bb841b9..3871ba24 100644 --- a/tests/Blog/BlogReviewGateTest.php +++ b/tests/Blog/BlogReviewGateTest.php @@ -52,6 +52,46 @@ class BlogReviewGateTest extends ApiTestCase $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 ─────────────────────────────────────────────────────────────── public function testApprovePublishesAndRecordsReviewer(): void