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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user