Files
clinicpro/docs/api/clinic.md
T
hamedandClaude Opus 5 fb1cb20c11 feat(representation): let registering reps edit their doctors and clinics
A representative could create a doctor or clinic but not finish its profile:
PATCH /api/v1/doctor/{uuid} accepted only the doctor or an admin, and the
clinic gate ran through ClinicDoctorPermissionChecker, which asks about clinic
membership — a representative is not a member. Onboarding stopped at an empty
public record.

Grant is permanent while representation_id points at the rep, and limited to
content: RepresentationEditPolicy holds ownership plus the field whitelist.
Sending a key outside it aborts the whole request with 403 and names the field,
rather than filtering the payload silently, so a rep never believes a change
saved when it did not. medical_system_code, `active` and clinic `doctors` stay
out — credential, and membership, belong to the record's owner. `active` already
has a dedicated rep endpoint.

ClinicDoctorPermissionChecker is untouched on purpose; folding a second concept
into it would give it two reasons to change.

Doctor/clinic detail responses now carry can_edit, computed by the same policy
the PATCH gate uses, so the panel reads authorization instead of re-deriving it
and drifting. Both endpoints stay public: no token means can_edit false and an
otherwise unchanged payload, which is what nobat724_front consumes.

Address endpoints follow the same policy. createAddress now resolves its target
from an explicit doctor_uuid instead of findByUser first — a representative who
also has a doctor profile was silently writing the address onto their own.

Every rep edit writes one app_log row (channel representation_edit) recording
who, what, and which field names — never values. Owner and admin edits write
nothing, keeping /admin/logs readable.

Docs corrected where they already disagreed with the code: 403/404 error codes
on both PATCH routes, a non-existent "cannot delete the last clinic address"
409, and the missing gallery-size 422.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:50:17 +03:30

