Files
clinicpro/docs/api/blog.md
T
hamed 15abcb5c8a feat(blog): add admin endpoint for blog details and cache invalidation
- Implemented `adminDetail()` method in `BlogController` to retrieve blog posts of any status for admin editing.
- Introduced `BlogCacheInvalidator` service to handle cache invalidation after blog create/update/delete actions.
- Updated existing methods in `BlogController` and `RepresentationBlogController` to call cache invalidation on blog modifications.
- Enhanced `BlogFormPage` and `RepresentationBlogFormPage` to utilize the new admin endpoint for fetching blog data.
- Added tests for `BlogCacheInvalidator` to ensure proper functionality and error handling.
- Updated documentation to reflect new API endpoint and cache invalidation behavior.
2026-07-27 18:54:35 +03:30

22 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) 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

⚠️ پاسخ double-nested است (data.data) — کنترلر $this->success(['data' => $blog->toArray()]) برمی‌گرداند. کلاینت باید data?.data را بخواند.

شکل کامل، عیناً خروجی Blog::toArray():

{
  "success": true,
  "data": {
    "data": {
      "uuid": "...",
      "title": "آشنایی با بیماری دیابت",
      "slug": "ashnayi-ba-bimari-diabat",
      "summary": "خلاصه...",
      "body": "<p>محتوای کامل...</p>",
      "image_url": "/uploads/blogs/blog_xxx_cover.png",
      "tags": ["دیابت", "تغذیه"],
      "sources": [{ "url": "https://...", "title": "..." }],
      "status": "published",
      "review_status": null,
      "reviewer": null,
      "reviewed_at": null,
      "review_note": null,
      "topic_slug": null,
      "meta_title": null,
      "meta_description": null,
      "primary_keyword": null,
      "secondary_keywords": [],
      "faq": [{ "q": "...", "a": "..." }],
      "internal_links": [],
      "external_links": [],
      "reading_time": null,
      "canonical_url": null,
      "og_image": null,
      "scheduled_at": null,
      "representation": null,
      "author": { "uuid": "...", "name": "احمدی" },
      "city": null,
      "created_at": 1717000000,
      "updated_at": 1717100000
    }
  }
}

نکته: tags آرایهٔ رشته است (نه آبجکت {id,name})، تصویر در image_url (نه image) و نام نویسنده در author.name (نه real_name). نسخهٔ قبلی این سند شکل دیگری نشان می‌داد که با کد هم‌خوان نبود.

Errors

Code HTTP Description
ERR_NOT_FOUND_001 404 Blog not found, not published, or owned by another city (when city_id is sent)

GET /api/v1/admin/blog/{uuid}

Get one blog post of any status for the admin edit form.

Permission: ROLE_ADMIN

اندپوینت عمومی GET /api/v1/blog/{slug} هر پستِ غیر published را ۴۰۴ می‌کند، پس فرم ویرایش پنل ادمین نمی‌توانست پیش‌نویس و آرشیو را بارگذاری کند (فرم خالی بالا می‌آمد). این اندپوینت هیچ فیلتر status/city ندارد. اندپوینت عمومی عمداً دست‌نخورده ماند تا پاسخِ cache-شدهٔ آن وابسته به نقشِ فراخوان نشود.

Path Parameters

Param Type Description
uuid string UUID مقاله. الگوی اجباری [0-9a-fA-F-]{36} — بدون آن این route مسیر /api/v1/admin/blog/review-queue را می‌دزدید. فقط UUID؛ slug پذیرفته نمی‌شود.

Response 200 (خروجی واقعی curl روی پیش‌نویس)

