- Fix tag filtering to correctly match Persian tags by adjusting JSON encoding in the applyTagFilter method. - Add new endpoint GET /api/v1/blogs/tags to retrieve distinct tag names and their counts for published posts, respecting city scope. - Update API documentation to reflect changes in tag filtering and the new tags endpoint. - Create BlogTagFilterTest to ensure correct functionality of tag filtering and facets, including edge cases for Persian tags and city filtering.
625 lines
26 KiB
Markdown
625 lines
26 KiB
Markdown
# 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`.
|
||
|
||
```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 |
|
||
|
||
---
|
||
|
||
## باطلسازی کش سایت عمومی (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` برمیگرداند.
|