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>
8.2 KiB
دسترسی پزشک و مدیر کلینیک به پروندههای کلینیک
پروژه
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) |
وضعیت فعلی
// 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.