A doctor's office number sat next to the address on the public profile and in the anonymous API payload, so harvesting the phone number of every practice in the country was one unauthenticated request away. Street address and map coordinates stay public — a patient needs those to find the place — but the phone is now opt-in per caller: DoctorAddress::toArray() and the clinic serializers only emit it when told to, and the public doctor/clinic endpoints tell them to only when the caller may edit that profile (the same can_edit they already compute). Owner-facing address CRUD keeps returning it unchanged. The patient still gets the number where it is actually useful — their own appointment. That payload also stops guessing: it used to serialise the doctor's *first* address, so a booking made at the clinic or at a second office showed the wrong street entirely. It now resolves the address recorded on the appointment itself, which works the same for a personal office and a clinic branch, and falls back to the clinic's own number when the address has none. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
754 lines
29 KiB
Markdown
754 lines
29 KiB
Markdown
# 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": "شنبه تا پنجشنبه ۸ تا ۱۸"
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
> 🔒 `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.
|