`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>
31 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 — 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
{
"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 درخواستی» |
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 هر آیتم پاسخ را میسازد):
- فلگ
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": "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 کند. (ریشهٔ آلودگی تولیدی همین بود.)
فرمان ممیزی رکوردهای موجود:
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. |
{
"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):
{
"success": false,
"data": null,
"errors": [
{
"code": "ERR_AUTH_006",
"message": "نماینده اجازهٔ تغییر این فیلد را ندارد",
"field": "medical_system_code"
}
]
}
{
"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 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",
"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
{
"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
{
"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
{ "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).
{
"success": true,
"data": [
{ "uuid": "…", "name": "کلینیک تخصصی مهر", "active": true }
]
}
۴۰۳: منشیِ بدون مجوز appointment_settings.view.