The public list GET /api/v1/doctors only excluded inactive doctors when an explicit `active` filter was passed; with no param it returned everyone (deactivated doctors just ranked lower). Deactivated doctors (admin toggled active_doctor_appointment off) leaked onto nobat724. - DoctorRepository::findWithFilters: default (no `active` param) now filters activeDoctorAppointment = true. The active=1 (bookable) and active=0 (admin, inactive-only) escape hatches are unchanged. - Doctor::toDetailArray: expose raw `is_active` (= activeDoctorAppointment, independent of schedule) so public clients can 404 a deactivated doctor's profile page; distinct from `active` (flag && has_schedule). - Tests + docs/api/doctor.md updated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
24 KiB
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 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 | ✅ | نام پزشک بدون عنوان. پیشوند «دکتر» سمت سرور با 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
{
"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
Path Parameters
| Param | Type | Description |
|---|---|---|
uuid |
string (UUID) | Doctor UUID |
Response 200
{
"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
{
"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در صورت وجود برنامه حفظ میشود. تا وقتی حتی یک برنامه روشن و دارای روز فعال باشد، همان مبنا قرار میگیرد.چنین پزشکی (همه برنامهها خاموش، ولی فلگ
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 درخواستی» |
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 نادیده گرفته میشوند؛ دامنه شهری/ناشناخته اثری ندارد |
gender |
string | ❌ | man یا woman |
degree |
string | ❌ | expert, general, specialist, subspecialistplus |
name |
string | ❌ | جستجوی LIKE روی نام پزشک |
sort |
string | ❌ | ASC یا DESC (پیشفرض DESC) — مرتبسازی ثانویه بر اساس doctorRate |
active |
0/1 |
❌ | 1 → فقط پزشکان دارای نوبت (تعریف پایین). 0 → فقط پزشکانی که فلگ active_doctor_appointment آنها خاموش است (کاربرد ادمین). بدون این پارامتر → فقط پزشکان فعال (active_doctor_appointment روشن)؛ پزشکان غیرفعال هرگز در لیست عمومی نمیآیند |
مرتبسازی و تعریف «دارای نوبت»
پزشک دارای نوبت یعنی هر سه شرط برقرار باشد (همان تعریفی که فیلد active هر آیتم پاسخ را میسازد):
- فلگ
active_doctor_appointmentروشن، WeeklyScheduleثبتشده با حداقل یک سشنactive: true،meta.online_booking_enabledبرابرfalseنباشد.
لیست همیشه اول پزشکان دارای نوبت را نشان میدهد و بعد بقیه را؛ داخل هر گروه بر اساس doctorRate و پارامتر sort مرتب میشود.
Response 200
{
"success": true,
"data": [
{
"uuid": "...",
"name": "علی احمدی",
"display_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هستند.
اعتبارسنجی نام پزشک
name نمیتواند شمارهتلفن (^0?9\d{9}$) یا مقدار آزمایشی (test، تست، -، null) باشد. این مقادیر در هر مسیر نوشتن با 422 رد میشوند — API عمومی، پنل ادمین، import و دعوت کلینیک — چون گارد روی خودِ Entity نشسته است (App\Shared\Util\DisplayName).
دلیل: نام پزشک در <title> و نتایج جستوجوی سایت عمومی رندر میشود؛ رکوردی با نام «09390039833» یک صفحهٔ بیارزش ایندکسشدنی میسازد.
دعوت پزشک توسط کلینیک، اگر نام ارسال نشود، دیگر شمارهٔ موبایل را بهعنوان نام نمینشاند — برچسب خنثای «پزشک دعوتشده» میگیرد تا خود پزشک پروفایلش را claim کند. (ریشهٔ آلودگی تولیدی همین بود.)
فرمان ممیزی رکوردهای موجود:
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 — 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 |