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>
78 lines
8.2 KiB
Markdown
78 lines
8.2 KiB
Markdown
# دسترسی پزشک و مدیر کلینیک به پروندههای کلینیک
|
||
|
||
## پروژه
|
||
|
||
`clinicpro` (backend + پنل ادمین) — بعد از `clinic-appointment-operations-fix.md` و `appointment-confirm-flow.md` اجرا شود.
|
||
|
||
## زمینه
|
||
|
||
پروندهها per-محیط silo شدهاند: `PatientRecord` مالک چندریختی دارد — `entityType` (`doctor|clinic|system`) + `entityId` با یکتایی `(entity_type, entity_id, user_id)`. `PatientController::resolveEntity` (~1186) هر کاربر را به **یک** scope نگاشت میکند (پزشک → پروندههای شخصی خودش، کلینیک → پروندههای کلینیک) و `ownsRecord()` (~1232) تساوی دقیق میسنجد. نتیجه فعلی: پزشکِ دعوتشده به کلینیک، پروندههای بیمارانش **در آن کلینیک** را نمیبیند (فقط مدیر کلینیک میبیند).
|
||
|
||
## مشکل / هدف
|
||
|
||
- پزشک عضو کلینیک و مدیر کلینیک هر دو به پروندههای بیماران آن پزشک در آن کلینیک دسترسی داشته باشند (مشاهده + مدیریت).
|
||
- تا وقتی پزشک در کلینیک فعال است (`ClinicDoctorPermission.active` / عضویت)، همه پروندههای مرتبطش در آن کلینیک برایش قابل مشاهده/مدیریت باشد.
|
||
- با پایان همکاری یا غیرفعال شدن، دسترسی پزشک طبق سطح دسترسی سیستم محدود/قطع شود؛ مدیر کلینیک دسترسی کامل بماند.
|
||
|
||
## فایلهای مرتبط
|
||
|
||
| فایل | نقش |
|
||
|------|-----|
|
||
| `src/Patient/Controller/PatientController.php` | `resolveEntity` (~1186)، `ownsRecord` (~1221-1240)، همه endpoint های پرونده/session/پرداخت |
|
||
| `src/Patient/Entity/PatientRecord.php` | مالک چندریختی، بدون FK پزشک |
|
||
| `src/Patient/Entity/PatientSession.php` | `appointment` nullable → پل به `appointment.doctor` |
|
||
| `src/Patient/Service/PatientService.php` | ساخت پرونده/session (`autoCreateForEntity` ~144) |
|
||
| `src/Clinic/Security/ClinicDoctorPermissionChecker.php` | `can(user, clinic, 'patients', action)` — `active=false` را رد میکند |
|
||
| `src/Clinic/Entity/ClinicDoctorPermission.php` | resource `patients` در `DEFAULT_PERMISSIONS` |
|
||
| `src/Shared/Context/EntityContextResolver.php` | کانتکست فعال (پزشکی که داخل کلینیک سوییچ کرده) |
|
||
| `assets/admin/pages/MyPatientsPage.tsx`, `PatientsListPage.tsx`, `PatientDetailPage.tsx` | UI پروندهها |
|
||
| `assets/admin/stores/authStore.ts`, `hooks/useClinicContext.ts` | کانتکست SPA (`switchContext`) |
|
||
|
||
## وضعیت فعلی
|
||
|
||
```php
|
||
// PatientController::resolveEntity — نگاشت تکمقصدی:
|
||
// ROLE_DOCTOR → ['doctor', doctorId] // فقط پروندههای مطب شخصی
|
||
// ROLE_CLINIC → ['clinic', clinicId] // فقط پروندههای کلینیک
|
||
// ROLE_SECRETARY → از UserActiveContext
|
||
|
||
// ownsRecord(): record.entityType === entityType && record.entityId === entityId
|
||
```
|
||
|
||
نکته کلیدی مدل: پرونده کلینیکی per-بیمار است نه per-پزشک (unique روی clinic+user). «پروندههای بیماران آن پزشک» یعنی پروندههای کلینیکیای که بیمارشان با آن پزشک session/نوبت داشته — از مسیر `PatientSession.appointment.doctor` (و برای سشنهای دستی `createdByType/createdById`) قابل استخراج است.
|
||
|
||
## وظایف
|
||
|
||
### ۱. Backend — دسترسی پزشک به پروندههای کلینیک
|
||
|
||
`resolveEntity`/`ownsRecord` را از نگاشت تکمقصدی به مدل «scope فعال + عضویت» ارتقا بده:
|
||
|
||
- وقتی پزشک با `UserActiveContext` روی کانتکست کلینیک است (SPA با `switchContext` این را ست میکند)، scope پرونده = `['clinic', clinicId]` **مشروط به** `ClinicDoctorPermissionChecker::can(user, clinic, 'patients', action)` — که خودش عضویت غیرفعال را رد میکند. این الزام «قطع دسترسی بعد از پایان همکاری» را بدون منطق جدید برآورده میکند.
|
||
- محدودسازی به «بیماران آن پزشک»: در لیست پروندهها (`GET /api/v1/patient(s)`) وقتی scope=clinic و کاربر پزشک عضو است (نه مدیر)، فیلتر کن به پروندههایی که حداقل یک session با `appointment.doctor = doctor` یا `createdByType='doctor' AND createdById=doctorId` دارند (subquery/EXISTS در repository). مدیر کلینیک بدون این فیلتر، همه را میبیند.
|
||
- دسترسی تکپرونده (detail/session/پرداخت/یادداشت/پیوست): همان قاعده — مدیر کلینیک کامل؛ پزشک عضو فعال فقط اگر پرونده طبق فیلتر بالا «مالِ بیماران خودش» باشد. تصمیم باز که باید حین اجرا گرفته و مستند شود: آیا پزشک به کل پرونده مشترک بیمار (شامل session های پزشک دیگر همان کلینیک) دید دارد یا فقط session های خودش؟ پیشفرض پیشنهادی: دید کامل به پرونده، مدیریت فقط روی session های خودش.
|
||
- `assertPatientGate` (feature اشتراک `patient_records`) سر جای خودش بماند — گیت اشتراک باید بر اساس محیط کلینیک چک شود نه اشتراک شخصی پزشک.
|
||
|
||
### ۲. Backend — منشی
|
||
|
||
منشی کلینیک (`DoctorSecretary` با `OWNER_CLINIC` و `active`) طبق همان الگو: scope کلینیک + محدود به پزشکان محولشده + `SecretaryPermissionChecker::can(..., 'patients', ...)`. رفتار فعلی منشی نباید پسرفت کند.
|
||
|
||
### ۳. Frontend — نمایش پروندههای کلینیک برای پزشک
|
||
|
||
- وقتی پزشک کانتکست کلینیک را انتخاب کرده (`useClinicContext` مقدار دارد)، `MyPatientsPage`/`PatientsListPage` باید پروندههای کلینیکِ scope شده را نشان دهند — احتمالاً بدون تغییر فرانت کار میکند چون scope سمت سرور است؛ تست کن و فقط اگر endpoint/پارامتر جدید لازم شد دست بزن.
|
||
- حالت خطای «دسترسی قطع شده» (پزشک غیرفعالشده): پیام فارسی روشن، نه صفحه خالی.
|
||
|
||
### ۴. تست
|
||
|
||
- پزشک عضو فعال در کلینیک `09024206041` (طبق `TEST_USERS.md` بساز/استفاده کن): در کانتکست کلینیک پرونده بیمارانش را میبیند و session/پرداخت ثبت میکند؛ در کانتکست شخصی فقط پروندههای مطب خودش.
|
||
- مدیر کلینیک: همه پروندههای کلینیک، قبل و بعد از غیرفعالسازی پزشک.
|
||
- پزشک را غیرفعال کن (`ClinicDoctorPermission.active=false`): پزشک 403/فیلتر میشود، مدیر همچنان کامل؛ پروندهها و تاریخچه دستنخورده میمانند (هیچ حذف/انتقالی رخ نمیدهد).
|
||
- قطعیکردن نوبت کلینیکی توسط پزشک عضو (خروجی پرامپت قبلی) پرونده را در محیط کلینیک میسازد و همان پرونده برای هر دو نقش دیده میشود.
|
||
- `docs/api/*` برای هر endpoint تغییرکرده بهروز شود.
|
||
|
||
## نکات مهم
|
||
|
||
- **FK پزشک به `PatientRecord` اضافه نکن** — یکتایی `(clinic, user)` عمداً پرونده مشترک کلینیکی است؛ ارتباط پزشک از مسیر session/appointment استخراج میشود.
|
||
- Voter نساز؛ الگوی checker service موجود.
|
||
- لیستها با DQL array hydration (`getArrayResult`)؛ فیلتر EXISTS را در repository اضافه کن نه در PHP.
|
||
- اگر schema تغییر کرد (بعید، ولی مثلاً index برای کوئری EXISTS): migration.
|