`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>
231 lines
6.3 KiB
Markdown
231 lines
6.3 KiB
Markdown
# Specialty API
|
|
|
|
> **Prefix:** `/api/v1/specialties`, `/api/v1/admin/specialty`
|
|
|
|
---
|
|
|
|
## GET `/api/v1/specialties`
|
|
|
|
List all medical specialties.
|
|
|
|
**Permission:** `PUBLIC`
|
|
|
|
### Query Parameters
|
|
| Param | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `parent_id` | integer | ❌ | Filter to sub-specialties of a parent. **بدون این پارامتر، هر تخصص فعال برمیگردد — ریشهها و فرزندان با هم.** |
|
|
|
|
### Response `200`
|
|
|
|
پاسخ **دولایه** است: `data.data`. خروجی واقعی از اجرای زنده (۹۹ رکورد):
|
|
|
|
```json
|
|
{
|
|
"success": true,
|
|
"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`
|
|
|
|
List active specialties together with the number of doctors in each — optionally scoped to a city. Used by the public `/specialties` page to show «N پزشک» under each specialty.
|
|
|
|
**Permission:** `PUBLIC`
|
|
|
|
### Query Parameters
|
|
| Param | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `city_id` | integer | ❌ | Count only doctors in this city (the `categories.id` of the city). Omit for a global count. |
|
|
|
|
### Response `200`
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": [
|
|
{
|
|
"id": 2,
|
|
"name": "داخلی عمومی",
|
|
"slug": "...",
|
|
"parent_id": null,
|
|
"status": "active",
|
|
"weight": 10,
|
|
"number_of_doctors": 10
|
|
}
|
|
]
|
|
}
|
|
```
|
|
- Returns **all** active specialties; those with no doctors have `number_of_doctors: 0`.
|
|
- `number_of_doctors` counts distinct doctors joined through `doctor_specialties` (and `doctor_cities` when `city_id` is given), matching the public `GET /api/v1/doctors?specialty_id=&city_id=` filter.
|
|
|
|
---
|
|
|
|
## GET `/api/v1/admin/specialties`
|
|
|
|
List all specialties with pagination (admin view — includes inactive).
|
|
|
|
**Permission:** `ROLE_ADMIN`
|
|
|
|
### Query Parameters
|
|
| Param | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `page` | integer | ❌ | Default: 1 |
|
|
| `limit` | integer | ❌ | Default: 20 |
|
|
| `search` | string | ❌ | Matches `name`, `slug`, **or the parent specialty's name** (case-insensitive `LIKE`) |
|
|
| `sort` / `order` | string | ❌ | `sort=id` with `order=asc\|desc`; otherwise ordered by weight then name |
|
|
|
|
### Response `200`
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": [
|
|
{ "id": 12, "uuid": "…", "name": "لیزر درمانی", "slug": "laser", "status": 1, "weight": 0,
|
|
"parent_id": 3, "parent_name": "پوست و مو" }
|
|
],
|
|
"meta": { "totalRecords": 50, "totalPages": 3, "currentPage": 1 }
|
|
}
|
|
```
|
|
- `parent_name` is **admin-list only** — the public `GET /api/v1/specialties` does not return it.
|
|
It exists because searching by parent can return a child whose parent is not on the current page,
|
|
so the client cannot resolve the name from its own list.
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
| `ERR_AUTH_006` | 403 | Not admin |
|
|
|
|
---
|
|
|
|
## POST `/api/v1/admin/specialty`
|
|
|
|
Create a new specialty.
|
|
|
|
**Permission:** `ROLE_ADMIN`
|
|
|
|
### Request Body (`application/json`)
|
|
```json
|
|
{
|
|
"name": "قلب و عروق",
|
|
"slug": "ghalb-va-oroug",
|
|
"parent_id": null,
|
|
"status": "active",
|
|
"weight": 10
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `name` | string | ✅ | Specialty name |
|
|
| `slug` | string | ❌ | Auto-generated from name if omitted |
|
|
| `parent_id` | integer | ❌ | Parent specialty ID (for sub-specialties) |
|
|
| `status` | string | ❌ | `"active"` (default) or `"inactive"` |
|
|
| `weight` | integer | ❌ | Sort weight |
|
|
|
|
### Response `201`
|
|
Specialty object.
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
| `ERR_AUTH_006` | 403 | Not admin |
|
|
| `ERR_VALIDATION_002` | 422 | Missing name |
|
|
|
|
---
|
|
|
|
## PATCH `/api/v1/admin/specialty/{id}`
|
|
|
|
Update a specialty.
|
|
|
|
**Permission:** `ROLE_ADMIN`
|
|
|
|
### Path Parameters
|
|
| Param | Type | Description |
|
|
|-------|------|-------------|
|
|
| `id` | integer | Specialty ID |
|
|
|
|
### Request Body (all optional)
|
|
```json
|
|
{
|
|
"name": "قلب و عروق ویرایششده",
|
|
"status": "inactive",
|
|
"weight": 20,
|
|
"parent_id": null,
|
|
"slug": "new-slug"
|
|
}
|
|
```
|
|
|
|
### Response `200`
|
|
Updated specialty object.
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
| `ERR_AUTH_006` | 403 | Not admin |
|
|
| `ERR_NOT_FOUND_001` | 404 | Specialty not found |
|
|
|
|
---
|
|
|
|
## DELETE `/api/v1/admin/specialty/{id}`
|
|
|
|
Delete a specialty.
|
|
|
|
**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 | Specialty not found |
|
|
|
|
---
|
|
|
|
## Bulk import / export
|
|
|
|
Full-table JSON export and strict wipe+replace import for this category live under
|
|
`/api/v1/admin/categories/{bundle}/{export|import}` — see [category-import.md](category-import.md).
|
|
|
|
|
|
### Sorting by id
|
|
|
|
The admin list endpoint accepts `sort=id&order=asc|desc` to order by `id`
|
|
(used by the admin «دستهبندیها» page when clicking the «شناسه» column).
|
|
Without `sort`, the default ordering (weight/name) is unchanged.
|