# 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 posts whose `tags` array contains this exact name are returned. The match is **exact, not a prefix and not a wildcard** — `?tag=سلامت` does not match `سلامت عمومی`, and `_` / `%` are literal characters. The vocabulary of valid values is `GET /api/v1/blogs/tags`. | | `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` Real output of `GET /api/v1/blogs?limit=1&tag=چشم و گوش`: ```json { "success": true, "data": [ { "uuid": "b0b23efa-c974-4dc4-a1a2-ea78d99b75af", "title": "تشخیص و نشانه‌های خشکی چشم مزمن با سوزش و خارش؛ ضرورت انجام سنجش تخصصی بینایی‌سنجی", "slug": "تشخیص-و-نشانههای-خشکی-چشم-مزمن-با-سوزش-و-خارش؛-ضرورت-انجام-سنجش-تخصصی-بیناییسنجی-b0b23efa", "summary": "خشکی چشم همراه با سوزش و خارش مزمن در یزد به دلیل شرایط محیطی اهمیت یافته است و تشخیص به موقع با سنجش تخصصی بینایی‌سنجی ضروری است.", "image_url": null, "tags": ["چشم و گوش"], "status": "published", "review_status": "approved", "reviewer": { "uuid": "7464eba2-6754-4cea-bcde-83e47050e829", "name": "ادمین" }, "topic_slug": "optometry-symptoms-132", "scheduled_at": null, "representation": null, "city": { "id": "132", "name": "یزد" }, "created_at": 1785236601 } ], "meta": { "totalRecords": 16, "totalPages": 16, "currentPage": 1, "limit": 1 } } ``` > `tags` آرایهٔ **رشته** است (نه آبجکت `{id,name}`) و تصویر در `image_url` می‌آید — نسخهٔ قبلی این نمونه شکل دیگری نشان می‌داد که با کد هم‌خوان نبود. #### چرا فیلتر `tag` قبلاً همیشه خالی برمی‌گشت ستون `blogs.tags` از نوع Doctrine `json` است و `JsonType` آن را با `json_encode` **بدون** `JSON_UNESCAPED_UNICODE` می‌نویسد؛ یعنی نام فارسی به شکل `["چشم و گوش"]` ذخیره می‌شود. `JSON_CONTAINS` در MariaDB این escapeها را نرمال نمی‌کند و مقایسه روی همان بایت‌ها انجام می‌شود، پس کاندیدِ فارسیِ خام هرگز match نمی‌شد. کاندید حالا با همان `json_encode` پیش‌فرض ساخته می‌شود. رگرسیون‌گارد: `tests/Blog/BlogTagFilterTest.php`. ### فیلد `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/blogs/tags` واژگان تگ‌های مقالات **منتشرشده** به‌همراه تعداد پست هر تگ. مصرف‌کننده: ردیف چیپ‌های دسته‌بندی در `/blogs` سایت عمومی. **Permission:** `PUBLIC` > این endpoint با `GET /api/v1/tags` یکی نیست. آن، واژگانِ `Tag` entity است (`دیابت`، `تغذیه`، …) و به مقاله‌ها وصل نیست؛ `blogs.tags` آرایه‌ای از رشته‌های آزاد است. برای فیلتر مقالات فقط مقادیر همین endpoint معتبرند. ### Query Parameters | Param | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `city_id` | integer | ❌ | — | همان scope ‏`GET /api/v1/blogs`: پست‌های آن شهر + پست‌های سراسری (`city_id IS NULL`). بدون آن، همهٔ پست‌های منتشرشده شمرده می‌شوند. | ### Response `200` مرتب‌سازی: `count` نزولی، سپس نام صعودی. تگ خالی و پست بدون تگ اصلاً وارد خروجی نمی‌شوند، پس هیچ ورودی‌ای `count: 0` ندارد. خروجی واقعی `GET /api/v1/blogs/tags?city_id=132`: ```json { "success": true, "data": [ { "name": "سلامت عمومی", "count": 4 }, { "name": "سلامت روان", "count": 1 }, { "name": "چشم و گوش", "count": 1 }, { "name": "گوارش و کبد", "count": 1 } ] } ``` - `count` هر تگ دقیقاً برابر `meta.totalRecords` در `GET /api/v1/blogs?tag=` با همان `city_id` است؛ یعنی هیچ چیپی به صفحهٔ خالی نمی‌رسد (تست: `testFacetCountMatchesFilteredTotal`). - `city_id` ناشناخته خطا نمی‌دهد — دقیقاً مثل `GET /api/v1/blogs?city_id=…` فقط پست‌های سراسری می‌مانند. - تنها status ممکن `200` است؛ ورودی نامعتبر `city_id` با `max(1, (int) …)` نرمال می‌شود. --- ## 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 any identifier it has ever been addressed by. **Permission:** `PUBLIC` ### Path Parameters | Param | Type | Description | |-------|------|-------------| | `slug` | string | Current slug, UUID, `topic_slug`, or an outdated slug ending in the post's 8-char uuid prefix. Resolution order: slug → uuid → topic_slug → uuid-prefix suffix. | ### چرا چند شناسه `slug` از عنوان ساخته می‌شود، پس ویرایش عنوان URL را عوض می‌کند و هر لینک منتشرشدهٔ قبلی ۴۰۴ می‌گیرد — Search Console همین ۴۰۴ها را روی `behbahan-nobat.ir` گزارش کرد. سه شناسهٔ پایدار می‌مانند: `uuid`، `topic_slug` پایپ‌لاین محتوا، و پسوند ۸ کاراکتریِ uuid که `Blog::generateSlug()` به انتهای هر اسلاگ می‌چسباند. پاسخ همیشه `slug` قطعیِ فعلی را برمی‌گرداند؛ کلاینت آن را با اسلاگ درخواستی مقایسه می‌کند و در صورت اختلاف ریدایرکت دائمی می‌زند (سایت عمومی همین کار را می‌کند). قواعدی که با این تغییر عوض **نشدند**: - پستِ غیر `published` با هیچ شناسه‌ای برنمی‌گردد — نه uuid، نه topic_slug. - فیلتر `city_id` بعد از resolve اعمال می‌شود؛ پستِ شهر دیگر همچنان ۴۰۴ است. - پسوندی که hex هشت‌کاراکتری نیست شناسه به حساب نمی‌آید (`some-old-title-zzzzzzzz` → ۴۰۴). تست: `tests/Blog/BlogIdentifierResolutionTest.php`. ### 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()`: ```json { "success": true, "data": { "data": { "uuid": "...", "title": "آشنایی با بیماری دیابت", "slug": "ashnayi-ba-bimari-diabat", "summary": "خلاصه...", "body": "

