feat(appointments,patients): make clinic context a first-class citizen

Three related fixes, all rooted in the same flaw: authorization and scoping
decided by the caller's role instead of by the environment the data belongs to.

1. Single-appointment access (clinic operations were entirely broken)

AppointmentController::canView/canManage only knew the patient, the owning
doctor and admin -- appointment.clinic was never consulted. A clinic user could
create an appointment through /my/appointment but got 403 on detail, edit,
move, reserve transfer/replace and status change, so nearly every appointment
operation failed in clinic mode.

AppointmentAccessChecker now decides from appointment.clinic: clinic owner,
member doctor (via ClinicDoctorPermissionChecker) and assigned secretary (via
active context + DoctorSecretary) are recognised. Actions reuse the existing
permission vocabulary, so active=false remains the single source of truth for
"collaboration ended". Cancellation is gated separately and an inline status on
PATCH /appointment/{uuid} cannot bypass that gate. The patient is narrowed to
view + cancel.

Also fixed alongside: listByDoctor now serves a clinic manager but scoped to
that clinic; todayStats gained an admin branch and no longer passes an array of
doctor ids as the clinic parameter; PatientController::appointments filters on
appointment.clinic instead of current membership, so deactivating a doctor no
longer erases clinic appointment history from the case file.

The doctor-only active_slot_key was reviewed and deliberately left alone -- a
doctor is one physical person, so adding clinic to the key would permit
double-booking, not fix a bug. Reasoning recorded on the entity.

2. Appointment registration and confirmation

Panel-created appointments are born pending ("ثبت شده") instead of confirmed.
Confirming is now an explicit act: POST /appointment/{uuid}/confirm transitions
the status, files the case file for the appointment's environment (reusing an
existing record or creating one) and registers full or partial payments on the
resulting visit -- all in one transaction.

AppointmentExpiryService would have expired those pending appointments the
moment their slot time passed; findExpiredPending is now limited to online
gateway holds, which are the only pendings carrying a TTL. A pending
appointment still occupies its slot, so the time stays reserved.

The admin panel gets a "قطعی کردن نوبت" modal showing the visit fee, each
selected service, the total, and paid/remaining/status. It is wired inside
AppointmentStatusDropdown, so picking "confirmed" anywhere (timeline, detail,
reserve list, info modal) goes through it and confirmation can never silently
skip the case file and payment.

3. Clinic case-file access

PatientRecordScopeResolver replaces the single-destination role mapping: the
active context decides, so a doctor invited into a clinic finally sees their
patients' records there. A clinic record is per-patient and shared by design,
so "their own patients" is derived from appointments with that doctor in that
clinic rather than from a new column. Clinic secretaries are limited to their
assigned doctors. Read and write share one rule, and out-of-scope records
report 404 so other environments are never disclosed.

