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>
20 KiB
Clinic API
Prefix:
/api/v1/clinic,/api/v1/clinics
POST /api/v1/clinic
Create a new clinic.
Permission: AUTH — any authenticated user becomes the clinic owner
Request Body (application/json)
{
"name": "کلینیک الوند",
"info": "توضیحات کلینیک",
"address": "تهران، خیابان ولیعصر",
"telephone": "02112345678",
"working_days": "شنبه تا چهارشنبه",
"is_24_7": false,
"latitude": 35.6892,
"longitude": 51.3890,
"state": "تهران",
"city": "تهران",
"image_clinic": [
{ "url": "https://..." }
],
"clinic_logo": "https://...",
"doctors": ["uuid1", "uuid2"],
"specialties": [1, 2],
"doctor_services": [3, 4],
"insurance": [5, 6]
}
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | ✅ | Clinic name |
info |
string | ❌ | Description |
address |
string | ❌ | Full address |
telephone |
string | ❌ | Contact number |
working_days |
string | ❌ | Working days description |
is_24_7 |
boolean | ❌ | Open 24/7 flag |
latitude |
float | ❌ | Latitude for map |
longitude |
float | ❌ | Longitude for map |
state |
string | ❌ | Province name |
city |
string | ❌ | City name |
image_clinic |
object[] | ❌ | Gallery images [{url: "..."}] — max 5; more returns ERR_VALIDATION_001 (422) |
clinic_logo |
string | ❌ | Logo URL |
doctors |
string[] | ❌ | Doctor UUIDs to associate |
specialties |
integer[] | ❌ | Specialty IDs |
doctor_services |
integer[] | ❌ | Service IDs |
insurance |
integer[] | ❌ | Insurance IDs |
social_media |
object | ❌ | Social media URLs — keys: instagram, telegram, aparat, youtube, linkedin. Values are validated as URLs; invalid/empty values are stored as null. |
Response 201
{
"success": true,
"data": {
"data": {
"uuid": "550e8400-...",
"name": "کلینیک الوند",
"info": "...",
"address": "...",
"telephone": "02112345678",
"working_days": "...",
"is_24_7": false,
"latitude": 35.6892,
"longitude": 51.3890,
"state": "تهران",
"city": "تهران",
"images_clinic": [{ "url": "https://..." }],
"clinic_logo": "https://...",
"is_active": true,
"doctors": [],
"specialties": [],
"doctor_services": [],
"insurance": [],
"tags": [],
"created_at": 1717000000
}
}
}
⚠️ Double-nested: Frontend extracts with
data?.data?.data
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing or invalid token |
GET /api/v1/clinic/{uuid}
Get clinic detail.
Permission: PUBLIC
Path Parameters
| Param | Type | Description |
|---|---|---|
uuid |
string (UUID) | Clinic UUID |
Response 200
{
"success": true,
"data": {
"data": {
"id": "233",
"uuid": "550e8400-...",
"name": "کلینیک الوند",
"title": "کلینیک الوند",
"is_active": true,
"phone": "02112345678",
"phone_number": "02112345678",
"logo": "/uploads/clinics/logo/...",
"clinic_logo": "/uploads/clinics/logo/...",
"images_clinic": [{ "url": "/uploads/clinics/gallery/..." }],
"social_media": {
"instagram": "https://instagram.com/clinic.example",
"telegram": "https://t.me/clinic_example",
"aparat": null,
"youtube": null,
"linkedin": null
},
"caption": "توضیحات کلینیک",
"list_bime": [],
"specialties": [{ "uuid": "...", "id": "1", "name": "قلب", "parent": null }],
"services": [],
"clinic_specialty": [{ "uuid": "...", "id": "1", "name": "قلب", "parent": null }],
"doctors": 5,
"doctor_list": null,
"city": [{ "uuid": "...", "id": "132", "name": "یزد", "parent": "100" }],
"state": [{ "uuid": "...", "id": "100", "name": "یزد" }],
"location": "یزد، خیابان اصلی، پلاک 101",
"map": { "latitude": "31.868", "longitude": "54.330" },
"24_7": false,
"field_working_days": "شنبه تا پنجشنبه ۸ تا ۱۸"
}
}
}
city/state/mapare resolved from the clinic's address (DoctorAddresslinked byclinic_id), not from columns on the clinic. Each is an array with a single object (or empty[]if the clinic has no address).doctorsis a count; the actual doctor list comes fromGET /api/v1/clinic/doctor-list/{clinicUuid}(doctor_listhere is alwaysnull).
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | Clinic not found |
PATCH /api/v1/clinic/{uuid}
Update a clinic.
Permission: AUTH — the clinic owner, ROLE_ADMIN, or a member doctor holding clinic_info.update (see Clinic Doctor Permissions)
Path Parameters
| Param | Type | Description |
|---|---|---|
uuid |
string (UUID) | Clinic UUID |
Request Body
Same fields as POST — all optional.
Response 200
Updated clinic object (same structure as GET).
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_FORBIDDEN_001 |
403 | Not the owner |
ERR_NOT_FOUND_001 |
404 | Clinic not found |
GET /api/v1/clinics
List clinics with pagination.
Permission: PUBLIC
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
page |
integer | ❌ | Default: 1 |
limit |
integer | ❌ | Default: 20 |
name |
string | ❌ | Search by clinic name |
city |
integer | ❌ | City id — filters by the clinic address's city |
state |
integer | ❌ | Province id — filters by the clinic address's province |
specialty |
integer | ❌ | Specialty id |
domain |
string | ❌ | دامنهی سایتِ درخواستکننده. اگر دامنهی یک نماینده سراسری باشد، فقط کلینیکهای همان نماینده برمیگردند و city/state نادیده گرفته میشوند؛ دامنه شهری/ناشناخته اثری ندارد |
city/stateare matched against the clinic's address (DoctorAddresslinked byclinic_id), not a field on the clinic itself.
Response 200
{
"success": true,
"data": [
{
"id": "3426",
"uuid": "...",
"name": "کلینیک الوند",
"title": "کلینیک الوند",
"phone": "02112345678",
"phone_number": "02112345678",
"logo": "/uploads/clinics/logo/...",
"clinic_logo": "/uploads/clinics/logo/...",
"images_clinic": [{ "url": "/uploads/clinics/gallery/..." }],
"doctors_count": 4,
"is_active": true,
"created_at": 1781762386,
"city": "تهران",
"state": "تهران",
"specialties": [{ "uuid": "...", "id": "7", "name": "..." }],
"24_7": false,
"field_working_days": "شنبه تا پنجشنبه ۸ تا ۲۰"
}
],
"meta": {
"totalRecords": 30,
"totalPages": 2,
"currentPage": 1
}
}
| Field | Type | Description |
|---|---|---|
clinic_logo / logo |
string|null | Logo path (relative /uploads/... or absolute URL) |
doctors_count |
integer | Number of doctors linked to the clinic |
city |
string|null | City name, resolved from the clinic's address (DoctorAddress) |
state |
string|null | Province name, resolved from the clinic's address (DoctorAddress) |
24_7 |
boolean | Open 24/7 flag |
field_working_days |
string|null | Working days/hours description |
GET /api/v1/clinic/doctor-list/{clinicUuid}
Get doctors associated with a clinic.
Permission: PUBLIC
Path Parameters
| Param | Type | Description |
|---|---|---|
clinicUuid |
string (UUID) | Clinic UUID |
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
page |
integer | ❌ | Default: 1 |
limit |
integer | ❌ | Default: 10, max 50 |
name |
string | ❌ | Filter by doctor name (LIKE) |
specialty |
integer | ❌ | Specialty id |
gender |
string | ❌ | man / woman |
degree |
string | ❌ | expert / general / specialist / subspecialistplus |
active |
0|1 | ❌ | Only doctors with appointments enabled |
sort |
string | ❌ | ASC / DESC by rating (default DESC) |
Filters apply only within this clinic's linked doctors.
⚠️ Double-nested: the doctors array is at
data.data(extract withdata?.data?.data); pagination is atdata.meta.
Response 200
{
"success": true,
"data": {
"data": [
{
"id": "1207",
"uuid": "...",
"name": "دکتر آرمان رضایی",
"gender": "man",
"degree": "specialist",
"img": [],
"specialties": [{ "uuid": "...", "id": "2", "name": "داخلی عمومی" }],
"satisfaction": "96",
"point": "4.8",
"free_turn": "پنجشنبه 09:00–13:00",
"hours_of_work": "شنبه تا چهارشنبه | پنجشنبه",
"active": true,
"city": [
{ "uuid": "7bfb989e-...", "id": "123", "name": "یاسوج", "parent": "23" }
],
"state": [
{ "uuid": "7bfb5705-...", "id": "23", "name": "کهگیلویه و بویراحمد" }
]
}
],
"meta": { "totalRecords": 3, "totalPages": 1, "currentPage": 1 }
}
}
| Field | Type | Description |
|---|---|---|
free_turn |
string | Next available appointment (e.g. پنجشنبه 09:00–13:00), or نوبت آزادی موجود نیست if the doctor has no active weekly schedule |
hours_of_work |
string | Working-days summary, or برنامه کاری تنظیم نشده when unscheduled |
active |
boolean | true only when appointments are enabled and the doctor has an active schedule |
city / state |
array | مکان خودِ پزشک (آدرس شخصی، و در نبودش آدرس کلینیک). آرایه با حداکثر یک عضو؛ پزشک بدون آدرس []. جزئیات و قاعدهٔ انتخاب در doctor.md |
free_turn/hours_of_work/activeare computed from each doctor'sWeeklySchedule(loaded in bulk by the endpoint). Without a schedule they fall back to the "not set" values.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | Clinic not found |
DELETE /api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}
Detach a doctor from a clinic. This removes the clinic↔doctor link (the clinic_doctors association) and the doctor's clinic_doctor_permissions row; it does not delete the doctor or change the doctor's own active appointment flag.
Permission: AUTH — the caller must be ROLE_ADMIN or the owner of this clinic (ROLE_CLINIC whose user owns clinicUuid). Any other authenticated user gets 403.
Path Parameters
| Param | Type | Description |
|---|---|---|
clinicUuid |
string (UUID) | Clinic UUID |
doctorUuid |
string (UUID) | Doctor UUID |
Response 200
{ "success": true, "data": { "message": "پزشک از کلینیک جدا شد" } }
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_ACCESS_DENIED |
403 | Caller is neither an admin nor the clinic owner |
ERR_VALIDATION_002 |
404 | Clinic not found |
ERR_NOT_FOUND_001 |
404 | Doctor not found, or doctor not linked to this clinic |
Clinic Doctor Permissions
Each doctor attached to a clinic has a permission envelope scoped to that clinic only — the doctor's own practice is never affected. Rows live in clinic_doctor_permissions (one per clinic+doctor) and are created lazily with defaults for doctors who joined before this feature existed.
The envelope is always returned in full ({version, resources}); it is never flattened.
{
"version": 1,
"resources": {
"appointments": { "view": true, "create": true, "cancel": true, "update_status": true },
"appointment_settings": { "view": true, "update": true },
"patients": { "view": true, "create": true, "update": true, "delete": false },
"payments": { "view": true, "create": false, "update": false, "delete": false },
"services": { "view": true, "update": false },
"clinic_info": { "view": true, "update": false }
}
}
active: false revokes everything at once regardless of the individual flags. The clinic owner and ROLE_ADMIN bypass all checks and can never be locked out.
Unknown resources and unknown actions in a PATCH body are silently ignored, so a client cannot invent permission keys.
GET /api/v1/admin/clinic/{clinicUuid}/doctor-permissions
List the permission rows of every doctor in the clinic.
Permission: AUTH — clinic owner or ROLE_ADMIN
Response 200
{
"success": true,
"data": [
{
"uuid": "ce200cde-826d-11f1-b923-c282b864cdcc",
"clinic_uuid": "41e325c4-e825-4067-8438-5d828ecaee09",
"doctor_uuid": "bcabb3a8-cae3-45ec-876c-548f9c1e1569",
"doctor_name": "دکتر تست",
"active": true,
"permissions": { "version": 1, "resources": { "...": {} } },
"created_at": 1784352916,
"updated_at": 1784352916
}
]
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_ACCESS_DENIED |
403 | Neither admin nor the clinic owner |
ERR_NOT_FOUND_001 |
404 | Clinic not found |
GET /api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions
Read one doctor's permissions. Creates the row with defaults if it does not exist yet.
Permission: AUTH — clinic owner or ROLE_ADMIN
Path Parameters
| Param | Type | Description |
|---|---|---|
clinicUuid |
string (UUID) | Clinic UUID |
doctorUuid |
string (UUID) | Doctor UUID — must already be attached to this clinic |
Response 200
Single permission object (same shape as one item of the list above).
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_ACCESS_DENIED |
403 | Neither admin nor the clinic owner |
ERR_NOT_FOUND_001 |
404 | Clinic not found, or doctor not attached to this clinic |
PATCH /api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}/permissions
Update one doctor's permissions. Deep merge — only the resources/actions present in the body change; everything else keeps its current value.
Permission: AUTH — clinic owner or ROLE_ADMIN
Request Body
{
"permissions": { "resources": { "payments": { "create": true } } },
"active": true
}
| Field | Type | Required | Description |
|---|---|---|---|
permissions |
object | ❌ | {resources: {<resource>: {<action>: bool}}}. The bare {<resource>: {...}} form is also accepted. |
active |
bool | ❌ | false revokes all access to this clinic |
Response 200
Updated permission object.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_001 |
401 | Missing token |
ERR_ACCESS_DENIED |
403 | Neither admin nor the clinic owner |
ERR_NOT_FOUND_001 |
404 | Clinic not found, or doctor not attached to this clinic |
ERR_VALIDATION_001 |
422 | permissions is not an object |
POST /file/upload/clinic_pro/clinic/field_clinic_logo
Upload clinic logo.
Permission: AUTH
Request
Content-Type: multipart/form-data
| Field | Type | Required | Max Size |
|---|---|---|---|
file |
binary | ✅ | 5MB |
Response 200
{
"success": true,
"data": {
"url": "https://clinic-pro.ddev.site/uploads/clinic/logo_abc.jpg",
"uuid": "...",
"filename": "logo_abc.jpg",
"filemime": "image/jpeg",
"filesize": 102400
}
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_FILE_001 |
422 | Invalid file type |
ERR_AUTH_001 |
401 | Missing token |
POST /file/upload/clinic_pro/clinic/field_image_clinic
Upload clinic gallery image.
Permission: AUTH
Request
Content-Type: multipart/form-data
| Field | Type | Required | Max Size |
|---|---|---|---|
file |
binary | ✅ | 5MB |
Response 200
{
"success": true,
"data": {
"url": "https://clinic-pro.ddev.site/uploads/clinic/gallery_abc.jpg",
"uuid": "...",
"filename": "gallery_abc.jpg",
"filemime": "image/jpeg",
"filesize": 307200
}
}
After uploading, use the returned
urlinsideimage_clinic: [{ "url": "..." }]when calling PATCH clinic.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_FILE_001 |
422 | Invalid file type |
ERR_AUTH_001 |
401 | Missing token |
Clinic Address Management
GET /api/v1/clinic/{clinicUuid}/addresses
Permission: Public
Returns all addresses registered for a clinic (type=clinic entries).
Response 200
{
"success": true,
"data": [
{
"id": "12",
"uuid": "abc-123",
"type": "clinic",
"clinic_id": "5",
"name": "شعبه مرکزی",
"address": "تهران، خیابان ولیعصر...",
"telephone": "02112345678",
"map": { "latitude": "35.699", "longitude": "51.337" },
"city": { "id": "1", "name": "تهران" },
"province": { "id": "8", "name": "تهران" }
}
]
}
POST /api/v1/clinic/{clinicUuid}/address
Permission: Clinic owner or ROLE_ADMIN
Creates a new address for the clinic. The address will appear in available-locations for doctors belonging to this clinic.
Request
{
"name": "شعبه مرکزی",
"address": "تهران، خیابان ولیعصر...",
"telephone": "02112345678",
"latitude": 35.699,
"longitude": 51.337,
"city_id": 123,
"province_id": 7
}
| Field | Type | Required |
|---|---|---|
name |
string | ❌ |
address |
string | ❌ |
telephone |
string | ❌ |
latitude |
float | ❌ |
longitude |
float | ❌ |
city_id |
integer | ❌ |
province_id |
integer | ❌ |
Response 201
{ "success": true, "data": { "id": "12", "uuid": "...", "type": "clinic", ... } }
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_AUTH_006 |
403 | Not the clinic owner |
ERR_VALIDATION_002 |
404 | Clinic not found |
PATCH /api/v1/clinic/{clinicUuid}/address/{addressUuid}
Permission: Clinic owner or ROLE_ADMIN
Updates an existing clinic address. Same body fields as POST (all optional).
Response 200
{ "success": true, "data": { ... } }
DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}
Permission: Clinic owner or ROLE_ADMIN
Deletes a clinic address.
A clinic must retain at least one address — attempting to delete the last address returns
409.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_CONFLICT_001 |
409 | Cannot delete the last address |
ERR_VALIDATION_002 |
404 | Address or clinic not found |
ERR_AUTH_006 |
403 | Not the clinic owner |
Removed endpoint
POST /api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid} — removed. Use clinic address management endpoints instead.