{
  "success": true,
  "data": {
    "data": {
      "uuid": "56fd9a20-9594-4aa1-a651-346fa86720bd",
      "title": "rtertert",
      "slug": "نوبتدهی-آنلاین-کلینیک-از-صف-انتظار-تا-پروندهٔ-دیجیتال-56fd9a20",
      "summary": "ertet",
      "body": "<p>etetetet</p>",
      "image_url": null,
      "tags": [],
      "sources": [],
      "status": "draft",
      "review_status": null,
      "reviewer": null,
      "reviewed_at": null,
      "review_note": null,
      "topic_slug": null,
      "meta_title": null,
      "meta_description": null,
      "primary_keyword": null,
      "secondary_keywords": [],
      "faq": [
        { "q": "etrtert", "a": "retet" },
        { "q": "eertret", "a": "ertet" }
      ],
      "internal_links": [],
      "external_links": [],
      "reading_time": null,
      "canonical_url": null,
      "og_image": null,
      "scheduled_at": null,
      "representation": null,
      "author": { "uuid": "7464eba2-6754-4cea-bcde-83e47050e829", "name": "1402/12/17 12" },
      "city": null,
      "created_at": 1784827054,
      "updated_at": 1785157798
    }
  }
}

Errors

Code HTTP Description
ERR_AUTH_001 401 بدون توکن
ERR_FORBIDDEN_001 403 توکن معتبر ولی بدون ROLE_ADMIN
ERR_NOT_FOUND_001 404 مقاله با این UUID وجود ندارد

POST /api/v1/blog

Create a new blog post.

Permission: ROLE_ADMIN, or ROLE_IMPORTER (the content pipeline's system user).

A caller that is not ROLE_ADMIN always produces a review-gated draft: whatever status / review_status it sends, the post is stored as status=draft, review_status=pending_review. Approving/publishing stays ROLE_ADMIN-only via POST /api/v1/admin/blog/{uuid}/review.

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 or ROLE_IMPORTER (read-only; the crawler panel shows this queue).

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

باطل‌سازی کش سایت عمومی (webhook خروجی)

سایت عمومی (nobat724_front) پاسخ GET /api/v1/blog/{slug} را با next: { revalidate: 3600, tags: [...] } کش می‌کند. بدون باطل‌سازی، هر تغییر در پنل ادمین تا یک ساعت روی سایت دیده نمی‌شد.

App\Blog\Service\BlogCacheInvalidator بعد از هر نوشتن یک درخواست خروجی می‌فرستد:

نقطهٔ فراخوانی فایل
POST /api/v1/blog (create) BlogController::create
PATCH /api/v1/blog/{uuid} BlogController::update
POST /api/v1/admin/blog/{uuid}/review BlogController::review
DELETE /api/v1/blog/{uuid} BlogController::delete (قبل از remove)
POST/PATCH/DELETE /api/v1/representation/blog… RepresentationBlogController

قرارداد درخواست (خروجی — مصرف‌کننده nobat724_front)

POST {REVALIDATE_WEBHOOK_URL}
X-Revalidate-Secret: {REVALIDATE_WEBHOOK_SECRET}
Content-Type: application/json

{"tags":["blog-<slug>","blog-<uuid>","blog-list"]}

بدنهٔ واقعی یک PATCH (خروجی شنوندهٔ تست):

{"tags":["blog-نوبتدهی-آنلاین-کلینیک-از-صف-انتظار-تا-پروندهٔ-دیجیتال-56fd9a20","blog-56fd9a20-9594-4aa1-a651-346fa86720bd","blog-list"]}

پاسخ مورد انتظار سایت: 200 {"revalidated": true}؛ سکرت غلط 401؛ بدنهٔ بدون tags400.

متغیرهای محیطی

متغیر پیش‌فرض معنا
REVALIDATE_WEBHOOK_URL خالی آدرس webhook سایت. خالی = هیچ درخواستی ارسال نمی‌شود
REVALIDATE_WEBHOOK_SECRET خالی باید با REVALIDATE_SECRET سمت سایت یکی باشد. خالی = ارسال نمی‌شود

رفتار خطا — fail-open

timeout سه ثانیه است و هر خطای شبکه فقط با سطح warning لاگ می‌شود؛ ذخیره/حذف مقاله هرگز به خاطر شکست webhook شکست نمی‌خورد. تست‌شده: با URL غیرقابل‌دسترس، PATCH همچنان 200 برمی‌گرداند.