Tests: 29 new cases across the three areas (clinic appointment access, confirm
flow, clinic record access). Full suite 466 tests, 2 pre-existing failures
unchanged. API docs updated for all three.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-18 21:04:50 +03:30
co-authored by Claude Opus 4.8
parent e6422014d1
commit 7921407f33
26 changed files with 2409 additions and 188 deletions
+165 -19
View File
@@ -273,11 +273,40 @@ Book an appointment slot.
---
## Single-appointment access model
`GET /appointment/{uuid}`, `PATCH /appointment/{uuid}`, `PATCH /appointment/{uuid}/status`
and `GET /appointment/{uuid}/events` all resolve access through
`App\Appointment\Security\AppointmentAccessChecker`. The decision is driven by the
appointment's own environment (`appointment.clinic`: `null` = the doctor's personal
office, a value = that clinic) — **not** by the caller's role.
| Caller | Allowed |
|---|---|
| `ROLE_ADMIN` | everything |
| Owning doctor (`appointment.doctor.user`) | everything |
| Patient (`appointment.user`) | `view` and `cancel` only — never reschedule/edit |
| Clinic owner | everything, when `appointment.clinic` is their clinic |
| Member doctor of that clinic | per `ClinicDoctorPermission.appointments.{view,cancel,update_status}`; denied once the row is `active = false` |
| Secretary | active-context scope must match the appointment (same clinic **and** an assigned doctor, or the scope doctor), then `DoctorSecretary.appointments.{view,cancel,update_status}` |
Actions map onto the existing permission vocabulary: reads use `view`; edit / move /
reserve-transfer / replace / non-cancel status changes use `update_status`; any
transition to `cancelled_by_doctor` / `cancelled_by_user` requires `cancel` — including
an inline `status` sent to `PATCH /appointment/{uuid}`. Denials return
`ERR_ACCESS_DENIED` with HTTP `403`.
> Deactivating a doctor in a clinic (`ClinicDoctorPermission.active = false`) or a
> secretary (`DoctorSecretary.active = false`) is the single source of truth for
> "collaboration ended" — both checkers refuse on it. The clinic owner keeps full access.
---
## GET `/api/v1/appointment/{uuid}`
Get appointment detail.
**Permission:** `AUTH`must be the patient, the doctor, or `ROLE_ADMIN`
**Permission:** `AUTH`see [Single-appointment access model](#single-appointment-access-model)
### Path Parameters
| Param | Type | Description |
@@ -323,7 +352,7 @@ Get appointment detail.
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not the patient/doctor/admin |
| `ERR_ACCESS_DENIED` | 403 | Caller fails the single-appointment access model |
| `ERR_NOT_FOUND_001` | 404 | Appointment not found |
---
@@ -332,7 +361,11 @@ Get appointment detail.
Get all appointments for a specific doctor.
**Permission:** `AUTH` must be the doctor, their secretary, or `ROLE_ADMIN`
**Permission:** `AUTH` — the doctor themselves or `ROLE_ADMIN` see every appointment of
that doctor. A clinic user (owner, or member doctor holding `appointments.view`) may also
call it, but the result is **scoped to their own clinic**: only appointments whose
`clinic_id` is that clinic are returned, so the doctor's personal-office appointments
never leak into a clinic. Anyone else gets `403`.
### Path Parameters
| Param | Type | Description |
@@ -365,7 +398,7 @@ Get all appointments for a specific doctor.
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not authorized to view this doctor's appointments |
| `ERR_ACCESS_DENIED` | 403 | Not the doctor/admin, and no clinic scope granting `appointments.view` over this doctor |
| `ERR_NOT_FOUND_001` | 404 | Doctor not found |
---
@@ -409,7 +442,11 @@ Get all appointments for the authenticated user.
Change appointment status.
**Permission:** `AUTH`patient can cancel; doctor/secretary can confirm/complete/no_show; admin can do all
**Permission:** `AUTH`see [Single-appointment access model](#single-appointment-access-model).
The required action depends on the target status: a transition to `cancelled_by_doctor` /
`cancelled_by_user` needs `appointments.cancel`, everything else needs
`appointments.update_status`. A clinic secretary therefore confirms and completes by
default but cannot cancel until `cancel` is granted.
### Path Parameters
| Param | Type | Description |
@@ -431,12 +468,15 @@ Change appointment status.
| `version` | integer | ❌ | Optimistic lock version (prevents double-submit) |
| `cancel_reason` | string | ❌ | Only when transitioning to `cancelled_by_doctor` / `cancelled_by_user`. Stored on the recorded cancellation event (Timeline). Ignored for other statuses. |
**Allowed Transitions by Role:**
| Actor | Allowed transitions |
|-------|---------------------|
| Patient | `pending → cancelled` |
| Doctor / Secretary | `pending → confirmed`, `confirmed → completed`, `confirmed → no_show` |
| Admin | Any transition |
**Allowed Transitions by Actor:** the state machine itself is
`Appointment::ALLOWED_TRANSITIONS` (identical for everyone); the actor only decides
*whether* the transition may be attempted:
| Actor | Allowed |
|-------|---------|
| Patient | cancellation of their own appointment only |
| Doctor (owner) / clinic owner / admin | any transition the state machine permits |
| Member doctor / secretary | non-cancel transitions with `update_status`; cancellations only with `cancel` |
> **Cancellation is logged.** When the status becomes `cancelled_by_doctor` or `cancelled_by_user`, an `AppointmentEvent` (type `cancelled`, title «نوبت لغو شد») is recorded with the actor (user id + name), the optional `cancel_reason`, and the cancel time — surfaced via `GET /api/v1/appointment/{uuid}/events`. A `warning`-level entry is also written to `app_log`.
@@ -447,18 +487,102 @@ Updated appointment object.
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing token |
| `ERR_FORBIDDEN_001` | 403 | Not authorized for this transition |
| `ERR_ACCESS_DENIED` | 403 | Caller lacks `update_status` (or `cancel` for a cancellation) on this appointment |
| `ERR_NOT_FOUND_001` | 404 | Appointment not found |
| `ERR_CONFLICT_001` | 409 | Version mismatch (optimistic lock) |
| `ERR_VALIDATION_001` | 422 | Invalid status value |
---
## POST `/api/v1/appointment/{uuid}/confirm`
Confirm an appointment («ثبت شده» → «قطعی شده») and register its money on the patient
case file — status transition, case file / visit, and payments in **one atomic
transaction**. If any step fails nothing is committed.
**Permission:** `AUTH``appointments.update_status` per the
[single-appointment access model](#single-appointment-access-model).
### Request Body
```json
{
"version": 3,
"payments": [
{ "method": "cash", "amount_rials": 2000000 },
{ "method": "pos", "amount_rials": 3000000 }
]
}
```
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `version` | integer | ❌ | Optimistic lock version; defaults to the stored one |
| `payments` | array | ❌ | Empty/absent = confirm without payment. Each row: `method``wallet\|pos\|cash\|card` and `amount_rials` > 0. Several rows are allowed (split payment). |
The sum of `payments` may not exceed the visit's payable amount → `ERR_SESSION_PAYMENT_EXCEEDS`.
Partial payment is normal: the remainder stays as `remaining_rials` on the visit and can be
collected later through `POST /api/v1/session/{uuid}/payments`.
### What happens on the server
1. `pending → confirmed` (state machine still applies).
2. `AppointmentConfirmationService` files the case file for the appointment's environment
(`appointment.clinic` → clinic, otherwise the doctor's personal office): an **existing**
record for that patient in that environment is reused, otherwise a new one is created.
A `PatientSession` is opened with the visit price and one line per attached service.
3. Each payment row is registered on that visit (`wallet` also debits the patient wallet).
### Response `200`
```json
{
"success": true,
"data": {
"appointment": { "uuid": "…", "status": "confirmed", "version": 4 },
"session": {
"uuid": "…",
"visit_price_rials": 5000000,
"services_total_rials": 1500000,
"final_price_rials": 6500000,
"discount_rials": 0,
"paid_total_rials": 5000000,
"remaining_rials": 1500000,
"is_paid": false
}
}
}
```
`session` is `null` when the tenant does not have the `patient_records` subscription
feature — the appointment is still confirmed, it simply has no case file. **Sending
`payments` in that situation fails with `403 ERR_SUBSCRIPTION_REQUIRED` and confirms
nothing**, because there would be nowhere to record the money.
Reserve-list entries (`is_reserve: true`) never open a visit; move them onto a real slot
first.
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_ACCESS_DENIED` | 403 | No `update_status` on this appointment |
| `ERR_SUBSCRIPTION_REQUIRED` | 403 | Payments sent but the tenant has no `patient_records` feature |
| `ERR_VALIDATION_002` | 404 | Appointment not found |
| `ERR_CONFLICT_001` | 409 | Version mismatch (optimistic lock) |
| `ERR_VALIDATION_001` | 422 | Transition to `confirmed` not allowed from the current status |
| `ERR_SESSION_PAYMENT_INVALID` | 422 | Unknown `method` or non-positive `amount_rials` |
| `ERR_SESSION_PAYMENT_EXCEEDS` | 422 | Payments exceed the payable amount |
> **Admin panel:** this is the only path to «قطعی شده». Picking `confirmed` in
> `AppointmentStatusDropdown` opens the «قطعی کردن نوبت» modal rather than issuing a raw
> `PATCH .../status`, so confirmation can never silently skip the case file and payment.
---
## GET `/api/v1/appointment/{uuid}/events`
Appointment Timeline — chronological event history for one appointment. Currently records cancellation events; the structure is generic for future event types.
**Permission:** `IS_AUTHENTICATED_FULLY`caller must be able to manage the appointment (`canManage`).
**Permission:** `IS_AUTHENTICATED_FULLY`read-only, so it needs `view` (not `update_status`):
see [Single-appointment access model](#single-appointment-access-model). The patient sees
their own Timeline.
### Path Parameters
| Param | Type | Description |
@@ -485,7 +609,7 @@ Events are ordered oldest → newest. `data` is a flat array (single nesting). `
### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_006` | 403 | Not allowed to manage this appointment |
| `ERR_ACCESS_DENIED` | 403 | Not allowed to view this appointment |
| `ERR_VALIDATION_002` | 404 | Appointment not found |
---
@@ -494,6 +618,18 @@ Events are ordered oldest → newest. `data` is a flat array (single nesting). `
Create a new appointment for a patient. Used by doctor/clinic/secretary to book appointments on behalf of patients. If no user exists with the given mobile, a new user account is created automatically.
> **Initial status is `pending` («ثبت شده»), not `confirmed`.** Every appointment —
> online, quick, or regular — starts as registered; confirming it is a separate act that
> shows the costs and takes payment (`POST /api/v1/appointment/{uuid}/confirm`). Because
> of that, **no case file / visit is opened at creation time** any more; it is opened on
> confirmation.
>
> A panel-created `pending` appointment still **occupies its slot** (so the time stays
> reserved) and carries **no `expires_at`**, so it is never auto-expired: only online
> gateway holds (created with a 15-minute TTL by `POST /api/v1/appointment`) are swept by
> `AppointmentExpiryService`. Ending a stale registered appointment is an operator
> decision (cancel).
**Auth:** `IS_AUTHENTICATED_FULLY` — Roles: `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY`, `ROLE_ADMIN`
> **Scope enforced:** the caller must be related to the target `doctor_uuid`, not merely hold an allowed role. A doctor may book only onto their own calendar; a clinic only onto doctors that belong to it; a secretary only within their active clinic/doctor scope **and** with the `appointments.create` permission; admin onto any. Otherwise `403 FORBIDDEN`.
@@ -598,9 +734,19 @@ Role-aware paginated list of appointments. Returns only what the authenticated u
| `ROLE_ADMIN` | All appointments |
| `ROLE_CLINIC` | Appointments for doctors in this clinic |
| `ROLE_DOCTOR` | Appointments for this doctor |
| `ROLE_SECRETARY` | Appointments for the linked doctor (empty if `appointments.view` permission is false) |
| `ROLE_SECRETARY` | Appointments of the doctors assigned to this secretary in their active scope (empty if `appointments.view` is false) |
| (plain patient `ROLE_USER`) | The patient's own appointments (`a.user = current user`) |
### GET `/api/v1/my/appointments/today-stats`
Same scoping rules as the list above, aggregated into `{ total, completed, waiting, cancelled }`
for one day (`?date=Y-m-d`, defaults to today).
**Auth:** `IS_AUTHENTICATED_FULLY`. A caller with no resolvable scope (clinic/doctor row
missing, secretary without `appointments.view` or with no assigned doctors) gets all-zero
counts rather than an unscoped, system-wide count. A plain patient gets counts over their
own appointments only.
### Query Parameters
| Param | Type | Default | Description |
|-------|------|---------|-------------|
@@ -640,13 +786,13 @@ Role-aware paginated list of appointments. Returns only what the authenticated u
## Clinic workflow extensions (نوبتها Figma)
New optional fields on `Appointment` (all backward-compatible): `service_section` (بخش), `service_item` (سرویسِ اصلی/اول), `service_items` (آرایهٔ همهٔ سرویسهای نوبت چند سرویس، هر عضو `{uuid, name}`), `staff` (پرسنل), `deposit_required` / `deposit_amount_rials` (بیعانه), `visit_price_rials` (هزینه ویزیت، nullable), `is_reserve` (نوبت رزرو day-level, never occupies a slot).
New optional fields on `Appointment` (all backward-compatible): `service_section` (بخش), `service_item` (سرویسِ اصلی/اول), `service_items` (آرایهٔ همهٔ سرویسهای نوبت چند سرویس، هر عضو `{uuid, name, price_rials}`؛ `price_rials` افزوده شد تا مودالِ «قطعی کردن نوبت» بتواند هزینهها را پیش از ساختهشدنِ مراجعه نشان دهد), `staff` (پرسنل), `deposit_required` / `deposit_amount_rials` (بیعانه), `visit_price_rials` (هزینه ویزیت، nullable), `is_reserve` (نوبت رزرو day-level, never occupies a slot).
New statuses: `following_up` (در حال پیگیری), `salon` (سالن). Transitions:
`pending confirmed|following_up|cancelled_*|expired` · `confirmed completed|following_up|salon|cancelled_*|no_show` · `following_up confirmed|salon|completed|cancelled_*|no_show` · `salon completed|following_up|cancelled_*|no_show`
### PATCH `/api/v1/appointment/{uuid}`
General update (ویرایش / جا به جایی / انتقال به رزرو / جایگزینی). All body fields optional; only present keys change. **Permission:** appointment's patient, owning doctor, or admin.
General update (ویرایش / جا به جایی / انتقال به رزرو / جایگزینی). All body fields optional; only present keys change. **Permission:** `appointments.update_status` per the [single-appointment access model](#single-appointment-access-model) the appointment's owning doctor, admin, the clinic owner / member doctor / assigned secretary of `appointment.clinic`. The patient is **not** allowed here (view + cancel only).
```json
{
@@ -661,7 +807,7 @@ General update (ویرایش / جا به جایی / انتقال به رزرو /
- `slot_start`/`slot_end` must be sent together; moving to an occupied slot → `409`.
- Relation uuids: empty string clears; unknown uuid → `422`.
- `status` follows the same transition rules as `PATCH /appointment/{uuid}/status`. A transition to `cancelled_by_doctor`/`cancelled_by_user` records a cancellation event (Timeline) + `app_log` warning; an optional `cancel_reason` body field is stored on the event.
- `status` follows the same transition rules as `PATCH /appointment/{uuid}/status`. A transition to `cancelled_by_doctor`/`cancelled_by_user` records a cancellation event (Timeline) + `app_log` warning; an optional `cancel_reason` body field is stored on the event. An inline cancellation is gated on `appointments.cancel` exactly like the dedicated status endpoint, so it cannot be used to bypass a secretary's missing cancel permission.
- Optimistic lock via `version``409` on concurrent edit.
Response `200`: `{ success, data: { data: <appointment.toArray()> } }`
@@ -669,7 +815,7 @@ Response `200`: `{ success, data: { data: <appointment.toArray()> } }`
| HTTP | Description |
|------|-------------|
| 404 | نوبت یافت نشد |
| 403 | not patient/doctor/admin |
| 403 | `ERR_ACCESS_DENIED` — no `update_status` on this appointment, or an inline cancellation without `cancel` |
| 422 | half slot pair, end < start, unknown relation uuid, invalid transition |
| 409 | slot taken or version conflict |
+49 -3
View File
@@ -5,7 +5,46 @@
Patient records track patients per entity (doctor or clinic). Each record holds multiple sessions (visits). Access requires an active subscription with the `patient_records` feature.
**Base path:** `/api/v1`
**Auth:** Bearer JWT (doctor, clinic, or secretary with `appointments.view` permission required)
**Auth:** Bearer JWT (doctor, clinic, or secretary)
---
## Record access model
Every endpoint in this file resolves the caller's environment through
`App\Patient\Security\PatientRecordScopeResolver`. The **active context**
(`UserActiveContext`, set by `POST /api/v1/auth/switch-context`) decides it — not the role
alone, because a doctor invited into a clinic has records in both places.
| Caller | Scope | Visible records |
|---|---|---|
| Clinic owner | `clinic:<id>` | every record of the clinic |
| Doctor, active context = a clinic they belong to | `clinic:<id>` | only records of **their own** patients in that clinic |
| Doctor, otherwise | `doctor:<id>` | their personal-office records only |
| Secretary, active context = clinic | `clinic:<id>` | records of the doctors assigned to that secretary |
| Secretary, active context = doctor | `doctor:<id>` | that doctor's records |
**"Their own patients" is derived, not stored.** A clinic record is per-patient
(`UNIQUE(entity_type, entity_id, user_id)`) and deliberately shared between the clinic's
doctors — there is no doctor column on it and none should be added. A record counts as a
member doctor's when the patient has at least one appointment with that doctor **in that
clinic**. Manually created visits carry no doctor (`PatientSession` has no creator column),
so they never widen a member doctor's view on their own.
The member-doctor path additionally requires `ClinicDoctorPermission.patients.view`, and a
clinic secretary requires an active `DoctorSecretary` row. Both refuse when `active = false`,
so **deactivating a doctor or secretary is the single mechanism that ends their access**
the clinic owner keeps everything, and no record is moved or deleted. A doctor whose clinic
membership was revoked silently falls back to their personal-office scope.
> **Read and write use the same rule.** An active member doctor who can see a record can
> also manage it (notes, sessions, payments, attachments): the clinic record is shared by
> design, and per-visit ownership is not modelled, so inventing a write-only restriction on
> top of it would produce confusing 403s. A clinic owner who wants a read-only doctor
> revokes `patients.update` for them.
A record outside the caller's scope is reported as `404 ERR_PATIENT_NOT_FOUND` (not 403), so
the existence of another environment's records is never disclosed.
---
@@ -17,7 +56,10 @@ Patient records track patients per entity (doctor or clinic). Each record holds
GET /api/v1/patients
```
Returns a paginated list of patient records belonging to the authenticated entity.
Returns a paginated list of patient records belonging to the authenticated entity, already
narrowed by the [record access model](#record-access-model) — a member doctor or clinic
secretary receives only their own patients, with `meta.totalRecords` counted over the same
restriction.
**Query params:**
@@ -356,7 +398,11 @@ GET /api/v1/patient/{uuid}/appointments
نوبت‌های همین بیمار را برمی‌گرداند. برای جلوگیری از نشتِ اطلاعات بین ارائه‌دهنده‌ها، فقط نوبت‌هایی نمایش داده می‌شوند که با پزشک(های) خودِ صاحب پرونده گرفته شده‌اند:
- ارائه‌دهنده‌ی **پزشک**: نوبت‌های بیمار با همان پزشک.
- ارائه‌دهنده‌ی **کلینیک** (و منشیِ فعالِ کلینیک): نوبت‌های بیمار با پزشکانی که دعوت پذیرفته‌شده (`accepted`) در آن کلینیک دارند.
- ارائه‌دهنده‌ی **کلینیک** (و منشیِ فعالِ کلینیک): نوبت‌هایی که `appointment.clinic_id` آن‌ها همین کلینیک است.
> شاخهٔ کلینیک قبلاً بر اساس «پزشکانِ دارای دعوتِ پذیرفته‌شده در این کلینیک» کوئری می‌شد؛
> با پایان همکاری یا غیرفعال شدن پزشک، تاریخچهٔ نوبت‌های همان کلینیک از پرونده ناپدید
> می‌شد. مبنا حالا خودِ محیطِ ثبت‌شدهٔ نوبت است، که تغییرناپذیر است.
مرتب‌شده بر اساس `starts_at` نزولی. خروجی آرایه‌ی ساده است (بدون صفحه‌بندی).