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:
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user