feat(doctors): search every specialty a doctor has, and expose the tree

`GET /api/v1/doctors` could not answer either question the public search box
asks. Typing a specialty name returned nothing, because `name` only matched
`d.name`. And `specialty_id` matched one id exactly, so a parent group only
found doctors who happened to carry the parent — which they usually do, but
only as a side effect of `expandWithAncestors` running on save. A doctor
imported through any other path has no denormalised parent, and a search
guarantee resting on a save-time side effect is not a guarantee.

`expandWithDescendants` mirrors the existing ancestor walk over the same cached
parentMap, so no extra query. It deliberately keeps unknown ids instead of
dropping them like its mirror does: the result feeds an `IN (...)`, and an empty
array turns the filter into a no-op that returns every doctor — an unknown id
must mean "nothing", never "everything".

Both specialty filters use their own EXISTS alias rather than the shared `s`
join. Two conditions on one alias force a single join row to satisfy both, so a
doctor filtered by specialty A while searching the name of specialty B was
silently dropped. Verified by reverting to the shared alias and watching
testFilterOnOneSpecialtyWhileSearchingTheNameOfAnother fail.

toListArray now carries specialties[].parent_id so a client can tell the main
specialty from a sub-specialty instead of printing all of them. It is a string,
matching toDetailArray and the sibling `id` key — one concept should not have
two types across two endpoints. Reading the id off the parent proxy costs no
query; measured 6→11 queries with four more doctors both with and without the
field. That growth is a pre-existing N+1 (findWithFilters does not fetch-join
specialties, unlike findByClinic) and is left untouched here.

Also drops the phantom `search` parameter from the OpenAPI annotation — it was
advertised but never read, so a client sending it got an unfiltered list — and
documents the six live parameters that were missing.

Note for deploy: DoctorRepository gained a constructor argument, so a stale
container fails with ArgumentCountError until cache:clear runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-08-08 16:44:32 +03:30
co-authored by Claude Opus 5
parent 08a344c99d
commit 2da5b5188c
9 changed files with 808 additions and 29 deletions
+33 -19
View File
@@ -13,33 +13,47 @@ List all medical specialties.
### Query Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `parent_id` | integer | ❌ | Filter to sub-specialties of a parent |
| `parent_id` | integer | ❌ | Filter to sub-specialties of a parent. **بدون این پارامتر، هر تخصص فعال برمی‌گردد — ریشه‌ها و فرزندان با هم.** |
### Response `200`
پاسخ **دولایه** است: `data.data`. خروجی واقعی از اجرای زنده (۹۹ رکورد):
```json
{
"success": true,
"data": [
{
"id": 1,
"name": "قلب و عروق",
"slug": "ghalb-va-oroug",
"parent_id": null,
"status": "active",
"weight": 10
},
{
"id": 5,
"name": "فوق تخصص قلب",
"slug": "fowgh-takhassos-ghalb",
"parent_id": 1,
"status": "active",
"weight": 5
}
]
"data": {
"data": [
{
"id": 1,
"uuid": "c60b2c3c-de3c-4c2b-a4c9-75c3444e69cb",
"name": "پزشک عمومی",
"slug": "general-practitioner",
"status": 1,
"weight": 10,
"parent_id": null
},
{
"id": 168,
"uuid": "6828d049-f0b3-421e-930a-4b6ac57c75f1",
"name": "جراح تیروئید",
"slug": "جراح-تیروئید",
"status": 1,
"weight": 0,
"parent_id": 13
}
]
}
}
```
`status` عدد است (`1` فعال)، نه رشته. `slug` می‌تواند فارسی باشد.
> **مصرف‌کنندهٔ حساس:** سایت عمومی `nobat724_front` فایل `data/specialties.json` خود را در زمان
> build از همین اندپوینت می‌سازد و آن فایل به `sitemap` و صفحات تخصص خوراک می‌دهد. تغییر شکل این
> پاسخ آن اسکریپت را می‌شکند و در build کلاینت خطا نمی‌دهد — فقط در رانتایم دیده می‌شود.
```
---
## GET `/api/v1/specialties/doctor-counts`