Files
clinicpro/docs/api/blog.md
T

424 lines
15 KiB
Markdown

# Blog API
> **Prefix:** `/api/v1/blog`, `/api/v1/blogs`
---
## GET `/api/v1/blogs`
List published blog posts.
**Permission:** `PUBLIC`
### Query Parameters
| Param | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `page` | integer | ❌ | 1 | Page number |
| `limit` | integer | ❌ | 20 | Items per page |
| `tag` | string | ❌ | — | Filter by exact tag **name** (e.g. `?tag=دیابت`). Blog tags are stored as a JSON array of names; only blogs whose `tags` array contains this exact name are returned. |
| `city_id` | integer | ❌ | — | Scope to one city. Returns that city's posts **plus every nationwide post** (`city_id IS NULL`). Omit it to return all published posts regardless of city. |
### Response `200`
```json
{
"success": true,
"data": [
{
"uuid": "...",
"title": "آشنایی با بیماری دیابت",
"slug": "ashnayi-ba-bimari-diabat",
"summary": "خلاصه مطلب...",
"image": "https://...",
"author": { "uuid": "...", "real_name": "احمدی" },
"tags": [{ "id": 1, "name": "دیابت" }],
"status": "published",
"city": { "id": "123", "name": "یاسوج" },
"created_at": 1717000000
}
],
"meta": {
"totalRecords": 25,
"totalPages": 2,
"currentPage": 1,
"limit": 20
}
}
```
### فیلد `city` — شهر پست
`city` در پاسخ لیست و جزئیات وجود دارد و دو حالت دارد:
| مقدار | معنا |
|-------|------|
| `{ "id": "123", "name": "یاسوج" }` | پست **شهری** — به آن شهر تعلق دارد |
| `null` | پست **سراسری** — حالت دائمی و معتبر، نه «تنظیم‌نشده» |
- `city_id` روی `blogs` **nullable** است و `NULL` معنای دائمی «سراسری» دارد. رکوردهای قبل از این تغییر همگی سراسری شدند.
- حذف شهر پست را حذف نمی‌کند (`ON DELETE SET NULL`) — پست سراسری می‌شود.
- پست سراسری روی **همهٔ** دامنه‌های شهری در لیست دیده می‌شود؛ فقط canonical آن روی دامنهٔ اصلی می‌نشیند. به همین دلیل `city_id=X` هم پست‌های شهر X و هم پست‌های سراسری را برمی‌گرداند — نه فقط شهر X.
> 🔗 مصرف‌کننده: سایت عمومی چند-دامنه‌ای (`nobat724_front`) با همین فیلد تصمیم می‌گیرد پست را روی دامنهٔ شهر canonical کند یا روی دامنهٔ اصلی، و در کدام sitemap بگذارد. تغییر معنای `null` قرارداد آن را می‌شکند.
---
## 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 a single blog post by slug.
**Permission:** `PUBLIC`
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `slug` | string | URL slug (e.g. `ashnayi-ba-bimari-diabat`) |
### Response `200`
```json
{
"success": true,
"data": {
"uuid": "...",
"title": "آشنایی با بیماری دیابت",
"slug": "ashnayi-ba-bimari-diabat",
"summary": "خلاصه...",
"body": "<p>محتوای کامل...</p>",
"image": "https://...",
"author": { "uuid": "...", "real_name": "احمدی" },
"tags": [{ "id": 1, "name": "دیابت" }],
"status": "published",
"created_at": 1717000000,
"updated_at": 1717100000
}
}
```
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | Blog not found or not published |
---
## POST `/api/v1/blog`
Create a new blog post.
**Permission:** `ROLE_ADMIN`
### Request Body (`application/json`)
```json
{
"title": "آشنایی با بیماری دیابت",
"body": "<p>محتوای کامل مقاله...</p>",
"summary": "خلاصه کوتاه از مقاله",
"tags": [1, 2],
"status": "draft",
"image_url": "/uploads/blogs/blog_abc.jpg"
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `title` | string | ✅ | Post title (slug auto-generated) |
| `body` | string | ✅ | Full HTML body (from the admin CKEditor) |
| `summary` | string | ❌ | Short excerpt |
| `tags` | integer[] | ❌ | Array of tag IDs |
| `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`). |
| `meta_title` | string | ❌ | SEO `<title>` override (falls back to `title`). |
| `meta_description` | string | ❌ | SEO meta description. |
| `primary_keyword` | string | ❌ | Primary target keyword. |
| `secondary_keywords` | string[] | ❌ | Secondary keywords (empty entries dropped). |
| `faq` | array | ❌ | `[{ "q": "...", "a": "..." }]` — rendered as FAQPage JSON-LD on the site. Malformed entries dropped. |
| `internal_links` / `external_links` | array | ❌ | Link suggestions, e.g. `["https://..."]` or `[{ "url", "anchor" }]`. |
| `reading_time` | integer | ❌ | Estimated minutes. |
| `canonical_url` | string | ❌ | Canonical override. |
| `og_image` | string | ❌ | Open Graph image URL. |
| `scheduled_at` | integer | ❌ | Unix timestamp for **automatic publish**. Omit for manual publishing. A scheduled post publishes only once `review_status = approved` (or `null`) — the review gate wins. |
> The same SEO/scheduling fields are accepted on `PATCH /api/v1/blog/{uuid}` (omitted keys untouched). All are returned in the blog `toArray`.
### Response `201`
```json
{
"success": true,
"data": {
"uuid": "blog-uuid-...",
"title": "آشنایی با بیماری دیابت",
"slug": "ashnayi-ba-bimari-diabat",
"summary": "...",
"body": "...",
"image": null,
"tags": [],
"status": "draft",
"created_at": 1717000000
}
}
```
**Blog Status Values:**
| Value | Description |
|-------|-------------|
| `draft` | Not visible to public |
| `published` | Visible in public listing |
| `archived` | Hidden from listing |
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_VALIDATION_002` | 422 | Missing required fields |
---
## PATCH `/api/v1/blog/{uuid}`
Update a blog post.
**Permission:** `ROLE_ADMIN`
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | string (UUID) | Blog UUID |
### Request Body (`application/json`)
```json
{
"title": "عنوان جدید",
"body": "<p>محتوای جدید</p>",
"summary": "خلاصه جدید",
"tags": [1, 3],
"status": "published",
"image_url": "/uploads/blogs/blog_abc.jpg"
}
```
All fields optional. Send `image_url: ""` to clear the cover image.
`city_id` follows PATCH semantics: **omit it and the post's city is left untouched**; send `null` (or `0`) to turn the post into a nationwide one; send a city id to move it to that city.
### Response `200`
Updated blog object.
### 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 | Unknown `city_id` |
---
## Representative blog (scoped to one representative)
A representative (`ROLE_REPRESENTATION`) manages their own posts for their own domain. They see and edit **only their own** posts, and may target **only cities within their coverage** (`representation.cities`). Admin CRUD (`/api/v1/blog*`) is unaffected — an admin still sees everything.
| Route | Method | Path | Permission |
|-------|--------|------|------------|
| list own | GET | `/api/v1/representation/blogs` | `ROLE_REPRESENTATION` (`page`,`limit`,`status`) |
| get own | GET | `/api/v1/representation/blog/{uuid}` | `ROLE_REPRESENTATION` |
| create | POST | `/api/v1/representation/blog` | `ROLE_REPRESENTATION` |
| update own | PATCH | `/api/v1/representation/blog/{uuid}` | `ROLE_REPRESENTATION` |
| delete own | DELETE | `/api/v1/representation/blog/{uuid}` | `ROLE_REPRESENTATION` |
- On create, `author` = the representative's user and `representation` = their record (set server-side).
- `city_id` **must** be one of the representative's coverage cities, else `422`. A post that isn't owned by the caller returns `404` (never leaked).
- The request body accepts the same core + SEO + scheduling fields as the admin create.
- The blog `toArray`/`toListArray` includes `representation` (`{ uuid, name, domain }` or `null`).
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_006` | 403 | Not a representative / no representative profile |
| `ERR_NOT_FOUND_001` | 404 | Post not found or not owned by the caller |
| `ERR_VALIDATION_002` | 422 | Missing title/body, unknown city, or city outside coverage |
---
## Scheduled publishing
`scheduled_at` (unix) drives automatic publishing. A scheduler task (`PublishScheduledBlogsMessage`, every 1 minute via `symfony/scheduler`, consumed by `messenger:consume scheduler_default`) publishes every `draft` whose `scheduled_at <= now` **and** whose `review_status` is `null` or `approved`. A `pending_review` post is never auto-published — the medical-review gate wins over the schedule.
---
## 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.
**Permission:** `ROLE_ADMIN`
### Response `200`
```json
{ "success": true, "data": { "message": "مقاله حذف شد" } }
```
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_NOT_FOUND_001` | 404 | Blog not found |
---
## POST `/file/upload/clinic_pro/blog/field_image`
Upload blog post header image.
**Permission:** `ROLE_ADMIN`
### Request
`Content-Type: multipart/form-data`
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `file` | binary | ✅ | Image (max 5MB) |
| `blog_uuid` | string (UUID) | ❌ | Auto-associate with blog post |
### Response `200`
```json
{
"success": true,
"data": {
"url": "https://clinic-pro.ddev.site/uploads/blog/post_abc.jpg",
"uuid": "...",
"filename": "post_abc.jpg",
"filemime": "image/jpeg",
"filesize": 204800
}
}
```
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_AUTH_006` | 403 | Not admin |
| `ERR_FILE_001` | 422 | Invalid file type |