محتوای کامل...

", "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 روی پیش‌نویس) ```json { "success": true, "data": { "data": { "uuid": "56fd9a20-9594-4aa1-a651-346fa86720bd", "title": "rtertert", "slug": "نوبتدهی-آنلاین-کلینیک-از-صف-انتظار-تا-پروندهٔ-دیجیتال-56fd9a20", "summary": "ertet", "body": "

etetetet

", "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`) ```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 `` 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` ```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": "<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`. > **صف، متنِ مقاله را نمی‌دهد.** ردیف‌ها از `Blog::toListArray()` می‌آیند، پس `body`، `faq`، `sources` و فیلدهای SEO در آن‌ها نیست — ۱۵ متن کامل HTML در یک پاسخِ فهرست جایی ندارد. کلاینتی که می‌خواهد مقاله را کامل نشان دهد (مثل مودال بازبینیِ `/admin/blog-review`) باید هنگام باز شدن، `GET /api/v1/admin/blog/{uuid}` را صدا بزند. پاسخ آن double-nested است: `data.data`. ```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 | --- ## سیاست پاک‌سازی بدنه بدنهٔ مقاله در **لحظهٔ ذخیره** پاک‌سازی می‌شود، نه هنگام نمایش — هر چهار نقطهٔ ورود (`POST`/`PATCH` در `BlogController` و `RepresentationBlogController`) از `App\Blog\Service\BlogBodySanitizer` می‌گذرند. دلیلش چند مصرف‌کننده بودنِ بدنه است: پنل ادمین، سایت عمومی و فید. با پاک‌سازی در لایهٔ نمایش، هر مصرف‌کنندهٔ تازه دوباره آسیب‌پذیر شروع می‌کرد. سیاست در `config/packages/html_sanitizer.yaml` است. سه قاعده‌ای که رفتار قابل‌مشاهده دارند: - `<script>`, `<style>`, `<iframe>`, `<object>`, `<embed>`, `<form>`, `<input>`, `<button>`, `<noscript>` با محتوایشان **حذف** می‌شوند (`drop` نه `block`) — وگرنه `<script>alert(1)</script>` به متنِ `alert(1)` تبدیل می‌شد. - روی هر `<a>` مقدار `rel="noopener noreferrer"` **تحمیل** می‌شود؛ طرح‌های مجاز فقط `http`, `https`, `mailto`. - `<table>` سه attributeِ ظاهریِ قدیمی را نگه می‌دارد — `border`, `cellpadding`, `cellspacing` — ولی `style` را از **هر** عنصری حذف می‌کند. ظاهرِ جدولِ بدنهٔ مقاله در پنل از `.blog-body` در `assets/admin/styles.css` می‌آید. بدنه‌ای که پس از پاک‌سازی خالی شود `422` می‌گیرد، نه اینکه خالی ذخیره شود. ### پاک‌سازی مقاله‌های قدیمی مقاله‌هایی که پیش از این سیاست ذخیره شده‌اند با یک دستور به همان وضع می‌رسند: ```bash ddev exec php bin/console app:blog:sanitize-bodies --dry-run # گزارش، بدون نوشتن ddev exec php bin/console app:blog:sanitize-bodies # اعمال ddev exec php bin/console app:blog:sanitize-bodies --show=42 # قبل/بعدِ یک مقاله ``` گزارشِ dry-run تغییرها را تفکیک می‌کند: «سخت‌سازی» یعنی فقط `rel` اضافه یا entity decode شده، و «حذفِ تگ یا attribute غیرمجاز» یعنی آن مقاله markupی داشته که سیاست نمی‌پذیرد. بدونِ این تفکیک، عددِ کلِ تغییرات گمراه‌کننده است — در اجرای ۲۰۲۶-۰۸-۰۸ از ۴۲۶ مقالهٔ تغییریافته، ۳۹۸ فقط سخت‌سازی بودند. دستور idempotent است: اجرای دوم صفر تغییر گزارش می‌دهد. --- ## باطل‌سازی کش سایت عمومی (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` (خروجی شنوندهٔ تست): ```json {"tags":["blog-نوبتدهی-آنلاین-کلینیک-از-صف-انتظار-تا-پروندهٔ-دیجیتال-56fd9a20","blog-56fd9a20-9594-4aa1-a651-346fa86720bd","blog-list"]} ``` پاسخ مورد انتظار سایت: `200 {"revalidated": true}`؛ سکرت غلط `401`؛ بدنهٔ بدون `tags` → `400`. ### متغیرهای محیطی | متغیر | پیش‌فرض | معنا | |---|---|---| | `REVALIDATE_WEBHOOK_URL` | خالی | آدرس webhook سایت. **خالی = هیچ درخواستی ارسال نمی‌شود** | | `REVALIDATE_WEBHOOK_SECRET` | خالی | باید با `REVALIDATE_SECRET` سمت سایت یکی باشد. خالی = ارسال نمی‌شود | ### رفتار خطا — fail-open timeout سه ثانیه است و هر خطای شبکه فقط با سطح `warning` لاگ می‌شود؛ **ذخیره/حذف مقاله هرگز به خاطر شکست webhook شکست نمی‌خورد.** تست‌شده: با URL غیرقابل‌دسترس، `PATCH` همچنان `200` برمی‌گرداند.