# 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/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": "
محتوای کامل...
", "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": "محتوای کامل مقاله...
", "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`). | ### 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": "محتوای جدید
", "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` | --- ## 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 |