clinic.md's default envelope is regenerated from the running app, so it now shows all 17 resources instead of 13, including services with its full create/delete actions. Both role docs point at permission.md for the shared registry and spell out the merge rule that makes new resources work on existing rows: deleting a key means "take the default", not "deny" — denying requires an explicit false. A systematic sweep over every gated route with all permissions off found two places where the docs claimed enforcement that does not exist: - GET /api/v1/subscription/my returns 200 with every permission off. Only trial is gated. - ServiceCatalogController has no gate at all. Both are pre-existing and both are left as-is rather than half-fixed: their endpoints are also consumed by the booking and subscription flows, where a hard gate would break secretaries who legitimately need them. The docs now say so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
25 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, and is
granted ROLE_CLINIC on success.
One clinic per user. ClinicRepository::findByUser() (which resolves the
caller's working context) is a findOneBy, so a second clinic would be
unreachable data — the request is rejected instead.
Errors:
| Code | HTTP | توضیح |
|---|---|---|
| ERR_VALIDATION_001 | 422 | name خالی است (field: name) |
| ERR_CONFLICT_001 | 409 | این کاربر از قبل مالک یک کلینیک است |
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/map/location/phone/phone_numberare all resolved from the clinic's address (DoctorAddresslinked byclinic_id), not from columns on the clinic.locationandphone/phone_numberfall back to the deprecatedclinics.address/clinics.telephonecolumns only when the address record has no value — reading them from different rows made one response describe two different places. 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).
معنای is_active
is_active: false یعنی «موقتاً غیرفعال»، نه «حذفشده». تصمیم صریح، چون رکورد و نوبتهای تاریخیاش باقی میمانند و کلینیک ممکن است دوباره فعال شود.
پیامدها:
- کلینیک غیرفعال همچنان از API برمیگردد و لینک مستقیمش ۲۰۰ میدهد (نه ۴۰۴/۴۱۰) تا لینکهای موجود نشکنند.
- سایت عمومی همان صفحه را
noindexمیکند و از sitemap بیرون میگذارد (nobat724_front/lib/entityQuality.js→isThinClinic). - اگر روزی معنای «حذفشده» لازم شد، باید فیلد جداگانهای اضافه شود — نه بازتعریف این یکی.
نام کلینیک
name نمیتواند شمارهتلفن یا مقدار آزمایشی (test، تست، -) باشد؛ این مقادیر با 422 رد میشوند (App\Shared\Util\DisplayName). null مجاز است و یعنی «هنوز نامگذاری نشده».
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | Clinic not found |
ERR_VALIDATION_001 |
422 | نام کلینیک شمارهتلفن یا مقدار آزمایشی است |
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 — plus:
| Field | Type | Description |
|---|---|---|
practice_domain_uuid |
string (UUID) | "" | null |
حوزهٔ فعالیت کلینیک. رشتهٔ خالی یا null یعنی «پاک کن»؛ نبودنِ کلید یعنی «دست نزن». uuid ناشناس ۴۲۲ میگیرد، نه رد شدن بیصدا. ← practice-domain.md |
Response 200
Updated clinic object (same structure as GET). Carries practice_domain — the full domain object, or
null when unset:
{"uuid":"8c7bfd18-9159-11f1-b98b-f28fd8aa5db5","code":"beauty","name":"کلینیک زیبایی","sort_order":0,"active":true}
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 |
ERR_VALIDATION_002 |
422 | practice_domain_uuid به هیچ حوزهای اشاره نمیکند |
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) |
phone / phone_number |
string|null | Contact number from the clinic's address (DoctorAddress), falling back to the deprecated clinics.telephone column |
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.
مجموعهٔ منابع را App\Shared\Security\PermissionCatalog تعیین میکند و از GET /api/v1/permission-catalog هم خوانده میشود — permission.md. این کلاس با منشی مشترک است، پس هر دو نقش دقیقاً یک فهرست از منابع و اکشنها دارند؛ فقط پیشفرضها فرق میکنند. تا پیش از این services برای پزشکِ عضو فقط view/update داشت و create/delete اصلاً قابل ذخیره نبود.
منبعی که بعد از ساختِ یک ردیف به رجیستری اضافه شود، هنگام خواندن پیشفرضِ نقش را میگیرد نه false، پس migration داده لازم نیست. توجه: حذفِ یک کلید از JSON یعنی «پیشفرض را بگیر»، نه «ممنوع» — برای ممنوعکردن باید false صریح ذخیره شود.
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 },
"patients": { "view": true, "create": true, "update": true, "delete": false },
"treatment": { "view": true, "update": true },
"payments": { "view": true, "create": false, "update": false, "delete": false },
"insurances": { "view": true, "create": false, "update": false, "delete": false },
"addresses": { "view": true, "create": false, "update": false, "delete": false },
"clinic_info": { "view": true, "update": false },
"services": { "view": true, "create": false, "update": false, "delete": false },
"inventory": { "view": false, "create": false, "update": false, "delete": false },
"staff": { "view": false, "create": false, "update": false, "delete": false },
"tags": { "view": false, "create": false, "update": false, "delete": false },
"discounts": { "view": false, "create": false, "update": false, "delete": false },
"sms": { "view": false, "create": false, "update": false, "delete": false },
"appointment_settings": { "view": true, "update": true },
"resources": { "view": true, "create": true, "update": true, "delete": true },
"clinic_doctors": { "view": false, "create": false, "update": false, "delete": false },
"subscription": { "view": false, "create": 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 (اعتبارسنجی از PermissionCatalog::filterPatch)، so a client cannot invent permission keys. subscription و clinic_doctors حالا در رجیستری هستند ولی پیشفرضشان برای پزشکِ عضو خاموش است — عملیاتِ مالکِ کلینیکاند.
اعمال (enforcement): همهٔ منابع در بکاند enforce میشوند. نقطهٔ واحد App\Clinic\Security\ClinicDoctorAccessChecker (denyUnlessGranted / memberClinicId) که فقط پزشکِ عضوِ کلینیک در محیطِ فعالِ کلینیک را محدود میکند؛ مالک/ادمین/منشی/پزشکِ مطبِ شخصی دستنخورده عبور میکنند. کنترلرهایی که tenant را نقشمحور حل میکنند (Inventory/Tag/Staff/Discount/Sms) با memberClinicId پزشکِ عضو را به دادهٔ کلینیک میبرند (نه مطبِ شخصی). نبودِ مجوز → 403. در پنل، سایدبار/Route/دکمههای CRUD با usePermissions().can برای محیطِ scope=clinic گِیت میشوند.
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.