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