# 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`) or UUID | ### Query Parameters | Param | Type | Required | Description | |-------|------|----------|-------------| | `city_id` | int | ❌ | Domain scope of the caller. Same rule as `GET /api/v1/blogs?city_id=…`: the post is returned only when it belongs to this city **or** is nationwide (`city IS NULL`). A post owned by another city — including a post assigned to the root record `نوبت 724` (id `600`) — returns **404**. Omit on the main domain (`nobat724.com`) to read any post. | > بدون این پارامتر، پستی که از لیستِ یک دامنه فیلتر شده بود همچنان با URL مستقیم روی همان دامنه ۲۰۰ میگرفت و یک محتوا روی چند دامنه تکرار میشد. سایت عمومی (`nobat724_front`) روی دامنههای شهری همیشه `city_id` را میفرستد. ### 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, not published, or owned by another city (when `city_id` is sent) | --- ## 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`). | | `meta_title` | string | ❌ | SEO `محتوای جدید
", "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 |