752 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`)
```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`
```json
{
"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` — a token is optional and only affects `can_edit`.
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | string (UUID) | Clinic UUID |
### `can_edit`
The payload carries `can_edit: boolean` — whether the **caller** may `PATCH` this clinic. It is `true`
for the owner, `ROLE_ADMIN`, a member doctor holding `clinic_info.update`, and the registering
representative; `false` for everyone else and always `false` without a token. It is computed by the
same checks the `PATCH` gate uses, so clients must read it rather than re-deriving the rule.
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/clinic/{uuid}`.
### Response `200`
```json
{
"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_number` are all resolved from the clinic's **address** (`DoctorAddress` linked by `clinic_id`), not from columns on the clinic. `location` and `phone`/`phone_number` fall back to the deprecated `clinics.address` / `clinics.telephone` columns 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). `doctors` is a **count**; the actual doctor list comes from `GET /api/v1/clinic/doctor-list/{clinicUuid}` (`doctor_list` here is always `null`).
### معنای `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`, a member doctor holding `clinic_info.update`
(see **Clinic Doctor Permissions**), or the **registering representative** (a `ROLE_REPRESENTATION`
user whose `Representation.id` equals the clinic'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. Owner, admin and member-doctor
access is unchanged and unrestricted. The representative path deliberately bypasses
`ClinicDoctorPermissionChecker`: that class answers "is this doctor a member of this clinic", and a
representative is not a member at all.
### Path Parameters
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | string (UUID) | Clinic 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.
`name` · `info` · `address` · `telephone` · `working_days` · `24_7` · `latitude` · `longitude` ·
`practice_domain_uuid` · `state` · `city` · `social_media` · `image_clinic` · `clinic_logo` ·
`specialties` · `doctor_services` · `insurance`
Notably excluded: `doctors`. Which doctors belong to a clinic is a membership decision for the owner,
not for the representative who registered it. `specialties` / `doctor_services` / `insurance` **are**
allowed — they are the clinic's public-facing catalogue, the mirror of the same keys on the doctor
whitelist, and are not membership.
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 owner, a member doctor, or an admin write no such row.
### Request Body
Same fields as POST — all optional — plus:
| Field | Type | Description |
|-------|------|-------------|
| `practice_domain_uuid` | string (UUID) \| `""` \| `null` | حوزهٔ فعالیت کلینیک. رشتهٔ خالی یا `null` یعنی «پاک کن»؛ نبودنِ کلید یعنی «دست نزن». uuid ناشناس ۴۲۲ می‌گیرد، نه رد شدن بی‌صدا. ← [practice-domain.md](./practice-domain.md) |
### Response `200`
Updated clinic object (same structure as GET). Carries `practice_domain` — the full domain object, or
`null` when unset:
```json
{"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 | Secretary without `clinic_info.update` — thrown by the pre-check, before the clinic is even loaded |
| `ERR_AUTH_006` | 403 | Not the owner, not an admin, not a permitted member doctor, 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 | Clinic not found |
| `ERR_VALIDATION_001` | 422 | `image_clinic` بیش از ۵ عکس دارد |
| `ERR_VALIDATION_002` | 422 | `practice_domain_uuid` به هیچ حوزه‌ای اشاره نمی‌کند |
Real response for a whitelist violation (captured from a functional test run, not hand-written):
```json
{
"success": false,
"data": null,
"errors": [
{
"code": "ERR_AUTH_006",
"message": "نماینده اجازهٔ تغییر این فیلد را ندارد",
"field": "doctors"
}
]
}
```
---
## 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`/`state` are matched against the clinic's address (`DoctorAddress` linked by `clinic_id`), not a field on the clinic itself.
### Response `200`
```json
{
"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 with `data?.data?.data`); pagination is at `data.meta`.
### Response `200`
```json
{
"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:0013: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:0013: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](doctor.md#city--state-در-پاسخ-لیست) |
> `free_turn`/`hours_of_work`/`active` are computed from each doctor's `WeeklySchedule` (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`
```json
{ "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](permission.md). این کلاس با منشی مشترک است، پس هر دو نقش دقیقاً یک فهرست از منابع و اکشن‌ها دارند؛ فقط **پیش‌فرض‌ها** فرق می‌کنند. تا پیش از این `services` برای پزشکِ عضو فقط `view/update` داشت و `create`/`delete` اصلاً قابل ذخیره نبود.
منبعی که بعد از ساختِ یک ردیف به رجیستری اضافه شود، هنگام خواندن **پیش‌فرضِ نقش** را می‌گیرد نه `false`، پس migration داده لازم نیست. توجه: حذفِ یک کلید از JSON یعنی «پیش‌فرض را بگیر»، نه «ممنوع» — برای ممنوع‌کردن باید `false` صریح ذخیره شود.
The envelope is always returned in full (`{version, resources}`); it is never flattened.
```json
{
"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 },
"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` حالا در رجیستری هستند ولی پیش‌فرضشان برای پزشکِ عضو خاموش است — عملیاتِ مالکِ کلینیک‌اند.
**خواندنِ پرونده و تگ.** `PatientController::resolveScope` و `TenantTagController::guardTagView` تا پیش از این فقط منشی را بررسی می‌کردند، پس پزشکِ عضو با `patients.view` خاموش به‌جای `403` یک `200` با فهرست **خالی** می‌گرفت (نشتی نبود — tenant scoping خالی‌اش می‌کرد — ولی یک مجوز دو رفتار داشت). حالا هر دو نقش قرینه‌اند. تگ‌ها مثل قبل با `tags.view` **یا** `patients.view` باز می‌شوند، برای هر دو نقش.
**اعمال (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`
```json
{
"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
```json
{
"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`
```json
{
"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`
```json
{
"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 `url` inside `image_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`
```json
{
"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, `ROLE_ADMIN`, or the clinic's registering representative
Creates a new address for the clinic. The address will appear in `available-locations` for doctors belonging to this clinic.
> A clinic may hold only one address — posting a second one returns `409` (`ERR_CONFLICT_001`). Use PATCH to change it.
#### Request
```json
{
"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`
```json
{ "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, `ROLE_ADMIN`, or the clinic's registering representative
Updates an existing clinic address. Same body fields as POST (all optional). The address is content,
not membership, so no field whitelist applies to representatives here.
#### Response `200`
```json
{ "success": true, "data": { ... } }
```
---
### `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}`
**Permission:** Clinic owner, `ROLE_ADMIN`, or the clinic's registering representative
Deletes a clinic address. There is no "must keep at least one" guard — the last address can be deleted.
#### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_002` | 404 | Address or clinic not found |
| `ERR_AUTH_006` | 403 | Not the owner, not an admin, and not the registering representative |
---
### Removed endpoint
`POST /api/v1/clinic-pro/doctor-address/from-clinic/{clinicUuid}`**removed**. Use clinic address management endpoints instead.