Files
clinicpro/.claude/prompt/clinic-record-access.md
hamedandClaude Opus 4.8 7921407f33 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>
2026-07-18 21:04:50 +03:30

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.