15 KiB
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
{
"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رویblogsnullable است و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
{
"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)
{
"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 blogtoArray.
Response 201
{
"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)
{
"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 andrepresentation= their record (set server-side). city_idmust be one of the representative's coverage cities, else422. A post that isn't owned by the caller returns404(never leaked).- The request body accepts the same core + SEO + scheduling fields as the admin create.
- The blog
toArray/toListArrayincludesrepresentation({ uuid, name, domain }ornull).
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.
{
"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)
{ "decision": "approved", "publish": true }
{ "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
{ "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
{
"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 |