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:
+165
-19
@@ -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
@@ -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` نزولی. خروجی آرایهی ساده است (بدون صفحهبندی).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user