Files
clinicpro/docs/api/doctor.md
T
hamedandClaude Fable 5 7baa4df3d4 fix(booking): aggregate public booking state across all schedules
The public doctor payload built `active`/`free_turn`/`hours_of_work` from the
personal schedule alone, so a doctor bookable only at a clinic was reported as
"نوبت‌دهی غیرفعال". Aggregate over every schedule instead: any schedule with
online booking on and an active day makes the doctor bookable, and the disabled
label only appears when all of them are off.

Three admin-panel fixes for the same class of bug:

- AppointmentsPage took the selected doctor from `dbUuid`, which is the clinic's
  uuid inside a clinic context — the slots request 404'd. Use `doctorUuid`.
- TurnsTimeline rendered any error or unknown empty_reason as "این روز شیفت کاری
  ندارد". Errors now surface as errors and unknown reasons get a neutral message;
  the day-off wording is reserved for an explicit day_off from the backend.
- Admins have no clinic context, so slots fell back to the personal schedule.
  They now pick a location from `appointment-booking-locations` and that choice
  drives the slot, service and create-appointment requests.

Adds `app:schedule:normalize-format` for legacy rows stored as a bare JSON list
covering only Saturday, which read as day-off for the rest of the week.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:25:24 +03:30

524 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Doctor API
> فیلد `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 | ✅ | Full name with title |
| `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 |
---
## GET `/api/v1/doctor/{uuid}`
Get doctor detail with clinics.
**Permission:** `PUBLIC`
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | string (UUID) | Doctor UUID |
### Response `200`
```json
{
"success": true,
"data": {
"data": {
"uuid": "550e8400-...",
"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:0013:00",
"hours_of_work": "شنبه: 09:0013:00 و 14:0018:00 | یکشنبه: 09:0013: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` در صورت وجود برنامه حفظ می‌شود. تا وقتی حتی یک برنامه روشن و
> دارای روز فعال باشد، همان مبنا قرار می‌گیرد. چنین پزشکی (همه خاموش) در لیست عمومی
> `GET /api/v1/doctors` (پیش‌فرض `active=true`) نمایش داده نمی‌شود، ولی صفحه‌ی تکی
> `GET /api/v1/doctor/{slug}` همچنان قابل دسترسی است.
---
## 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: 20 |
| `search` | string | ❌ | Search in title |
| `specialty_id` | integer | ❌ | Filter by 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` نادیده گرفته می‌شوند؛ دامنه شهری/ناشناخته اثری ندارد |
### Response `200`
```json
{
"success": true,
"data": [
{
"uuid": "...",
"name": "دکتر علی احمدی",
"gender": "man",
"degree": "specialist",
"img": [],
"specialties": [{ "uuid": "...", "id": "1", "name": "قلب و عروق" }],
"satisfaction": "60",
"point": "3.5",
"free_turn": "دوشنبه 09:0013:00",
"hours_of_work": "شنبه: 09:0013:00 و 14:0018:00 | یکشنبه: 09:0013:00",
"active": true,
"owner_status": "claimed"
}
],
"meta": {
"totalRecords": 50,
"totalPages": 3,
"currentPage": 1
}
}
```
> ️ `point` و `satisfaction` فقط برای `owner_status="claimed"` مقدار دارند؛ برای `unclaimed`/`pending_transfer` هر دو `null` هستند.
---
## PATCH `/api/v1/doctor/{uuid}`
Update doctor profile.
**Permission:** `AUTH` — must be the owner (or `ROLE_ADMIN`)
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | string (UUID) | Doctor UUID |
### 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).
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the owner |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
---
## 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",
"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",
"map": { "latitude": null, "longitude": null },
"city": { "id": "3", "name": "اصفهان" },
"province": { "id": "2", "name": "اصفهان" }
}
]
}
```
> **نکته:** آدرس‌های با `type: "clinic"` از کلینیک‌هایی که پزشک عضو آن‌هاست می‌آیند و `clinic_name` نام کلینیک را نشان می‌دهد.
---
## POST `/api/v1/clinic-pro/doctor-address`
Add a new practice address.
**Permission:** `AUTH` — must own the doctor profile
### Request Body
```json
{
"name": "مطب تهران",
"address": "تهران، خیابان ولیعصر",
"telephone": "02112345678",
"province_id": 1,
"city_id": 3,
"latitude": 35.6892,
"longitude": 51.3890
}
```
| Field | Type | Required |
|-------|------|----------|
| `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` — must own the doctor profile
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `id` | integer | Address ID |
### Request Body
Same fields as POST — all optional.
### Response `200`
Updated address object.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the owner |
| `ERR_NOT_FOUND_001` | 404 | Address not found |
---
## DELETE `/api/v1/clinic-pro/doctor-address/{id}`
Delete a practice address.
**Permission:** `AUTH` — must own the doctor profile
### Response `200`
```json
{ "success": true, "data": { "message": "آدرس حذف شد" } }
```
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the owner |
| `ERR_NOT_FOUND_001` | 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 |