Files
clinicpro/docs/api/blog.md
T
hamed 47323daa27 feat: add RichTextEditor component for rich text editing in articles
feat: create SanitizeBlogBodiesCommand to clean existing blog bodies according to current HTML sanitization policies

test: add AppointmentTreatmentSessionLinkTest to ensure appointment booking functionality works correctly with treatment session links
2026-08-08 11:40:17 +03:30

668 lines
29 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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=<name>` با همان `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 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()`:
```json
{
"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 روی پیش‌نویس)
```json
{
"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`)
```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`
```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` برمی‌گرداند.