The public doctor list had no location field, so multi-domain consumers could not tell which city domain owns a doctor. nobat724_front's sitemap worked around this by fetching the list once per city (35 sweeps) and subtracting, costing ~13s to build the root sitemap. Location is resolved in bulk by DoctorRepository::findLocationsByDoctors using the same rule the city_id/state_id filter applies: the doctor's own address first, falling back to the address of a clinic they belong to. Without the clinic fallback a doctor could match city_id=X yet report no city, which would break the sitemap's per-domain partitioning. city/state are arrays with at most one entry, matching the shape already used by the doctor detail response and the clinic list. A doctor with no address reports [] rather than null. Multi-location doctors get a single primary city, mirroring the canonical rule on the public site. Also surface the applied page size as meta.limit. Repositories silently clamp limit to 50, which previously made clients believe pagination had ended early — this is what truncated the sitemap to 50 doctors. The clinic doctor-list endpoint gets the same location data so both endpoints agree. Location resolution costs at most 2 queries regardless of page size, asserted directly against the repository rather than through the endpoint, since the endpoint carries a pre-existing specialties N+1 in findWithFilters that is unrelated to this change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
19 KiB
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 clean404instead of a500.
POST /api/v1/doctor
Create a doctor profile for the authenticated user.
Permission: AUTH — any authenticated user
Request Body (application/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
{
"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
{
"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": "..." }],
"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
{
"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
clinicsarray (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: 10. حداکثر ۵۰ — مقادیر بزرگتر بیصدا به ۵۰ کاهش مییابند. مقدار واقعاً اعمالشده در meta.limit برمیگردد؛ برای پیمایش کامل به meta.totalPages تکیه کن، نه به «تعداد آیتم کمتر از limit درخواستی» |
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
{
"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",
"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هستند.
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 — 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. |
{
"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 theirtenant_service_coverages) is purged in the same request — these reference the doctor via a polymorphicentity_idwith no DB FK, so the cleanup is enforced in the application.
Guard: a doctor with existing appointments cannot be deleted (the
appointments.doctor_idFK would otherwise raise a500). The endpoint pre-checks and returns409 ERR_CONFLICT_001instead.
Path Parameters
| Param | Type | Description |
|---|---|---|
uuid |
string (UUID) | Doctor UUID |
Response 200
{
"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
{
"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
{
"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
{
"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
{
"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
{ "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 |