feat(migrations): add clinic_id context to weekly_schedules, date_overrides, and holidays

- Introduced clinic_id to weekly_schedules, date_overrides, and holidays to differentiate between personal and clinic schedules.
- Updated unique constraints and indexes to accommodate the new clinic context.

feat(command): create AssignScheduleClinicCommand to move schedules

- Added a command to move a doctor's personal weekly schedule into a clinic context.
- Implemented checks to ensure sessions align with the target clinic.

feat(context): implement EntityContext and EntityContextResolver

- Created EntityContext to represent the effective working environment of a request (doctor or clinic).
- Developed EntityContextResolver to determine the execution context based on user roles and active contexts.

test: add ServiceModeContextTest for appointment scheduling

- Implemented tests to ensure service booking respects clinic and personal contexts.
- Verified that financial data is omitted in clinic contexts in InvitedDoctorDashboardScopeTest.
This commit is contained in:
hamed
2026-07-18 13:32:56 +03:30
parent 2553b45990
commit f1258d206d
28 changed files with 2126 additions and 276 deletions
+80 -6
View File
@@ -5,24 +5,47 @@
Doctors configure their availability via three resources: **weekly schedule**, **date overrides**, and **holidays**.
## Booking context (`clinic_uuid`)
Every endpoint in this file operates inside **one booking context**, selected by the optional
`clinic_uuid` parameter (query string on `GET`/`DELETE`, body field on `POST`/`PATCH`):
| `clinic_uuid` | Context | Services usable | Addresses selectable |
|---|---|---|---|
| omitted / `null` | the doctor's **personal practice** | `entity_type='doctor'` | the doctor's own `personal` addresses |
| a clinic uuid | that **doctor inside that clinic** | `entity_type='clinic'` | that clinic's addresses |
A doctor holds **one schedule per context** — a personal one plus one per clinic — and they are
fully independent: separate sessions, separate `booking_mode` lock, separate date overrides.
Services never cross the boundary (they are polymorphic on `service_sections.entity_type`).
If the doctor is not a member of the given clinic → `422 ERR_VALIDATION_001`
(«این پزشک عضو کلینیک انتخاب‌شده نیست»). Unknown clinic → `404 ERR_VALIDATION_002`.
## Access rule
All 14 endpoints in this file share a single check. Given the target doctor (resolved from the path/body uuid, or from the parent schedule/override/holiday), access is granted when the caller is:
Given the target doctor and the resolved context, access is granted when the caller is:
1. `ROLE_ADMIN`, **or**
2. the doctor themselves, **or**
3. the **owner of a clinic** the doctor belongs to, **or**
4. a **doctor member of that clinic** holding the `appointment_settings` permission — `view` for `GET`, `update` for `POST`/`PATCH`/`DELETE` (see `docs/api/clinic.md` *Clinic Doctor Permissions*)
3. in a **clinic context only**, someone holding that clinic's `appointment_settings` permission —
`view` for `GET`, `update` for `POST`/`PATCH`/`DELETE` (see `docs/api/clinic.md`
*Clinic Doctor Permissions*). The clinic owner always passes this check.
Anything else → `403 ERR_AUTH_006`. This is what lets the clinic panel manage every member doctor's booking settings from `تنظیمات → نوبت‌دهی`, one tab per doctor, using the same endpoints the doctor's own panel calls.
Anything else → `403 ERR_AUTH_006`.
A doctor's own settings are never affected by clinic permissions — rule 2 short-circuits before any permission lookup.
> **Breaking change (2026-07):** a clinic owner can no longer read or write a member doctor's
> **personal** schedule. Without `clinic_uuid` the request targets the personal context, which only
> the doctor and an admin may touch. The clinic panel must send `clinic_uuid`; the admin SPA already
> does (`ScheduleSection` takes a `clinicUuid` prop).
---
## Weekly Schedule
Each doctor has **one** weekly schedule (upsert). The schedule is keyed by **day index** (0=Saturday ... 6=Friday), each day containing a `sessions` array.
Each doctor has **one weekly schedule per context** (upsert keyed by `doctor_id` + `clinic_id`).
The schedule is keyed by **day index** (0=Saturday ... 6=Friday), each day containing a `sessions`
array.
### Day Index Convention
@@ -45,11 +68,16 @@ Create or update the weekly schedule for a doctor (upsert).
**Permission:** `AUTH` — see [Access rule](#access-rule)
> **الزام آدرس:** هر session با `active=true` باید `location_id` (آدرس مطب/کلینیک) داشته باشد. در غیر این صورت `422 ERR_VALIDATION_001` («برای هر شیفت فعال باید آدرس انتخاب شود»). این آدرس هنگام رزرو خودکار روی نوبت ذخیره می‌شود.
>
> **الزام محیط:** آدرس انتخاب‌شده باید به همان context تعلق داشته باشد. آدرس کلینیک در محیط شخصی (و برعکس) → `422 ERR_VALIDATION_001` («آدرس انتخاب‌شده متعلق به این کلینیک نیست»).
>
> **نوبت‌دهی سرویسی:** با `meta.booking_mode = "service"` صاحبِ همان context باید حداقل یک سرویس با `bookable = true` داشته باشد؛ وگرنه `422 ERR_VALIDATION_001` روی فیلد `booking_mode`. پیام در محیط کلینیک به کلینیک اشاره می‌کند.
### Request Body (`application/json`)
```json
{
"doctor_uuid": "550e8400-e29b-41d4-a716-446655440000",
"clinic_uuid": null,
"schedule": {
"0": {
"sessions": [
@@ -648,3 +676,49 @@ Returns all locations a doctor can assign as `location_id` in their schedule ses
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_002` | 404 | Doctor not found |
---
## Context additions (2026-07)
### Response fields
`WeeklySchedule.toArray()` now also returns:
| Field | Type | Meaning |
|---|---|---|
| `clinic_uuid` | `string\|null` | the clinic this schedule belongs to; `null` = personal practice |
| `context` | `"personal" \| "clinic"` | convenience mirror of the above |
`DateOverride.toArray()` returns the same two fields. `Holiday.toArray()` returns `clinic_uuid`
plus `scope` (`"global" | "clinic"`), and the list endpoint adds `editable` (see below).
### Holidays are global by default
A holiday means "the doctor is not there", which is a physical fact — so unlike schedules and date
overrides it is **not** per-context by default:
| `clinic_id` | Meaning |
|---|---|
| `NULL` | the doctor is absent **everywhere** — applies to the personal practice and every clinic |
| set | the doctor is absent in that clinic only |
`GET /holidays/list/{doctorUuid}?clinic_uuid=…` returns the **union**: the clinic's own holidays plus
the doctor's global ones. Global rows come back with `editable: false` — a clinic must see that the
doctor is away but may not delete that fact.
`POST /holidays` **without** `clinic_uuid` creates a global holiday and is restricted to the doctor
themselves and admins → otherwise `403 ERR_ACCESS_DENIED` («کلینیک فقط می‌تواند تعطیلی مخصوص خودش را
ثبت کند»). A clinic closing for *all* its doctors is not expressible in this model and needs a
separate `ClinicHoliday` entity — **not implemented**.
### Date overrides are always per-context
`GET /date-override/list/{doctorUuid}?clinic_uuid=…` returns only that context's overrides — no
union, because an override changes working hours and working hours are themselves per-context.
### `GET /available-locations/{doctorUuid}`
Now takes `?clinic_uuid=`. Without it, only the doctor's `personal` addresses are returned; with it,
only that clinic's addresses. The two sets are never merged (they used to be).