Files
clinicpro/docs/api/blog.md
T

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 روی 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

{
  "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 blog toArray.

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 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.

{
  "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