# 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": [{ "uuid": "...", "id": "176", "name": "تامین اجتماعی", "logo_url": "/uploads/insurances/logo/2026-08/tamin.png" }],
      "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": "شنبه تا پنجشنبه ۸ تا ۱۸"
    }
  }
}
```

> `list_bime[].logo_url` مسیر نسبی روی همین API است (`/uploads/...`) و کلاینت باید آن را با دامنهٔ API کامل کند. بیمهٔ بدون لوگو `null` می‌گیرد، نه کلید غایب.


> 🔒 `phone`/`phone_number` are `null` unless the caller may edit the clinic (`can_edit: true`). The number is not public data; the patient sees the venue phone on their own appointment instead.
>
> `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. `null` for anyone who cannot edit the clinic, and always `null` in the public list. |
| `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: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](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.
