`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>
706 lines
31 KiB
Markdown
706 lines
31 KiB
Markdown
# Doctor API
|
||
|
||
> **`name` در برابر `display_name`:** `name` نامِ خام است (بدون عنوان — برای فرم ویرایش).
|
||
> `display_name` نامِ آمادهٔ نمایش با پیشوند «دکتر» است که بکاند تولید میکند
|
||
> (`PersianText::withDoctorTitle`) تا همهٔ کلاینتها یکسان و بدون «دکتر دکتر …» نشان دهند؛
|
||
> در لیست و جزئیات پزشک هر دو حاضرند. برای نمایش از `display_name`، برای ویرایش از `name` استفاده کنید.
|
||
|
||
> فیلد `owner_status` (`claimed` | `unclaimed` | `pending_transfer`) به خروجی لیست و جزئیات پزشک
|
||
> اضافه شده است — پروفایل `unclaimed` (ایمپورت نظام پزشکی) در سایت دکمهٔ «تصاحب پروفایل» میگیرد
|
||
> (`docs/api/doctor-claim.md`) و نوبتدهی آنلاینش غیرفعال است.
|
||
>
|
||
> **امتیاز فقط برای پروفایل `claimed` منتشر میشود.** برای هر `owner_status` غیر از `claimed`
|
||
> (یعنی `unclaimed` / `pending_transfer`) فیلدهای `point` و `satisfaction` مقدار `null` برمیگردند —
|
||
> مقدار پیشفرض انتیتی (`3.5` / `60`) نشتی نمیکند تا امتیاز جعلی/`AggregateRating` جعلی ساخته نشود.
|
||
> بعد از تصاحب و claimed شدن، امتیاز واقعی بهصورت خودکار برمیگردد.
|
||
|
||
> **Prefix:** `/api/v1/doctor`, `/api/v1/doctors`, `/api/v1/clinic-pro/doctor-address*`
|
||
>
|
||
> Numeric path params on the address routes (`doctor-address/{id}`, `doctor-addresses/{doctorId}`) require `\d+`; a non-numeric value returns a clean `404` instead of a `500`.
|
||
|
||
---
|
||
|
||
## POST `/api/v1/doctor`
|
||
|
||
Create a doctor profile for the authenticated user.
|
||
|
||
**Permission:** `AUTH` — any authenticated user
|
||
|
||
### Request Body (`application/json`)
|
||
```json
|
||
{
|
||
"title": "علی احمدی",
|
||
"gender": "male",
|
||
"medical_system_code": "12345",
|
||
"degree": "متخصص",
|
||
"info": "توضیحات درباره پزشک",
|
||
"specialties": [1, 2],
|
||
"doctor_services": [3, 4]
|
||
}
|
||
```
|
||
|
||
| Field | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `title` | string | ✅ | نام پزشک **بدون** عنوان. پیشوند «دکتر» سمت سرور با `PersianText::stripDoctorTitle()` حذف میشود؛ نمایش عنوان کار لایهٔ UI است. |
|
||
| `gender` | string | ❌ | `"male"` or `"female"` |
|
||
| `medical_system_code` | string | ❌ | Nظام پزشکی code |
|
||
| `degree` | string | ❌ | Academic degree |
|
||
| `info` | string | ❌ | Bio/description |
|
||
| `specialties` | integer[] | ❌ | Array of specialty IDs. تخصصها درختیاند: هر شناسهٔ فرزند سمت سرور با تمام والدهایش تا ریشه گسترش مییابد، پس پاسخ ممکن است تخصصهای بیشتری از ورودی داشته باشد (مثلاً ارسال «گوارش و کبد» → ذخیرهٔ «داخلی» + «گوارش و کبد»). شناسههای ناموجود نادیده گرفته میشوند. |
|
||
| `doctor_services` | integer[] | ❌ | Array of doctor service IDs |
|
||
| `activity_time` | integer | ❌ | Unix timestamp (ثانیه) تاریخ شروع فعالیت؛ مبنای محاسبهٔ `experience` (سال تجربه) در پاسخ |
|
||
|
||
### Response `201`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||
"title": "علی احمدی",
|
||
"gender": "male",
|
||
"medical_system_code": "12345",
|
||
"degree": "متخصص",
|
||
"info": "...",
|
||
"doctor_rate": null,
|
||
"active_doctor_appointment": false,
|
||
"specialties": [],
|
||
"doctor_services": [],
|
||
"created_at": 1717000000
|
||
}
|
||
}
|
||
```
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing or invalid token |
|
||
| `ERR_CONFLICT_001` | 409 | Doctor profile already exists for this user |
|
||
| `ERR_VALIDATION_002` | 422 | Missing required field |
|
||
| `ERR_VALIDATION_001` | 422 | نام پزشک شمارهتلفن یا مقدار آزمایشی است |
|
||
|
||
---
|
||
|
||
## GET `/api/v1/doctor/{uuid}`
|
||
|
||
Get doctor detail with clinics.
|
||
|
||
**Permission:** `PUBLIC` — a token is optional and only affects `can_edit`.
|
||
|
||
### Path Parameters
|
||
| Param | Type | Description |
|
||
|-------|------|-------------|
|
||
| `uuid` | string (UUID) | Doctor UUID |
|
||
|
||
### `can_edit`
|
||
|
||
The payload carries `can_edit: boolean` — whether the **caller** may `PATCH` this doctor. It is `true`
|
||
for the doctor themselves, `ROLE_ADMIN`, and the registering representative; `false` for everyone else
|
||
and always `false` without a token. It is computed by the same policy the `PATCH` gate uses, so clients
|
||
must read it rather than re-deriving the rule; a client that recomputes will drift.
|
||
|
||
It says nothing about *which fields* are writable — a representative sees `can_edit: true` yet is still
|
||
limited to the whitelist under `PATCH /api/v1/doctor/{uuid}`.
|
||
|
||
### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"data": {
|
||
"uuid": "550e8400-...",
|
||
"name": "علی احمدی",
|
||
"display_name": "دکتر علی احمدی",
|
||
"gender": "man",
|
||
"medical_system_code": "12345",
|
||
"degree": "specialist",
|
||
"detail": "...",
|
||
"img": [],
|
||
"social_media": {
|
||
"instagram": "https://instagram.com/dr.example",
|
||
"telegram": "https://t.me/dr_example",
|
||
"aparat": null,
|
||
"youtube": null,
|
||
"linkedin": null
|
||
},
|
||
"satisfaction": "60",
|
||
"point": "3.5",
|
||
"free_turn": "دوشنبه 09:00–13:00",
|
||
"hours_of_work": "شنبه: 09:00–13:00 و 14:00–18:00 | یکشنبه: 09:00–13:00",
|
||
"active": true,
|
||
"owner_status": "claimed",
|
||
"specialties": [{ "uuid": "...", "id": "1", "name": "قلب و عروق", "parent_id": null }],
|
||
"expertise": [{ "uuid": "...", "id": "3", "name": "نوار قلب" }],
|
||
"address": [],
|
||
"state": [],
|
||
"city": [],
|
||
"clinics": [{ "uuid": "...", "name": "کلینیک الوند", "address": "...", "telephone": "..." }],
|
||
"representation": { "id": 12, "uuid": "9c1...", "full_name": "علی محمدی" }
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
> ⚠️ **Double-nested:** Frontend extracts with `data?.data?.data`
|
||
>
|
||
> ℹ️ `representation` نمایندهی مالکِ پزشک است؛ برای پزشکِ بدون نماینده `null`.
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
|
||
|
||
---
|
||
|
||
## GET `/api/v1/clinic/my-doctor/{doctorUuid}`
|
||
|
||
Get doctor detail for clinic owner — only doctors who are members of the authenticated clinic.
|
||
|
||
**Permission:** `ROLE_CLINIC`
|
||
|
||
### Path Parameters
|
||
| Param | Type | Description |
|
||
|-------|------|-------------|
|
||
| `doctorUuid` | string (UUID) | Doctor UUID |
|
||
|
||
### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"data": {
|
||
"uuid": "...",
|
||
"title": "علی احمدی",
|
||
"specialties": [...],
|
||
"clinics": [{ "uuid": "...", "name": "کلینیک نور", "address": "...", "telephone": "..." }]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
> ⚠️ **Double-nested:** Frontend extracts with `data?.data?.data`
|
||
|
||
> **Side note:** Returns only the authenticated clinic's data in the `clinics` array (not all clinics of the doctor).
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_VALIDATION_002` | 404 | Clinic or doctor not found |
|
||
| `ERR_AUTH_006` | 403 | Doctor is not a member of this clinic |
|
||
|
||
---
|
||
|
||
### Schedule Fields Notes
|
||
|
||
**تجمیع همهٔ برنامهها (2026-07):** این فیلدها روی **همهٔ** برنامههای هفتگی پزشک محاسبه
|
||
میشوند — برنامهٔ مطب شخصی (`clinic_id IS NULL`) بهعلاوهٔ یک برنامه به ازای هر کلینیک.
|
||
پزشکی که برنامهٔ شخصی خالی/خاموش ولی برنامهٔ کلینیکیِ فعال دارد، `active=true` میگیرد؛
|
||
برنامهٔ یک محیط هرگز محیط دیگر را نمیپوشاند.
|
||
|
||
| Field | When at least one schedule is bookable | When none |
|
||
|-------|---------------------------------------|-----------|
|
||
| `free_turn` | نزدیکترین روز/ساعت کاری از امروز، بین همهٔ برنامههای روشن (مثلاً «دوشنبه ۹:۰۰–۱۳:۰۰») | «نوبت آزادی موجود نیست» |
|
||
| `hours_of_work` | خلاصه ساعتهای همان برنامهای که `free_turn` را داده (ساعتهای دو محل با هم ترکیب نمیشوند) | «برنامه کاری تنظیم نشده» |
|
||
| `active` | `activeDoctorAppointment && (∃ schedule: online_booking_enabled && has_active_sessions)` | `false` — نوبتدهی غیرفعال |
|
||
|
||
> **نوبتدهی آنلاین غیرفعال:** اگر `meta.online_booking_enabled` در **همهٔ** برنامههای پزشک
|
||
> `false` باشد، `free_turn` برابر `"نوبتدهی آنلاین غیرفعال است"` و `active` برابر `false`
|
||
> برمیگردد؛ `hours_of_work` در صورت وجود برنامه حفظ میشود. تا وقتی حتی یک برنامه روشن و
|
||
> دارای روز فعال باشد، همان مبنا قرار میگیرد.
|
||
>
|
||
> چنین پزشکی (همه برنامهها خاموش، ولی فلگ `active_doctor_appointment` **روشن**) در لیست
|
||
> عمومی `GET /api/v1/doctors` **نمایش داده میشود** — چون هنوز فعال است — ولی با
|
||
> `bookableRank` پایینتر از پزشکان دارای نوبت مرتب میشود و تنها با `active=1` از نتایج حذف
|
||
> میگردد.
|
||
>
|
||
> **پزشک غیرفعال (`active_doctor_appointment` خاموش):** با غیرفعالکردن پزشک از پنل ادمین،
|
||
> او دیگر در لیست عمومی `GET /api/v1/doctors` ظاهر نمیشود (پیشفرض این اندپوینت پزشکان
|
||
> غیرفعال را کنار میگذارد). صفحهٔ تکی `GET /api/v1/doctor/{slug}` همچنان پاسخ میدهد
|
||
> (ادمین/کلینیک از همین اندپوینت برای مشاهده/ویرایش استفاده میکنند) و در بدنهٔ پاسخ فیلد
|
||
> خام `is_active` (`= active_doctor_appointment`، مستقل از داشتن برنامه) را برمیگرداند؛
|
||
> کلاینت عمومی مثل nobat724 با `is_active === false` صفحهٔ پزشک را `404` میکند. این با
|
||
> فیلد `active` (که `active_doctor_appointment && has_schedule` است) فرق دارد.
|
||
|
||
---
|
||
|
||
## GET `/api/v1/doctors`
|
||
|
||
List doctors with pagination and filters.
|
||
|
||
**Permission:** `PUBLIC`
|
||
|
||
### Query Parameters
|
||
| Param | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `page` | integer | ❌ | Default: 1 |
|
||
| `limit` | integer | ❌ | Default: 10. **حداکثر ۵۰** — مقادیر بزرگتر بیصدا به ۵۰ کاهش مییابند. مقدار واقعاً اعمالشده در `meta.limit` برمیگردد؛ برای پیمایش کامل به `meta.totalPages` تکیه کن، نه به «تعداد آیتم کمتر از limit درخواستی» |
|
||
| `specialty_id` | integer | ❌ | Filter by specialty ID — **این تخصص و همهٔ زیرشاخههایش**. `specialty_id` یک تخصص والد یعنی «همهٔ پزشکان آن گروه»، حتی پزشکی که فقط با یک زیرتخصص ثبت شده و والد روی او ننشسته. شناسهٔ ناموجود → لیست خالی، نه لیست کامل |
|
||
| `city_id` | integer | ❌ | Filter by city ID — شامل دکترهایی که آدرس شخصیشان (`doctor_addresses.city_id`, با `doctor_id` مقداردار) در آن شهر است یا از طریق کلینیکی که آدرس آن در آن شهر است (`doctor_addresses.clinic_id`) |
|
||
| `state_id` | integer | ❌ | Filter by province ID — بر اساس آدرس شخصی پزشک (`doctor_addresses.province_id`) یا آدرس کلینیک |
|
||
| `domain` | string | ❌ | دامنهی سایتِ درخواستکننده. اگر دامنهی یک **نماینده سراسری** باشد، فقط پزشکانِ همان نماینده برمیگردند و `city_id`/`state_id` نادیده گرفته میشوند؛ دامنه شهری/ناشناخته اثری ندارد |
|
||
| `gender` | string | ❌ | `man` یا `woman` |
|
||
| `degree` | string | ❌ | `expert`, `general`, `specialist`, `subspecialistplus` |
|
||
| `name` | string | ❌ | جستجوی `LIKE` روی نام پزشک **یا نام هر یک از تخصصهای او**. کادر جستجوی سایت عمومی یک فیلد بیشتر ندارد و کاربر در آن هر دو را تایپ میکند. با `specialty_id` مستقل ترکیب میشود: پزشکی که با یک تخصص فیلتر را پاس میکند و نامِ تخصص دیگرش را میجویند، حذف نمیشود |
|
||
| `sort` | string | ❌ | `ASC` یا `DESC` (پیشفرض `DESC`) — مرتبسازی ثانویه بر اساس `doctorRate` |
|
||
| `active` | `0`/`1` | ❌ | `1` → فقط پزشکان **دارای نوبت** (تعریف پایین). `0` → فقط پزشکانی که فلگ `active_doctor_appointment` آنها خاموش است (کاربرد ادمین). بدون این پارامتر → فقط پزشکان **فعال** (`active_doctor_appointment` روشن)؛ پزشکان غیرفعال هرگز در لیست عمومی نمیآیند |
|
||
|
||
### مرتبسازی و تعریف «دارای نوبت»
|
||
|
||
پزشک **دارای نوبت** یعنی هر سه شرط برقرار باشد (همان تعریفی که فیلد `active` هر آیتم پاسخ را میسازد):
|
||
1. فلگ `active_doctor_appointment` روشن،
|
||
2. `WeeklySchedule` ثبتشده با حداقل یک سشن `active: true`،
|
||
3. `meta.online_booking_enabled` برابر `false` نباشد.
|
||
|
||
لیست همیشه اول پزشکان دارای نوبت را نشان میدهد و بعد بقیه را؛ داخل هر گروه بر اساس `doctorRate` و پارامتر `sort` مرتب میشود.
|
||
|
||
### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{
|
||
"uuid": "...",
|
||
"name": "علی احمدی",
|
||
"display_name": "دکتر علی احمدی",
|
||
"gender": "man",
|
||
"degree": "specialist",
|
||
"img": [],
|
||
"specialties": [
|
||
{ "uuid": "277d0e01-...", "id": "5179", "name": "جراحی عمومی", "parent_id": null },
|
||
{ "uuid": "460bd820-...", "id": "5180", "name": "جراح گوارش", "parent_id": "5179" }
|
||
],
|
||
"satisfaction": "60",
|
||
"point": "3.5",
|
||
"free_turn": "دوشنبه 09:00–13:00",
|
||
"hours_of_work": "شنبه: 09:00–13:00 و 14:00–18:00 | یکشنبه: 09:00–13:00",
|
||
"active": true,
|
||
"owner_status": "claimed",
|
||
"city": [
|
||
{ "uuid": "7bfb989e-...", "id": "123", "name": "یاسوج", "parent": "23" }
|
||
],
|
||
"state": [
|
||
{ "uuid": "7bfb5705-...", "id": "23", "name": "کهگیلویه و بویراحمد" }
|
||
]
|
||
}
|
||
],
|
||
"meta": {
|
||
"totalRecords": 50,
|
||
"totalPages": 3,
|
||
"currentPage": 1,
|
||
"limit": 50
|
||
}
|
||
}
|
||
```
|
||
|
||
> ℹ️ `point` و `satisfaction` فقط برای `owner_status="claimed"` مقدار دارند؛ برای `unclaimed`/`pending_transfer` هر دو `null` هستند.
|
||
|
||
> ℹ️ هر آیتم `specialties[]` کلید `parent_id` دارد — **رشته** یا `null` برای ریشه، همشکل با `id` در همان آرایه و با پاسخ جزئیات پزشک. کلاینت با همین تشخیص میدهد کدام تخصص «اصلی» است؛ پزشک چندتخصصی معمولاً هم والد را دارد و هم زیرشاخههایش، چون ذخیرهٔ یک زیرتخصص والدهایش را هم مینشاند.
|
||
|
||
### اعتبارسنجی نام پزشک
|
||
|
||
`name` نمیتواند شمارهتلفن (`^0?9\d{9}$`) یا مقدار آزمایشی (`test`، `تست`، `-`، `null`) باشد. این مقادیر در **هر** مسیر نوشتن با `422` رد میشوند — API عمومی، پنل ادمین، import و دعوت کلینیک — چون گارد روی خودِ Entity نشسته است (`App\Shared\Util\DisplayName`).
|
||
|
||
دلیل: نام پزشک در `<title>` و نتایج جستوجوی سایت عمومی رندر میشود؛ رکوردی با نام «09390039833» یک صفحهٔ بیارزش ایندکسشدنی میسازد.
|
||
|
||
> دعوت پزشک توسط کلینیک، اگر نام ارسال نشود، دیگر شمارهٔ موبایل را بهعنوان نام نمینشاند — برچسب خنثای «پزشک دعوتشده» میگیرد تا خود پزشک پروفایلش را claim کند. (ریشهٔ آلودگی تولیدی همین بود.)
|
||
|
||
فرمان ممیزی رکوردهای موجود:
|
||
|
||
```bash
|
||
php bin/console app:audit-polluted-records # فقط گزارش
|
||
php bin/console app:audit-polluted-records --force # خارجکردن از انتشار (بدون حذف)
|
||
```
|
||
|
||
### `city` / `state` در پاسخ لیست
|
||
|
||
آرایه با حداکثر یک عضو — همشکل با `city`/`state` در پاسخ جزئیات پزشک و پاسخ لیست کلینیکها.
|
||
|
||
- منبع مکان **دقیقاً همان قاعدهای است که فیلتر `city_id`/`state_id` اعمال میکند**: اول آدرس شخصی پزشک (`doctor_addresses` با `doctor_id` مقداردار)، و اگر نداشت آدرس کلینیکی که عضو آن است (`doctor_addresses` با `clinic_id` مقداردار و `doctor_id` تهی). یعنی هر پزشکی که با `city_id=X` برگردد، در پاسخ هم همان شهر را اعلام میکند.
|
||
- پزشک چند-مطبی **یک شهر اصلی** میگیرد (اولین مکان یافتشده) — نه فهرست همهٔ شهرها.
|
||
- پزشک بدون هیچ آدرس: `"city": []` و `"state": []` (آرایهٔ خالی، نه `null`).
|
||
- `city[].parent` شناسهٔ استان است.
|
||
- استخراج مکان دستهای انجام میشود (`DoctorRepository::findLocationsByDoctors`) — حداکثر دو کوئری ثابت، مستقل از تعداد پزشکان در صفحه.
|
||
|
||
> 🔗 مصرفکننده: `nobat724_front/app/sitemap.js` با این فیلد تشخیص میدهد هر پزشک به کدام دامنهٔ شهری تعلق دارد (canonical). تغییر شکل این فیلد قرارداد آن را میشکند.
|
||
|
||
---
|
||
|
||
## PATCH `/api/v1/doctor/{uuid}`
|
||
|
||
Update doctor profile.
|
||
|
||
**Permission:** `AUTH` — the doctor themselves, `ROLE_ADMIN`, or the **registering representative**
|
||
(a `ROLE_REPRESENTATION` user whose `Representation.id` equals the doctor's `representation_id`).
|
||
|
||
The representative's grant is permanent for as long as `representation_id` points at them, but it is
|
||
restricted to content fields — see *Representative field whitelist* below. The doctor's and the
|
||
admin's own access is unchanged and unrestricted.
|
||
|
||
### Path Parameters
|
||
| Param | Type | Description |
|
||
|-------|------|-------------|
|
||
| `uuid` | string (UUID) | Doctor UUID |
|
||
|
||
### Representative field whitelist
|
||
|
||
A representative may send only these keys. Any other key aborts the whole request with `403`
|
||
and **nothing is saved** — the payload is not silently filtered.
|
||
|
||
`title` · `gender` · `degree` · `info` · `detail` · `mobile_number` · `activity_time` ·
|
||
`images` · `image_data` · `social_media` · `specialties` · `doctor_services` · `expertise` ·
|
||
`states` · `cities`
|
||
|
||
Notably excluded: `medical_system_code` (professional credential) and `active`. A representative
|
||
activates or deactivates their own doctor through `POST /api/v1/representation/doctors/{uuid}/status`
|
||
instead.
|
||
|
||
Every successful representative edit writes one `app_log` row with `channel = 'representation_edit'`,
|
||
recording the representative id, the target uuid, and the **names** of the changed fields (never
|
||
their values). Edits by the doctor or an admin write no such row.
|
||
|
||
### Request Body (`application/json`)
|
||
Same fields as POST (all optional), plus:
|
||
|
||
| Field | Type | Description |
|
||
|-------|------|-------------|
|
||
| `social_media` | object | Keys: `instagram`, `telegram`, `aparat`, `youtube`, `linkedin`. Each value must be a full valid URL or `null`. Any value that fails `FILTER_VALIDATE_URL` is silently stored as `null`. |
|
||
|
||
```json
|
||
{
|
||
"social_media": {
|
||
"instagram": "https://instagram.com/dr.example",
|
||
"telegram": "https://t.me/dr_example",
|
||
"aparat": null,
|
||
"youtube": null,
|
||
"linkedin": null
|
||
}
|
||
}
|
||
```
|
||
|
||
### Response `200`
|
||
Updated doctor object (same structure as GET single), wrapped as `{ "success": true, "data": { ... } }`.
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_AUTH_006` | 403 | Not the doctor, not an admin, and not the registering representative |
|
||
| `ERR_AUTH_006` | 403 | Representative sent a field outside the whitelist — the offending key is in `errors[0].field` |
|
||
| `ERR_VALIDATION_002` | 404 | Doctor not found |
|
||
|
||
Real responses (captured from a functional test run, not hand-written):
|
||
|
||
```json
|
||
{
|
||
"success": false,
|
||
"data": null,
|
||
"errors": [
|
||
{
|
||
"code": "ERR_AUTH_006",
|
||
"message": "نماینده اجازهٔ تغییر این فیلد را ندارد",
|
||
"field": "medical_system_code"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
```json
|
||
{
|
||
"success": false,
|
||
"data": null,
|
||
"errors": [
|
||
{ "code": "ERR_AUTH_006", "message": "دسترسی ممنوع" }
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## DELETE `/api/v1/doctor/{uuid}`
|
||
|
||
Delete a doctor profile.
|
||
|
||
**Permission:** `IS_AUTHENTICATED_FULLY` — **admin** (any profile) **or the owner** of a `claimed` profile (`doctor.user === current user`). Other users get `403`.
|
||
|
||
> **Side effect:** the doctor's insurance configuration (`tenant_insurances`, `entity_insurance_pricing`, and their `tenant_service_coverages`) is purged in the same request — these reference the doctor via a polymorphic `entity_id` with no DB FK, so the cleanup is enforced in the application.
|
||
|
||
> **Guard:** a doctor with existing appointments cannot be deleted (the `appointments.doctor_id` FK would otherwise raise a `500`). The endpoint pre-checks and returns `409 ERR_CONFLICT_001` instead.
|
||
|
||
### Path Parameters
|
||
| Param | Type | Description |
|
||
|-------|------|-------------|
|
||
| `uuid` | string (UUID) | Doctor UUID |
|
||
|
||
### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": { "message": "پزشک با موفقیت حذف شد" }
|
||
}
|
||
```
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_FORBIDDEN_001` | 403 | Not admin and not the owner of this claimed profile |
|
||
| `ERR_VALIDATION_002` | 404 | Doctor not found |
|
||
| `ERR_CONFLICT_001` | 409 | Doctor has existing appointments and cannot be deleted |
|
||
|
||
---
|
||
|
||
## POST `/file/upload/clinic_pro/doctor/field_image`
|
||
|
||
Upload doctor profile image.
|
||
|
||
**Permission:** `AUTH`
|
||
|
||
### Request
|
||
`Content-Type: multipart/form-data`
|
||
|
||
| Field | Type | Required | Description |
|
||
|-------|------|----------|-------------|
|
||
| `file` | binary | ✅ | Image file (max 5MB) |
|
||
|
||
### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"url": "https://clinic-pro.ddev.site/uploads/doctor/abc123.jpg",
|
||
"uuid": "...",
|
||
"filename": "abc123.jpg",
|
||
"filemime": "image/jpeg",
|
||
"filesize": 204800
|
||
}
|
||
}
|
||
```
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_FILE_001` | 422 | Invalid file type |
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
|
||
---
|
||
|
||
## GET `/api/v1/clinic-pro/doctor-addresses/{doctorId}`
|
||
|
||
Get all practice addresses for a doctor, including addresses of clinics the doctor is a member of.
|
||
|
||
**Permission:** `PUBLIC`
|
||
|
||
### Path Parameters
|
||
| Param | Type | Description |
|
||
|-------|------|-------------|
|
||
| `doctorId` | integer | Doctor's numeric ID (route requires `\d+`; non-numeric → 404) |
|
||
|
||
### Response `200`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{
|
||
"id": "1",
|
||
"uuid": "...",
|
||
"type": "personal",
|
||
"clinic_id": null,
|
||
"clinic_name": null,
|
||
"name": "مطب تهران",
|
||
"address": "تهران، خیابان...",
|
||
"telephone": "02112345678",
|
||
"active": true,
|
||
"timezone": "Asia/Tehran",
|
||
"map": { "latitude": "35.6892", "longitude": "51.3890" },
|
||
"city": { "id": "1", "name": "تهران" },
|
||
"province": { "id": "1", "name": "تهران" }
|
||
},
|
||
{
|
||
"id": "5",
|
||
"uuid": "...",
|
||
"type": "clinic",
|
||
"clinic_id": 12,
|
||
"clinic_name": "کلینیک الوند",
|
||
"name": null,
|
||
"address": "اصفهان، خیابان...",
|
||
"telephone": "03112345678",
|
||
"active": true,
|
||
"timezone": "Asia/Tehran",
|
||
"map": { "latitude": null, "longitude": null },
|
||
"city": { "id": "3", "name": "اصفهان" },
|
||
"province": { "id": "2", "name": "اصفهان" }
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
> **نکته:** آدرسهای با `type: "clinic"` از کلینیکهایی که پزشک عضو آنهاست میآیند و `clinic_name` نام کلینیک را نشان میدهد.
|
||
|
||
> **`active` و `timezone` (افزودهشده در تسک شعبه):** هر آدرس یک «شعبه» است و این دو
|
||
> ویژگی روی خروجی **همهٔ** اندپوینتهای آدرس ظاهر میشوند، چون از
|
||
> `DoctorAddress::toArray()` میآیند. هر دو ستون `NOT NULL DEFAULT` دارند، پس ردیفهای
|
||
> قدیمی هم `active: true` و `timezone: "Asia/Tehran"` میدهند؛ تغییر additive است.
|
||
>
|
||
> `active` **فقط ذخیره میشود** و اثرش بر دسترسپذیری فقط همین است که منبعِ روی یک
|
||
> آدرسِ غیرفعال، روزش با دلیل `address_inactive` خالی برمیگردد. اندپوینت اختصاصیِ
|
||
> نوشتنِ این دو فیلد با حذف دامنهٔ شعبه برداشته شد.
|
||
|
||
---
|
||
|
||
## POST `/api/v1/clinic-pro/doctor-address`
|
||
|
||
Add a new practice address.
|
||
|
||
**Permission:** `AUTH` — the doctor themselves, `ROLE_ADMIN`, or the registering representative.
|
||
|
||
**Target resolution.** When `doctor_uuid` is present it is the target, and the caller must be that
|
||
doctor, an admin, or that doctor's registering representative. When it is absent the target is the
|
||
caller's own doctor profile. A representative who also happens to have a doctor profile therefore no
|
||
longer silently writes the address onto their own profile — the explicit uuid always wins.
|
||
|
||
| Caller | `doctor_uuid` present | `doctor_uuid` absent |
|
||
|---|---|---|
|
||
| The doctor | must be their own profile, else `403` | their own profile |
|
||
| `ROLE_ADMIN` | any doctor | `422` — `doctor_uuid` required |
|
||
| Registering representative | their own doctors only, else `403` | `422` — `doctor_uuid` required |
|
||
| Anyone else | `403` | `403` |
|
||
|
||
### Request Body
|
||
```json
|
||
{
|
||
"doctor_uuid": "9d5f94ba-322d-4735-88e1-b15e8447a0fa",
|
||
"name": "مطب تهران",
|
||
"address": "تهران، خیابان ولیعصر",
|
||
"telephone": "02112345678",
|
||
"province_id": 1,
|
||
"city_id": 3,
|
||
"latitude": 35.6892,
|
||
"longitude": 51.3890
|
||
}
|
||
```
|
||
|
||
| Field | Type | Required |
|
||
|-------|------|----------|
|
||
| `doctor_uuid` | string (UUID) | ✅ for admins and representatives; optional for the doctor themselves |
|
||
| `name` | string | ❌ |
|
||
| `address` | string | ✅ (frontend validation) |
|
||
| `telephone` | string | ✅ (frontend validation) |
|
||
| `province_id` | integer | ✅ (frontend validation) |
|
||
| `city_id` | integer | ✅ (frontend validation) |
|
||
| `latitude` | float | ❌ |
|
||
| `longitude` | float | ❌ |
|
||
|
||
### Response `201`
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": {
|
||
"id": 1,
|
||
"name": "مطب تهران",
|
||
"address": "تهران، خیابان ولیعصر",
|
||
"telephone": "02112345678",
|
||
"latitude": 35.6892,
|
||
"longitude": 51.3890
|
||
}
|
||
}
|
||
```
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_FORBIDDEN_001` | 403 | Not the doctor owner |
|
||
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
|
||
|
||
---
|
||
|
||
## PATCH `/api/v1/clinic-pro/doctor-address/{id}`
|
||
|
||
Update a practice address.
|
||
|
||
**Permission:** `AUTH` — the doctor themselves, `ROLE_ADMIN`, or the registering representative.
|
||
|
||
Only `type = personal` addresses are reachable here. A clinic address returns `403` regardless of
|
||
caller, including the representative — clinic addresses are edited through the clinic routes.
|
||
|
||
### Path Parameters
|
||
| Param | Type | Description |
|
||
|-------|------|-------------|
|
||
| `id` | integer | Address ID |
|
||
|
||
### Request Body
|
||
Same fields as POST — all optional. `doctor_uuid` is ignored; the target comes from the address itself.
|
||
|
||
### Response `200`
|
||
Updated address object, double-wrapped as `{ "success": true, "data": { "data": { … } } }`.
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_AUTH_006` | 403 | Address is a clinic address, or the caller is none of: the doctor, an admin, the registering representative |
|
||
| `ERR_VALIDATION_002` | 404 | Address not found |
|
||
|
||
---
|
||
|
||
## DELETE `/api/v1/clinic-pro/doctor-address/{id}`
|
||
|
||
Delete a practice address.
|
||
|
||
**Permission:** `AUTH` — the doctor themselves, `ROLE_ADMIN`, or the registering representative.
|
||
Clinic addresses return `403` here, same as PATCH.
|
||
|
||
### Response `200`
|
||
```json
|
||
{ "success": true, "data": { "message": "آدرس با موفقیت حذف شد" } }
|
||
```
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_AUTH_001` | 401 | Missing token |
|
||
| `ERR_AUTH_006` | 403 | Address is a clinic address, or the caller is none of: the doctor, an admin, the registering representative |
|
||
| `ERR_VALIDATION_002` | 404 | Address not found |
|
||
|
||
---
|
||
|
||
## POST `/api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}`
|
||
|
||
Create a doctor address automatically from a clinic's location.
|
||
|
||
**Permission:** `AUTH` — must own the doctor profile and be associated with the clinic
|
||
|
||
### Path Parameters
|
||
| Param | Type | Description |
|
||
|-------|------|-------------|
|
||
| `clinicUuid` | string (UUID) | Clinic UUID |
|
||
|
||
### Response `201`
|
||
Address object created from clinic data.
|
||
|
||
### Errors
|
||
| Code | HTTP | Description |
|
||
|------|------|-------------|
|
||
| `ERR_FORBIDDEN_001` | 403 | Not associated with this clinic |
|
||
| `ERR_NOT_FOUND_001` | 404 | Clinic not found |
|
||
|
||
---
|
||
|
||
## `GET /api/v1/addresses`
|
||
|
||
مجوز: `appointment_settings.view`. فهرست محلهای نوبتدهی محیط جاری، فقط برای انتخاب.
|
||
|
||
جانشین `GET /api/v1/branches` است که با حذف دامنهٔ شعبه برداشته شد. خودِ آدرس نمیرود:
|
||
هر منبع، لیست قیمت و نوبت به یکی از اینها بسته است. ساخت و ویرایش آدرس همانجایی است
|
||
که همیشه بود (`ClinicController` و `AppointmentSettingsController`).
|
||
|
||
```json
|
||
{
|
||
"success": true,
|
||
"data": [
|
||
{ "uuid": "…", "name": "کلینیک تخصصی مهر", "active": true }
|
||
]
|
||
}
|
||
```
|
||
|
||
**۴۰۳:** منشیِ بدون مجوز `appointment_settings.view`.
|