Files
hamedandClaude Opus 5 2da5b5188c 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>
2026-08-08 16:44:32 +03:30

6.3 KiB

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. خروجی واقعی از اجرای زنده (۹۹ رکورد):

{
  "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

{
  "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)

{
  "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)

{
  "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

{ "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.

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.