feat(blog): implement medical review gate for blog posts

- Added new fields to the Blog entity: sources, review_status, reviewer, reviewed_at, review_note, and topic_slug.
- Created API endpoints for reviewing blog posts: GET /api/v1/admin/blog/review-queue and POST /api/v1/admin/blog/{uuid}/review.
- Updated BlogController to handle review logic, including approval and rejection of posts.
- Introduced BlogReviewPage component for admin interface to manage blog reviews.
- Added migration to update the database schema for new fields.
- Implemented tests for review queue functionality and review decision handling.
This commit is contained in:
hamed
2026-07-23 21:01:58 +03:30
parent 2abf915f95
commit 14730e43ce
10 changed files with 746 additions and 0 deletions
+102
View File
@@ -127,6 +127,9 @@ Create a new blog post.
| `status` | string | ❌ | `"draft"` (default) or `"published"` |
| `image_url` | string | ❌ | Cover image path returned by the upload endpoint |
| `city_id` | integer\|null | ❌ | City this post belongs to. **Omitting it, or sending `null`/`0`, creates a nationwide post.** An unknown city id is rejected with `422`. |
| `sources` | array | ❌ | E-E-A-T source list. Each item `{ "url": "...", "title": "..." }`. Used by the content pipeline; shown on the published post. |
| `review_status` | string\|null | ❌ | Medical-review gate. Omit/`null` = manual admin post (no review). The content pipeline sends `"pending_review"` so the post enters the doctor review queue (`GET /api/v1/admin/blog/review-queue`). |
| `topic_slug` | string | ❌ | Stable pipeline topic key `(specialty, angle)`, **unique**. Makes creation **idempotent**: posting the same `topic_slug` again returns the existing post with HTTP **200** (not a duplicate `201`). |
### Response `201`
```json
@@ -202,6 +205,105 @@ Updated blog object.
---
## Medical-review gate
The content pipeline (`clinicpro-crawler/content/`) generates Persian health articles as **drafts** (`status=draft`, `review_status=pending_review`). A doctor/admin then approves or rejects each one before it goes public. The reviewer's identity is stored and shown on the post — the E-E-A-T signal for YMYL content. `review_status=null` posts (created manually by an admin) are outside this gate.
`review_status` values:
| Value | Meaning |
|-------|---------|
| `null` | Manual admin post — never entered the review workflow |
| `pending_review` | Awaiting a doctor's decision (in the queue) |
| `approved` | Reviewed and approved (usually also `status=published`) |
| `rejected` | Reviewed and rejected — stays a draft |
---
## GET `/api/v1/admin/blog/review-queue`
List blog drafts awaiting review, newest first.
**Permission:** `ROLE_ADMIN`
### Query Parameters
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | integer | ❌ | 1 | Page number |
| `limit` | integer | ❌ | 20 (max 50) | Items per page |
### Response `200`
Paginated (`{ data:[...], meta:{ totalRecords, totalPages, currentPage } }`). Each item is the blog list shape plus `review_status`, `reviewer` (`{ uuid, name }` or `null`), and `topic_slug`.
```json
{
"success": true,
"data": [
{
"uuid": "...",
"title": "علائم سکته قلبی که نباید نادیده بگیرید",
"slug": "...",
"summary": "...",
"status": "draft",
"review_status": "pending_review",
"reviewer": null,
"topic_slug": "cardiology-heart-attack-symptoms",
"city": null,
"created_at": 1769000000
}
],
"meta": { "totalRecords": 3, "totalPages": 1, "currentPage": 1 }
}
```
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
---
## POST `/api/v1/admin/blog/{uuid}/review`
Record a doctor's review decision. Approving publishes the post by default.
**Permission:** `ROLE_ADMIN`
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | string (UUID) | Blog UUID |
### Request Body (`application/json`)
```json
{ "decision": "approved", "publish": true }
```
```json
{ "decision": "rejected", "note": "ادعاهای پزشکی بدون منبع کافی" }
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `decision` | string | ✅ | `"approved"` or `"rejected"` |
| `note` | string | ⚠️ | Reviewer note. **Required when `decision=rejected`.** |
| `publish` | boolean | ❌ | On approval, publish immediately. Default `true`. `false` keeps it a draft. |
On **approve**: `review_status=approved`, `reviewer`/`reviewed_at` set, and `status=published` unless `publish:false`. On **reject**: `review_status=rejected`, note stored, `status` stays `draft`.
### Response `200`
Updated blog object (full `toArray`, including `reviewer`, `reviewed_at`, `review_note`).
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_NOT_FOUND_001` | 404 | Blog not found |
| `ERR_VALIDATION_002` | 422 | Invalid `decision`, or `rejected` without a `note` |
---
## DELETE `/api/v1/blog/{uuid}`
Delete a blog post.