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).
+89
View File
@@ -685,3 +685,92 @@ Extra optional body fields: `service_section_uuid`, `service_item_uuid`, `staff_
### GET `/api/v1/my/appointments` (extended)
New query param `reserve=1` → returns only reserve-list entries; without it only regular slot bookings are returned. Each row now also includes: `patient_uuid`, `is_reserve`, `deposit_required`, `deposit_amount_rials`, `note`, `service_section`, `service_item`, `staff` (each `{uuid, name|full_name}` or null).
---
## Booking context (2026-07)
A doctor may now hold several booking schedules — one for the personal practice and one per clinic.
Every public booking endpoint therefore accepts an optional **`clinic_uuid`**:
| Endpoint | Where |
|---|---|
| `GET /api/v1/appointment-slots` | query |
| `GET /api/v1/appointment-service-slots` | query |
| `GET /api/v1/appointment-booking-services/{doctorUuid}` | query |
| `GET /api/v1/appointment-settings/month-availability/{doctorUuid}` | query |
| `POST /api/v1/appointment` | body |
Omitting it means the **personal practice** — it is never a wildcard. If the doctor is not a member
of the given clinic → `404 ERR_VALIDATION_002` («محل نوبت‌دهی یافت نشد»). All four `GET`s echo back
`clinic_uuid` so a client can tell which context answered.
On `POST /api/v1/appointment`, any `service_item_uuids` must belong to the same context, otherwise
`422 ERR_VALIDATION_001` («سرویس انتخاب‌شده به این محل نوبت‌دهی تعلق ندارد»). The appointment's
`address_id` is resolved from that context's schedule.
> **Silent-failure warning:** before this change the location was inferred from the doctor's single
> schedule. A client that does not send `clinic_uuid` will now book into the personal practice —
> which is correct, but is a behaviour change for any doctor who also works in a clinic. Update
> callers before relying on the default.
### GET `/api/v1/appointment-booking-locations/{doctorUuid}`
**Permission:** public.
Lists every place the doctor can be booked at. The site should show **all** of them, grouped by
location — picking one and hiding the rest removes real capacity from the doctor.
```json
{
"success": true,
"data": {
"doctor_uuid": "550e8400-e29b-41d4-a716-446655440000",
"booking_locations": [
{
"location_uuid": "0f0b…",
"type": "personal",
"title": "مطب شخصی",
"address": "یزد، خیابان …",
"clinic_uuid": null,
"booking_mode": "slot",
"buffer_minutes": 0,
"services": [],
"next_available_at": 1755000000
},
{
"location_uuid": "7c21…",
"type": "clinic",
"title": "کلینیک علی بهروزی",
"address": "یزد، بلوار …",
"clinic_uuid": "41e325c4-e825-4067-8438-5d828ecaee09",
"booking_mode": "service",
"buffer_minutes": 10,
"services": [
{ "uuid": "…", "name": "ویزیت", "duration_minutes": 20, "price_rials": 500000,
"service_section": { "uuid": "…", "name": "عمومی" } }
],
"next_available_at": 1754900000
}
]
}
}
```
| Field | Type | Notes |
|---|---|---|
| `location_uuid` | `string\|null` | the `DoctorAddress` uuid; `null` when the context has no address yet |
| `type` | `"personal" \| "clinic"` | |
| `booking_mode` | `"slot" \| "service"` | per-context — the same doctor can differ between locations |
| `services` | `array` | populated only in `service` mode, scoped to that context's owner |
| `next_available_at` | `int\|null` | Unix timestamp of the earliest free slot within 30 days |
Sorted by `next_available_at` ascending, so `booking_locations[0]` is the sensible default
selection; locations with no capacity sort last. Deep links should carry the chosen location
(`/doctor/{uuid}?location={location_uuid}`).
**Status codes:** `200`, `404 ERR_VALIDATION_002` (doctor not found).
**Consumer:** `nobat724_front` — the doctor page must render one booking block per entry and pass the
matching `clinic_uuid` into the slot and booking calls.
+23
View File
@@ -327,3 +327,26 @@ refresh مستقیم هم کار کند، بنابراین فیلترکردن س
|------|------|-------|
| ERR_SERVICE_NOT_FOUND | 404 | سرویس یافت نشد |
| ERR_VALIDATION_001 | 422 | سال نامعتبر |
---
## Owner resolution (2026-07)
Every endpoint in this file resolves its owner through `App\Shared\Context\EntityContextResolver`
instead of reading the caller's role directly. Precedence:
1. an explicit **`clinic_uuid`** on the request (query string, or body on `POST`/`PATCH`/`PUT`) —
403 if the caller may not act in that clinic;
2. the caller's stored active context (`user_active_context`);
3. their role.
This fixes a user who is both a doctor and a clinic owner: they used to always resolve as `doctor`
and could never reach their own clinic's services.
`GET /api/v1/service-items?clinic_uuid=…` therefore returns that clinic's services rather than the
caller's personal ones.
> **TODO:** `Inventory`, `Patient`, `Staff`, `Billing`, `Insurance`, `Subscription`, `Tag` and `Sms`
> controllers still carry their own private `resolveEntity()` copy with the old role-first logic.
> They should be migrated to `EntityContextResolver` too.
+31
View File
@@ -266,3 +266,34 @@ Returns time-series chart data for admin dashboard. All series are filtered to t
- `appointment_status` — all-time counts, not filtered by period
- `top_specialties` — top 8 by appointment volume, all-time
- `subscription_sales_by_plan` — subscriptions created in period, grouped by plan; `revenue` sums only payments with status `received`
---
## Doctor dashboard is context-scoped (2026-07)
`GET /api/v1/dashboard/doctor` now accepts an optional **`clinic_uuid`**. When absent it falls back
to the caller's stored active context (`user_active_context`), then to their role.
In a **clinic context** the response is restricted to that clinic:
* appointment counts and `today_appointments` only include appointments whose `address_id` belongs
to that clinic;
* the financial fields are **omitted entirely** — `revenue_period_rials`, `today_payments_rials`,
`week_payments_rials`, `sms_wallet_balance`, `unique_patients_count`, `total_patients`, and
`charts.revenue_by_day`. They describe the doctor's personal practice and have no meaning inside
someone else's clinic. They return only in the personal context, or for the clinic's own owner
holding `payments.view`;
* `clinics` comes back as `[]` — the "کلینیک‌های من" list belongs to the personal dashboard.
A `clinic_uuid` the caller has no access to is ignored and the personal context is used.
New response field:
```json
"context": { "type": "personal" | "clinic", "clinic_uuid": "…|null", "clinic_name": "…|null" }
```
The admin SPA dispatches on this: `primaryRole === 'doctor' && context.scope === 'clinic'` renders
`InvitedDoctorDashboard` (appointment tiles + today's list only) instead of the full doctor
dashboard. Hiding the cards client-side was not enough — the endpoint is directly callable.