diff --git a/.claude/prompt/blog-tag-filter-and-facets.md b/.claude/prompt/blog-tag-filter-and-facets.md new file mode 100644 index 00000000..76189de2 --- /dev/null +++ b/.claude/prompt/blog-tag-filter-and-facets.md @@ -0,0 +1,343 @@ +# اصلاح فیلتر تگ بلاگ + endpoint واژگان تگ‌ها + +## پروژه + +`clinicpro` (backend) + +پرامپت همتا در سایت عمومی: `nobat724_front/.claude/prompt/blog-category-breadcrumb-and-filter.md` — +**اول این پرامپت اجرا شود**، چون فرانت به `tag` سالم و به endpoint جدید وابسته است. + +## زمینه + +سایت عمومی (`https://yasuj-nobat.ir/blogs`) بالای لیست مقاله‌ها ردیفی از چیپ‌های دسته‌بندی دارد و با +کلیک روی هرکدام `GET /api/v1/blogs?tag=` می‌زند. این فیلتر **برای هیچ تگ فارسی‌ای نتیجه +برنمی‌گرداند** — همیشه لیست خالی. علاوه بر آن، فرانت مجبور است واژگان چیپ‌ها را از خودِ لیست +مقاله‌ها استخراج کند، که ساختاراً ناقص است. + +## مشکل / هدف + +### ۱) فیلتر تگ همیشه صفر نتیجه می‌دهد + +`blogs.tags` ستون `#[ORM\Column(type: 'json')]` است. Doctrine `JsonType` مقدار را با +`json_encode($value)` **بدون** `JSON_UNESCAPED_UNICODE` می‌نویسد، پس در دیتابیس فرم escape‌شده +ذخیره می‌شود: + +``` +mysql> SELECT LEFT(tags,60) FROM blogs WHERE id=4; +["سلامت عمومی"] + +mysql> SELECT HEX(LEFT(tags,14)) FROM blogs WHERE id=4; +5B225C75303633335C7530363434 -- یعنی ["سل با بک‌اسلش تکی +``` + +ولی `BlogRepository::applyTagFilter` کاندید را با `JSON_UNESCAPED_UNICODE` می‌سازد، یعنی +`"سلامت عمومی"` خام. MariaDB در `JSON_CONTAINS` فرم `\uXXXX` را نرمال **نمی‌کند** و مقایسه سر +همان بایت‌ها انجام می‌شود: + +```sql +-- روی MariaDB 11.8.8 (همان نسخهٔ ddev) اجرا و تأیید شد: +SELECT JSON_CONTAINS('["سل..."]', '"سلامت عمومی"'); -- 0 ← وضعیت فعلی +SELECT JSON_CONTAINS('["سل..."]', '"سل..."'); -- 1 ← فرم درست +SELECT COUNT(*) FROM blogs + WHERE JSON_CONTAINS(tags, '"سلامت عمومی"')=1; -- 74 +``` + +تأیید روی محیط واقعی: + +```bash +curl -s "https://clinic-pro.ir/api/v1/blogs?page=1&limit=3&tag=چشم و گوش" +# {"success":true,"data":[],"meta":{"totalRecords":0,"totalPages":0,"currentPage":1,"limit":3}} +# در حالی که ۱۶ مقالهٔ منتشرشده تگ «چشم و گوش» دارند. +``` + +### ۲) واژگان تگ‌های بلاگ endpoint ندارد + +فرانت (`nobat724_front/components/blogs/title/index.js`) چیپ‌ها را این‌طور می‌سازد: +`request.getBlogs({page:1, limit:50})` و سپس `flatMap(b => b.tags)`. سقف `limit` در کنترلر ۵۰ است +و ۱۱۷ مقالهٔ منتشرشده وجود دارد → تگ‌های صفحات بعد هرگز چیپ نمی‌شوند. ضمناً `city_id` پاس داده +نمی‌شود، پس روی دامنهٔ شهری چیپی نمایش داده می‌شود که هیچ پستی روی آن دامنه ندارد. + +`GET /api/v1/tags` جایگزین نیست: آن، واژگانِ **Tag entity** است (`بیماری‌های قلبی`، `تغذیه`، +`دیابت`، `زیبایی`) و با تگ‌های واقعیِ بلاگ (`سلامت عمومی`، `چشم و گوش`، `زنان و بارداری`، …) +هم‌پوشانی ندارد؛ `blogs.tags` یک آرایهٔ JSON از رشته‌های آزاد است، نه FK به Tag. + +توزیع واقعیِ تگ‌ها (۱۱۷ پست منتشرشده روی prod، شمارش از سه صفحهٔ ۵۰تایی): + +``` +75 سلامت عمومی 5 آزمایش و تصویربرداری 3 پیشگیری و غربالگری 2 خون و سرطان +16 چشم و گوش 3 پوست، مو و زیبایی 2 مغز و اعصاب 1 قلب و عروق +15 زنان و بارداری 3 تغذیه و سبک زندگی 2 سلامت روان 1 جراحی و توان‌بخشی + 5 تنفس، آلرژی و عفونت 3 گوارش و کبد 3 غدد و متابولیسم 1 دارو و درمان +``` + +## معیار پذیرش + +- ✅ موفق: `GET /api/v1/blogs?tag=چشم و گوش` → `200` و `meta.totalRecords > 0`؛ همهٔ آیتم‌های + `data[]` در آرایهٔ `tags` خود دقیقاً `"چشم و گوش"` دارند. +- ✅ موفق: `GET /api/v1/blogs/tags` → `200` با `{ success, data: [{ name, count }, …] }` مرتب + نزولی بر اساس `count`؛ مجموع نام‌ها با تگ‌های واقعی مقالات منتشرشده یکی است. +- ✅ موفق: `GET /api/v1/blogs/tags?city_id=132` فقط تگ‌های مقالات همان شهر + مقالات سراسری را + برمی‌گرداند (همان قاعدهٔ `applyCityFilter`). +- ✅ موفق: `GET /api/v1/blogs?tag=چشم و گوش&city_id=132` هر دو فیلتر را هم‌زمان اعمال می‌کند و + `meta.totalRecords` با تعداد رکوردهای `data` در حالت تک‌صفحه‌ای هم‌خوان است (یعنی + `countPublished` هم همان فیلتر را دارد). +- ❌ خطا: `?tag=یک‌تگ‌ناموجود` → `200` با `data: []` و `meta.totalRecords: 0` (نه ۵۰۰، نه خطا). +- ❌ خطا: `?city_id=999999` (شهر ناموجود) در `/api/v1/blogs/tags` → `200` با `data: []`؛ + رفتار باید آینهٔ `GET /api/v1/blogs?city_id=999999` باشد، نه استثنا. +- ⚠️ مرزی: تگ فارسی با نیم‌فاصله (`تنفس، آلرژی و عفونت`) و تگ حاوی `_` یا `%` باید **دقیق** + match شود، نه wildcard — یعنی `?tag=سلامت` نباید مقالات `سلامت عمومی` را برگرداند. +- ⚠️ مرزی: مقاله‌ای با `tags: []` نباید در هیچ facet یا فیلتری ظاهر شود و نباید باعث ورودی + خالی/`null` در خروجی `/api/v1/blogs/tags` شود. +- ⚠️ مرزی: مقالات `draft`/`archived` نه در فیلتر نتیجه می‌دهند و نه تگشان در facet می‌آید. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Blog/Repository/BlogRepository.php` | `applyTagFilter` (باگ)، `findPublished`، `countPublished`، `applyCityFilter` | +| `src/Blog/Controller/BlogController.php` | `list()` (خواندن `tag`/`city_id`)، محل افزودن اکشن facet | +| `src/Blog/Entity/Blog.php` | `#[ORM\Column(type:'json')] private array $tags` (خط ۷۰-۷۱) | +| `src/Shared/Doctrine/JsonContains.php` | DQL function ثبت‌شده در `config/packages/doctrine.yaml` | +| `docs/api/blog.md` | سند endpointها — خط ۱۸ توضیح پارامتر `tag` | +| `tests/Blog/BlogCityScopeTest.php` | الگوی تست لیست عمومی (ساخت City/Blog، `listBy()`) | + +## وضعیت فعلی + +`src/Blog/Repository/BlogRepository.php:171-179` — کد واقعی: + +```php + private function applyTagFilter(\Doctrine\ORM\QueryBuilder $qb, ?string $tag): void + { + if ($tag === null || $tag === '') { + return; + } + // Blog.tags is a JSON array of tag names; match exact name membership. + $qb->andWhere('JSON_CONTAINS(b.tags, :tag) = 1') + ->setParameter('tag', json_encode($tag, JSON_UNESCAPED_UNICODE)); + } +``` + +`src/Blog/Controller/BlogController.php:87-102` — کد واقعی: + +```php + #[Route('/api/v1/blogs', methods: ['GET'])] + public function list(Request $request): JsonResponse + { + $page = max(1, (int) $request->query->get('page', 1)); + $limit = min(50, max(1, (int) $request->query->get('limit', 20))); + $tag = $request->query->get('tag') ?: null; + $cityId = $request->query->get('city_id') !== null + ? max(1, (int) $request->query->get('city_id')) + : null; + + $blogs = array_map( + fn(Blog $b) => $b->toListArray(), + $this->blogRepo->findPublished($page, $limit, $tag, $cityId) + ); + $total = $this->blogRepo->countPublished($tag, $cityId); + + return $this->paginated($blogs, $total, $page, $limit); + } +``` + +`src/Blog/Repository/BlogRepository.php:161-169` — فیلتر شهر که facet هم باید همان را رعایت کند: + +```php + private function applyCityFilter(\Doctrine\ORM\QueryBuilder $qb, ?int $cityId): void + { + if ($cityId === null) { + return; + } + + $qb->andWhere('b.city = :cityId OR b.city IS NULL') + ->setParameter('cityId', $cityId); + } +``` + +## وظایف + +### ۱. اصلاح `applyTagFilter` — تطبیق encoding کاندید با encoding ستون + +تنها تغییر لازم: کاندید باید با **همان** تنظیمی encode شود که Doctrine ستون را می‌نویسد، یعنی +`json_encode` بدون `JSON_UNESCAPED_UNICODE`. + +```php + private function applyTagFilter(\Doctrine\ORM\QueryBuilder $qb, ?string $tag): void + { + if ($tag === null || $tag === '') { + return; + } + // Doctrine's JsonType writes this column with plain json_encode, so Persian + // tags are stored \uXXXX-escaped. MariaDB's JSON_CONTAINS compares the two + // documents without normalizing those escapes, so the candidate must be + // escaped exactly the same way — JSON_UNESCAPED_UNICODE never matched. + $qb->andWhere('JSON_CONTAINS(b.tags, :tag) = 1') + ->setParameter('tag', json_encode($tag)); + } +``` + +هیچ migration، هیچ کلاس جدید و هیچ تغییری در `JsonContains` لازم نیست. + +**نحوه تست:** + +```bash +# ۱) تست SQL خام روی همان دیتابیس (تأیید فرضیه، قبل و بعد) +ddev exec mysql -uroot -proot db -e "SELECT COUNT(*) FROM blogs WHERE JSON_CONTAINS(tags, '\"\\\\u0686\\\\u0634\\\\u0645 \\\\u0648 \\\\u06af\\\\u0648\\\\u0634\')=1;" + +# ۲) endpoint واقعی — باید غیرصفر شود +curl -s "https://clinic-pro.ddev.site/api/v1/blogs?limit=5&tag=چشم و گوش" | python3 -m json.tool | head -20 + +# ۳) تگ ناموجود → لیست خالی، بدون خطا +curl -s "https://clinic-pro.ddev.site/api/v1/blogs?tag=nope" | python3 -m json.tool | tail -5 + +# ۴) پیشوند نباید match شود (مرزی) +curl -s "https://clinic-pro.ddev.site/api/v1/blogs?tag=سلامت" | python3 -m json.tool | tail -5 # totalRecords: 0 +``` + +### ۲. endpoint واژگان تگ‌های بلاگ: `GET /api/v1/blogs/tags` + +**چرا endpoint جدید (قاعدهٔ «اول بگرد، بعد توسعه بده، در آخر بساز»):** هیچ endpoint موجودی این +داده را نمی‌دهد — `GET /api/v1/tags` واژگان `Tag` entity است و با رشته‌های `blogs.tags` هم‌پوشانی +ندارد؛ `GET /api/v1/blogs` سقف `limit=50` دارد و استخراج سمت کلاینت روی ۱۱۷ رکورد ساختاراً ناقص +است و city scope هم نمی‌گیرد. + +متد repository — تگ‌ها با یک کوئری آرایه‌ای خوانده و در PHP شمرده می‌شوند (تعداد رکوردهای +منتشرشده کوچک است و DQL راهی برای unnest کردن آرایهٔ JSON ندارد): + +```php + /** + * Distinct tag names across published posts, with post counts, honouring the + * same city scope as the public list. Blog tags are free-form strings inside a + * JSON column, so they are counted in PHP — DQL cannot unnest a JSON array. + * + * @return list sorted by count DESC, then name + */ + public function tagFacets(?int $cityId = null): array + { + $qb = $this->createQueryBuilder('b') + ->select('b.tags') + ->where('b.status = :status') + ->setParameter('status', Blog::STATUS_PUBLISHED); + + $this->applyCityFilter($qb, $cityId); + + $counts = []; + foreach ($qb->getQuery()->getArrayResult() as $row) { + foreach ($row['tags'] ?? [] as $name) { + if (!is_string($name) || $name === '') { + continue; + } + $counts[$name] = ($counts[$name] ?? 0) + 1; + } + } + + arsort($counts); + + return array_map( + static fn(string $name, int $count) => ['name' => $name, 'count' => $count], + array_keys($counts), + array_values($counts) + ); + } +``` + +اکشن کنترلر — کنار `list()`، با همان قرارداد خواندن `city_id`: + +```php + #[OA\Get( + path: '/api/v1/blogs/tags', + summary: 'Distinct tag names of published posts with post counts', + parameters: [ + new OA\Parameter( + name: 'city_id', + in: 'query', + required: false, + description: 'Same scope as GET /api/v1/blogs: that city\'s posts plus nationwide posts.', + schema: new OA\Schema(type: 'integer') + ), + ], + responses: [ /* 200: { success, data: [{ name, count }] } */ ] + )] + #[Route('/api/v1/blogs/tags', methods: ['GET'])] + public function tags(Request $request): JsonResponse + { + $cityId = $request->query->get('city_id') !== null + ? max(1, (int) $request->query->get('city_id')) + : null; + + return $this->success($this->blogRepo->tagFacets($cityId)); + } +``` + +⚠️ مسیر `/api/v1/blogs/tags` باید در `config/packages/security.yaml` مثل بقیهٔ مسیرهای عمومی بلاگ +بدون احراز هویت در دسترس باشد؛ اگر الگوی موجود `^/api/v1/blogs` است تغییری لازم نیست — بررسی و +گزارش کن. + +⚠️ `$this->success($array)` پاسخ `{success, data:[...]}` می‌دهد. عمداً `['data' => …]` پاس نده +تا double-nesting (`data.data`) اتفاق نیفتد — پرامپت فرانت روی همین شکل `data[]` نوشته شده است. + +**نحوه تست:** + +```bash +ddev exec php bin/console cache:clear +ddev exec php bin/console debug:router | grep "blogs/tags" + +# سراسری +curl -s "https://clinic-pro.ddev.site/api/v1/blogs/tags" | python3 -m json.tool + +# محدود به یک شهر — زیرمجموعهٔ خروجی بالا باشد +curl -s "https://clinic-pro.ddev.site/api/v1/blogs/tags?city_id=132" | python3 -m json.tool + +# صحت شمارش: count یک تگ باید با totalRecords فیلترِ همان تگ برابر باشد +curl -s "https://clinic-pro.ddev.site/api/v1/blogs?limit=1&tag=چشم و گوش" | python3 -c "import sys,json;print(json.load(sys.stdin)['meta']['totalRecords'])" +``` + +### ۳. تست PHPUnit + +فایل جدید `tests/Blog/BlogTagFilterTest.php` با الگوی `tests/Blog/BlogCityScopeTest.php` +(ساخت `City`/`Province`، ساخت `Blog` منتشرشده، فراخوانی `GET /api/v1/blogs...`): + +- تگ فارسی: دو پست با `setTags(['چشم و گوش'])` و یکی با `setTags(['سلامت عمومی'])` → + `?tag=چشم و گوش` دقیقاً همان دو پست را برگرداند و `meta.totalRecords === 2` + (این تست با کد فعلی **باید قرمز شود** — رگرسیون‌گارد باگ). +- پیشوند match نکند: `?tag=چشم` → `totalRecords === 0`. +- تگ با `_` و `%` (`?tag=a_b` روی پستی با تگ `axb`) → `0`، یعنی wildcard نیست. +- پست `draft` با همان تگ → نه در فیلتر، نه در `/api/v1/blogs/tags`. +- `city_id` + `tag` هم‌زمان: پست شهر دیگر با همان تگ نباید بیاید؛ پست سراسری باید بیاید. +- `/api/v1/blogs/tags` بدون `city_id` و با `city_id`، شامل مرزیِ `tags: []`. + +**نحوه تست:** `ddev exec php bin/phpunit tests/Blog/BlogTagFilterTest.php` +و سپس کل دامنه: `ddev exec php bin/phpunit tests/Blog` + +### ۴. به‌روزرسانی `docs/api/blog.md` + +- خط ۱۸ (توضیح پارامتر `tag`): اضافه شود که match دقیق و case/format-sensitive روی نام تگ است و + wildcard نیست. +- بخش جدید برای `GET /api/v1/blogs/tags`: method/path/permission (عمومی)، پارامتر `city_id`، و + **JSON واقعیِ خروجی اجرای واقعی** (نه دست‌ساز)، به‌همراه `200` تنها status ممکن. +- یک جملهٔ کوتاه در بخش «نکات» دربارهٔ اینکه واژگان تگ بلاگ از `GET /api/v1/tags` (Tag entity) + جداست، تا کلاینت بعدی اشتباه نگیرد. + +## نکات مهم + +- **علت ریشه‌ای فقط encoding است، نه DQL و نه schema.** وسوسه نشو ستون را migrate کنی یا + `JsonContains` را بازنویسی کنی؛ داده سالم است و همان‌طور که Doctrine می‌نویسد خوانده می‌شود. +- **چرا `JSON_SEARCH` انتخاب نشد:** `JSON_SEARCH(tags,'one',:tag,NULL,'$[*]')` هم روی همین داده + جواب می‌دهد (تست شد: ۷۴ رکورد)، ولی آرگومان آن الگوی `LIKE` است و `_` و `%` را wildcard + می‌گیرد؛ برای فیلتر دسته‌بندی که باید دقیق باشد ریسک over-match دارد. `JSON_CONTAINS` با کاندید + هم‌encoding، هم دقیق است و هم DQL function موجود را استفاده می‌کند (بدون کد جدید). +- **مقاومت در برابر داده‌ی قدیمی:** اگر جایی رکوردی با unicode خام نوشته شده باشد (مثلاً import + با SQL خام) با این fix پیدا نمی‌شود. اگر تست نشان داد چنین رکوردهایی وجود دارند، شرط را به + `JSON_CONTAINS(b.tags, :tagEsc) = 1 OR JSON_CONTAINS(b.tags, :tagRaw) = 1` گسترش بده؛ در غیر + این صورت این شاخه اضافه نشود (abstraction بدون مصرف). بررسی: + `ddev exec mysql -uroot -proot db -e "SELECT COUNT(*) FROM blogs WHERE tags NOT LIKE '%\\\\\\\\u%' AND tags <> '[]';"` +- **`countPublished` و `findPublished` هر دو از `applyTagFilter` استفاده می‌کنند** — یک fix هر دو + را درست می‌کند؛ در تست حتماً هم `data` و هم `meta.totalRecords` را چک کن، چون ناهم‌خوانی این دو + در pagination سایت خودش را نشان می‌دهد. +- **کلاینت متأثر (cross-repo):** `nobat724_front` — `components/blogs/index.js` (پاس دادن + `params.tag`) و `components/blogs/title/index.js` (منبع چیپ‌ها). تغییر قرارداد در build آن‌ها + خطا نمی‌دهد؛ بعد از این پرامپت، پرامپت فرانت اجرا و رفتار واقعی روی + `http://yazd-nobat.localhost:3000/blogs` دستی بررسی شود. +- **کش:** لیست بلاگ در سایت با `next: { tags: ['blog-list'] }` کش می‌شود؛ endpoint جدید facet در + سمت کلاینت (`services/response.js`) صدا زده می‌شود و کش Next ندارد — نیازی به تغییر + `BlogCacheInvalidator` نیست، ولی اگر آن را به fetch سمت سرور بردی، tag `blog-list` را ثبت کن. diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 283acd11..cd5f7e04 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -37,7 +37,7 @@ security: # مجاز) و هم برای پنل مدیریت (توکن معتبر → احراز می‌شود تا management=1 کار کند) # سرویس می‌دهد. برای همین از الگوی زیر خارج شده‌اند. public_endpoints: - pattern: ^/(api/v1/altcha/(challenge|config)$|api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic/[^/]+/addresses$|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-booking-services/|api/v1/comments/|api/v1/rate/[^/]+$|api/v1/specialties|api/v1/blogs$|api/v1/tags$|api/v1/clinic-invitation/|api/v1/pre-registration$|api/v1/doctor/[^/]+/claim-info$) + pattern: ^/(api/v1/altcha/(challenge|config)$|api/v1/user/(send-code|verify-code|register|otp-login|reset-password)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic/[^/]+/addresses$|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-booking-services/|api/v1/comments/|api/v1/rate/[^/]+$|api/v1/specialties|api/v1/blogs(/tags)?$|api/v1/tags$|api/v1/clinic-invitation/|api/v1/pre-registration$|api/v1/doctor/[^/]+/claim-info$) stateless: true security: false @@ -75,6 +75,7 @@ security: methods: [GET] roles: PUBLIC_ACCESS - { path: ^/api/v1/blogs$, roles: PUBLIC_ACCESS } + - { path: ^/api/v1/blogs/tags$, methods: [GET], roles: PUBLIC_ACCESS } - path: '^/api/v1/blog/[^/]+$' methods: [GET] roles: PUBLIC_ACCESS diff --git a/docs/api/blog.md b/docs/api/blog.md index 4cd41865..2579ba82 100644 --- a/docs/api/blog.md +++ b/docs/api/blog.md @@ -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=` با همان `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. diff --git a/src/Blog/Controller/BlogController.php b/src/Blog/Controller/BlogController.php index 0852fab2..9e492278 100644 --- a/src/Blog/Controller/BlogController.php +++ b/src/Blog/Controller/BlogController.php @@ -105,6 +105,51 @@ class BlogController extends BaseController return $this->paginated($blogs, $total, $page, $limit); } + #[OA\Get( + path: '/api/v1/blogs/tags', + summary: 'Distinct tag names of published posts with post counts', + parameters: [ + new OA\Parameter( + name: 'city_id', + in: 'query', + required: false, + description: 'Same scope as GET /api/v1/blogs: that city\'s posts plus nationwide posts (city_id IS NULL). Omit to count every published post.', + schema: new OA\Schema(type: 'integer') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Tag vocabulary of published posts, most used first', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'name', type: 'string', example: 'چشم و گوش'), + new OA\Property(property: 'count', type: 'integer', example: 16), + ], + type: 'object' + ) + ), + ] + ) + ), + ] + )] + #[Route('/api/v1/blogs/tags', methods: ['GET'])] + public function tags(Request $request): JsonResponse + { + $cityId = $request->query->get('city_id') !== null + ? max(1, (int) $request->query->get('city_id')) + : null; + + return $this->success($this->blogRepo->tagFacets($cityId)); + } + #[OA\Get( path: '/api/v1/blog/{slug}', summary: 'Get a published blog post by slug or UUID', diff --git a/src/Blog/Repository/BlogRepository.php b/src/Blog/Repository/BlogRepository.php index acae7f99..96115f04 100644 --- a/src/Blog/Repository/BlogRepository.php +++ b/src/Blog/Repository/BlogRepository.php @@ -153,6 +153,42 @@ class BlogRepository extends ServiceEntityRepository return (int) $qb->getQuery()->getSingleScalarResult(); } + /** + * Distinct tag names across published posts with their post counts, honouring + * the same city scope as the public list. Blog tags are free-form strings in a + * JSON column, so they are counted in PHP — DQL cannot unnest a JSON array. + * + * @return list count DESC, then name ASC + */ + public function tagFacets(?int $cityId = null): array + { + $qb = $this->createQueryBuilder('b') + ->select('b.tags') + ->where('b.status = :status') + ->setParameter('status', Blog::STATUS_PUBLISHED); + + $this->applyCityFilter($qb, $cityId); + + $counts = []; + foreach ($qb->getQuery()->getArrayResult() as $row) { + foreach ($row['tags'] ?? [] as $name) { + if (!is_string($name) || $name === '') { + continue; + } + $counts[$name] = ($counts[$name] ?? 0) + 1; + } + } + + // Ties keep a stable, human-predictable order instead of insertion order. + uksort($counts, static fn(string $a, string $b) => [$counts[$b], $a] <=> [$counts[$a], $b]); + + return array_map( + static fn(string $name, int $count) => ['name' => $name, 'count' => $count], + array_keys($counts), + array_values($counts) + ); + } + /** * دامنهٔ یک شهر باید پست‌های همان شهر **و** پست‌های سراسری را ببیند — پست * سراسری (city NULL) روی همهٔ دامنه‌ها منتشر است، فقط canonicalش روی دامنهٔ اصلی @@ -174,8 +210,12 @@ class BlogRepository extends ServiceEntityRepository return; } // Blog.tags is a JSON array of tag names; match exact name membership. + // Doctrine's JsonType writes this column with plain json_encode, so Persian + // names are stored \uXXXX-escaped. MariaDB's JSON_CONTAINS compares the two + // documents without normalizing those escapes, so the candidate must be + // encoded identically — JSON_UNESCAPED_UNICODE never matched a single row. $qb->andWhere('JSON_CONTAINS(b.tags, :tag) = 1') - ->setParameter('tag', json_encode($tag, JSON_UNESCAPED_UNICODE)); + ->setParameter('tag', json_encode($tag)); } public function save(Blog $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); } diff --git a/tests/Blog/BlogTagFilterTest.php b/tests/Blog/BlogTagFilterTest.php new file mode 100644 index 00000000..25a9017f --- /dev/null +++ b/tests/Blog/BlogTagFilterTest.php @@ -0,0 +1,213 @@ + query return + * an empty list. These cases pin both the filter and the tag facet endpoint. + * + * db_test is never reset, so every case tags its fixtures with a random suffix and + * asserts only on its own rows. + */ +class BlogTagFilterTest extends ApiTestCase +{ + private function makeCity(string $name): City + { + $province = new Province($name); + $this->em->persist($province); + $city = new City($name, $province); + $this->em->persist($city); + + return $city; + } + + /** @param string[] $tags */ + private function makePost(string $title, array $tags, ?City $city = null, string $status = Blog::STATUS_PUBLISHED): Blog + { + $blog = new Blog($this->createUser(['ROLE_ADMIN']), $title, 'متن آزمایشی مقاله برای تست'); + $blog->setStatus($status)->setCity($city)->setTags($tags); + $this->em->persist($blog); + + return $blog; + } + + /** @return array{titles: string[], total: int} */ + private function listBy(string $query): array + { + $this->client->request('GET', '/api/v1/blogs?limit=50&' . $query); + $this->assertSame(200, $this->responseCode()); + $payload = json_decode($this->client->getResponse()->getContent(), true); + + return [ + 'titles' => array_column($payload['data'], 'title'), + 'total' => $payload['meta']['totalRecords'], + ]; + } + + /** @return array tag name => post count */ + private function facets(string $query = ''): array + { + $this->client->request('GET', '/api/v1/blogs/tags' . ($query !== '' ? '?' . $query : '')); + $this->assertSame(200, $this->responseCode()); + $payload = json_decode($this->client->getResponse()->getContent(), true); + + return array_column($payload['data'], 'count', 'name'); + } + + public function testPersianTagFilterReturnsMatchingPosts(): void + { + $suffix = bin2hex(random_bytes(4)); + $eye = "چشم و گوش-$suffix"; + $other = "سلامت عمومی-$suffix"; + + $this->makePost("هاله رنگی-$suffix", [$eye]); + $this->makePost("خشکی چشم-$suffix", [$eye, $other]); + $this->makePost("تغذیه-$suffix", [$other]); + $this->em->flush(); + + $result = $this->listBy('tag=' . rawurlencode($eye)); + + $this->assertSame(2, $result['total'], 'meta.totalRecords must honour the tag filter too'); + $this->assertContains("هاله رنگی-$suffix", $result['titles']); + $this->assertContains("خشکی چشم-$suffix", $result['titles']); + $this->assertNotContains("تغذیه-$suffix", $result['titles']); + } + + public function testUnknownTagReturnsEmptyListNotAnError(): void + { + $result = $this->listBy('tag=' . rawurlencode('برچسب-ناموجود-' . bin2hex(random_bytes(4)))); + + $this->assertSame(0, $result['total']); + $this->assertSame([], $result['titles']); + } + + public function testTagMatchIsExactNotAPrefix(): void + { + $suffix = bin2hex(random_bytes(4)); + $this->makePost("مقاله-$suffix", ["سلامت عمومی-$suffix"]); + $this->em->flush(); + + $this->assertSame(0, $this->listBy('tag=' . rawurlencode('سلامت'))['total']); + } + + public function testTagMatchDoesNotTreatUnderscoreOrPercentAsWildcard(): void + { + $suffix = bin2hex(random_bytes(4)); + $this->makePost("مقاله-$suffix", ["axb-$suffix"]); + $this->em->flush(); + + $this->assertSame(0, $this->listBy('tag=' . rawurlencode("a_b-$suffix"))['total']); + $this->assertSame(0, $this->listBy('tag=' . rawurlencode("%-$suffix"))['total']); + } + + public function testDraftPostsAreExcludedFromFilterAndFacets(): void + { + $suffix = bin2hex(random_bytes(4)); + $tag = "پیش‌نویس-$suffix"; + + $this->makePost("منتشرشده-$suffix", [$tag]); + $this->makePost("پیش‌نویس-$suffix", [$tag], null, Blog::STATUS_DRAFT); + $this->em->flush(); + + $this->assertSame(1, $this->listBy('tag=' . rawurlencode($tag))['total']); + $this->assertSame(1, $this->facets()[$tag] ?? 0); + } + + public function testTagAndCityFiltersApplyTogether(): void + { + $suffix = bin2hex(random_bytes(4)); + $tag = "چشم و گوش-$suffix"; + $yasuj = $this->makeCity('یاسوج'); + $tabriz = $this->makeCity('تبریز'); + + $this->makePost("یاسوجی-$suffix", [$tag], $yasuj); + $this->makePost("تبریزی-$suffix", [$tag], $tabriz); + $this->makePost("سراسری-$suffix", [$tag], null); + $this->em->flush(); + + $result = $this->listBy('tag=' . rawurlencode($tag) . '&city_id=' . $yasuj->getId()); + + $this->assertSame(2, $result['total']); + $this->assertContains("یاسوجی-$suffix", $result['titles']); + $this->assertContains("سراسری-$suffix", $result['titles'], 'nationwide posts stay visible on a city domain'); + $this->assertNotContains("تبریزی-$suffix", $result['titles']); + } + + public function testFacetsCountPublishedPostsPerTag(): void + { + $suffix = bin2hex(random_bytes(4)); + $eye = "چشم و گوش-$suffix"; + $heart = "قلب و عروق-$suffix"; + + $this->makePost("اول-$suffix", [$eye]); + $this->makePost("دوم-$suffix", [$eye, $heart]); + $this->em->flush(); + + $facets = $this->facets(); + + $this->assertSame(2, $facets[$eye] ?? 0); + $this->assertSame(1, $facets[$heart] ?? 0); + } + + public function testFacetsHonourCityScope(): void + { + $suffix = bin2hex(random_bytes(4)); + $tag = "چشم و گوش-$suffix"; + $yasuj = $this->makeCity('یاسوج'); + $tabriz = $this->makeCity('تبریز'); + + $this->makePost("یاسوجی-$suffix", [$tag], $yasuj); + $this->makePost("تبریزی-$suffix", [$tag], $tabriz); + $this->makePost("سراسری-$suffix", [$tag], null); + $this->em->flush(); + + $this->assertSame(3, $this->facets()[$tag] ?? 0, 'unscoped facets count every published post'); + $this->assertSame(2, $this->facets('city_id=' . $yasuj->getId())[$tag] ?? 0); + } + + /** + * Every facet count must equal the totalRecords of filtering by that same tag — + * otherwise a chip in the public site leads to an empty result page. + */ + public function testFacetCountMatchesFilteredTotal(): void + { + $suffix = bin2hex(random_bytes(4)); + $tag = "غدد و متابولیسم-$suffix"; + + $this->makePost("اول-$suffix", [$tag]); + $this->makePost("دوم-$suffix", [$tag]); + $this->makePost("سوم-$suffix", ["دیگر-$suffix"]); + $this->em->flush(); + + $this->assertSame( + $this->facets()[$tag] ?? 0, + $this->listBy('tag=' . rawurlencode($tag))['total'] + ); + } + + public function testPostsWithoutTagsProduceNoFacetEntry(): void + { + $suffix = bin2hex(random_bytes(4)); + $this->makePost("بدون برچسب-$suffix", []); + $this->em->flush(); + + $facets = $this->facets(); + + $this->assertArrayNotHasKey('', $facets, 'an empty tag must never become a facet'); + $this->assertNotContains(0, $facets, 'no facet may report a zero count'); + } + + public function testFacetsEndpointIsPublic(): void + { + $this->client->request('GET', '/api/v1/blogs/tags'); + + $this->assertSame(200, $this->responseCode(), 'the public site calls this without a token'); + } +}