feat(blog): implement tag filtering and facets endpoint
- 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.
This commit is contained in:
+63
-13
@@ -15,36 +15,49 @@ List published blog posts.
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `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. |
|
||||
| `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": "...",
|
||||
"title": "آشنایی با بیماری دیابت",
|
||||
"slug": "ashnayi-ba-bimari-diabat",
|
||||
"summary": "خلاصه مطلب...",
|
||||
"image": "https://...",
|
||||
"author": { "uuid": "...", "real_name": "احمدی" },
|
||||
"tags": [{ "id": 1, "name": "دیابت" }],
|
||||
"uuid": "b0b23efa-c974-4dc4-a1a2-ea78d99b75af",
|
||||
"title": "تشخیص و نشانههای خشکی چشم مزمن با سوزش و خارش؛ ضرورت انجام سنجش تخصصی بیناییسنجی",
|
||||
"slug": "تشخیص-و-نشانههای-خشکی-چشم-مزمن-با-سوزش-و-خارش؛-ضرورت-انجام-سنجش-تخصصی-بیناییسنجی-b0b23efa",
|
||||
"summary": "خشکی چشم همراه با سوزش و خارش مزمن در یزد به دلیل شرایط محیطی اهمیت یافته است و تشخیص به موقع با سنجش تخصصی بیناییسنجی ضروری است.",
|
||||
"image_url": null,
|
||||
"tags": ["چشم و گوش"],
|
||||
"status": "published",
|
||||
"city": { "id": "123", "name": "یاسوج" },
|
||||
"created_at": 1717000000
|
||||
"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": 25,
|
||||
"totalPages": 2,
|
||||
"totalRecords": 16,
|
||||
"totalPages": 16,
|
||||
"currentPage": 1,
|
||||
"limit": 20
|
||||
"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` در پاسخ لیست و جزئیات وجود دارد و دو حالت دارد:
|
||||
@@ -62,6 +75,43 @@ List published blog posts.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
Reference in New Issue
Block a user