- Extract import logic from AdminApiController into DoctorImportService (thin DoctorImportController keeps the same route/contract) - Surrogate users get marker role ROLE_UNCLAIMED_DOCTOR (+ backfill command app:doctors:backfill-surrogate-role) enabling safe deletion after claim - DB-level UNIQUE (source, medical_system_code) + concurrent-import retry - Doctor profile claim flow (climed.md): shahkar + PersonInfo identity checks via existing ApiIrService, Persian name normalization (PersianText), pessimistic-lock race protection, DoctorClaimRequest audit table (national code hashed, mobile masked), doctor_claim rate limiter, public claim-info endpoint, welcome SMS - Admin support tools: manual transfer endpoint + paginated doctor-claims audit list + owner_status filter/fields in admin doctors list - Least privilege: system owner now gets ROLE_IMPORTER (ROLE_ADMIN stripped), import endpoint accepts ADMIN|IMPORTER, isStaff includes IMPORTER - Headless crawler login: X-Service-Token header bypasses captcha only (rate limit + password checks intact; empty env = no bypass) - docs: doctor-claim.md (new), doctor-import.md, admin.md, doctor.md - tests: DoctorImportTest (6), DoctorClaimTest (11), PersianTextTest (5) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
504 lines
15 KiB
Markdown
504 lines
15 KiB
Markdown
# Doctor API
|
||
|
||
> فیلد `owner_status` (`claimed` | `unclaimed` | `pending_transfer`) به خروجی لیست و جزئیات پزشک
|
||
> اضافه شده است — پروفایل `unclaimed` (ایمپورت نظام پزشکی) در سایت دکمهٔ «تصاحب پروفایل» میگیرد
|
||
> (`docs/api/doctor-claim.md`) و نوبتدهی آنلاینش غیرفعال است.
|
||
|
||
> **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: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": "..." }]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
> ⚠️ **Double-nested:** Frontend extracts with `data?.data?.data`
|
||
|
||
### 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
|
||
|
||
| Field | When schedule exists | When no schedule |
|
||
|-------|---------------------|-----------------|
|
||
| `free_turn` | نزدیکترین روز/ساعت کاری از امروز (مثلاً «دوشنبه ۹:۰۰–۱۳:۰۰») | «نوبت آزادی موجود نیست» |
|
||
| `hours_of_work` | خلاصه ساعتهای روزهای فعال با `\|` جداشده | «برنامه کاری تنظیم نشده» |
|
||
| `active` | `online_booking_enabled && has_active_sessions` | `false` — نوبتدهی غیرفعال |
|
||
|
||
> **نوبتدهی آنلاین غیرفعال:** منبعِ فعال/غیرفعال بودن نوبتدهی آنلاین، فیلد `meta.online_booking_enabled` در `WeeklySchedule` پزشک است. اگر `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:00–13:00",
|
||
"hours_of_work": "شنبه: 09:00–13:00 و 14:00–18:00 | یکشنبه: 09:00–13:00",
|
||
"active": true,
|
||
"owner_status": "claimed"
|
||
}
|
||
],
|
||
"meta": {
|
||
"totalRecords": 50,
|
||
"totalPages": 3,
|
||
"currentPage": 1
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 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:** `ROLE_ADMIN`
|
||
|
||
> **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_AUTH_006` | 403 | Not admin |
|
||
| `ERR_NOT_FOUND_001` | 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 |
|