# اصلاح فیلتر تگ بلاگ + 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=<name>` می‌زند. این فیلتر **برای هیچ تگ فارسی‌ای نتیجه
برنمی‌گرداند** — همیشه لیست خالی. علاوه بر آن، فرانت مجبور است واژگان چیپ‌ها را از خودِ لیست
مقاله‌ها استخراج کند، که ساختاراً ناقص است.

## مشکل / هدف

### ۱) فیلتر تگ همیشه صفر نتیجه می‌دهد

`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<array{name: string, count: int}> 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` را ثبت کن.
