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>
10 KiB
رفع کامل عملیات نوبت در حالت کلینیک (context / permissions)
پروژه
clinicpro (backend + پنل ادمین)
زمینه
در حالت کلینیک تقریباً هیچیک از عملیات نوبت کار نمیکند. کاربر تست کلینیک: نام کاربری 09024206041 / رمز 09024206041 (بعد از ریست دیتابیس: ddev exec php create_test_users.php).
ریشهیابی انجام شده: مسیر نوشتن نوبت (MyAppointmentsController) کلینیک را میفهمد، اما مسیر خواندن/تغییر تکنوبت (AppointmentController) فقط بیمار، پزشکِ مالک و ادمین را میشناسد. نتیجه: کاربر کلینیک نوبت میسازد ولی روی GET /appointment/{uuid}، PATCH /appointment/{uuid}، PATCH /appointment/{uuid}/status و GET /appointment/{uuid}/events خطای 403 میگیرد — یعنی ویرایش، جابهجایی، انتقال/جایگزینی رزرو، تغییر وضعیت و مشاهده جزئیات همگی میشکنند.
مشکل / هدف
تمام عملیات زیر باید در حالت کلینیک (مدیر کلینیک + منشی کلینیک) بدون خطا و مطابق منطق دسترسی کار کند:
- ویرایش نوبت، ثبت سرویس برای نوبت، مشاهده جزئیات، جابهجایی، انتقال به لیست رزرو، جایگزینی از لیست رزرو، تغییر وضعیت (همه وضعیتها).
فایلهای مرتبط
| فایل | نقش |
|---|---|
src/Appointment/Controller/AppointmentController.php |
canView/canManage (خطوط ~686-698)، endpoint های detail/status/update/events |
src/Appointment/Controller/MyAppointmentsController.php |
لیست role-scoped، canBookForDoctor (~460)، todayStats (~385) |
src/Appointment/Repository/AppointmentRepository.php |
کوئریهای slot فقط بر اساس doctor (~76, 122-172) |
src/Appointment/Entity/Appointment.php |
refreshActiveSlotKey (~204-211) — کلید slot بدون clinic |
src/Shared/Context/EntityContextResolver.php |
resolver کانتکست (canActInClinic خط ~68) |
src/Clinic/Security/ClinicDoctorPermissionChecker.php |
مجوزهای پزشکِ عضو کلینیک |
src/Secretary/Security/SecretaryPermissionChecker.php + src/Secretary/Entity/DoctorSecretary.php |
مجوز منشی (active، OWNER_CLINIC) |
src/Patient/Controller/PatientController.php |
resolveEntity (~1186)، appointments (~940, ~955) |
assets/admin/components/AppointmentActions.tsx |
منوی عملیات + مودالهای move/transfer/replace + findRecordUuid |
assets/admin/components/ui/AppointmentStatusDropdown.tsx |
تغییر وضعیت (PATCH .../status با version) |
assets/admin/pages/AppointmentsPage.tsx, ReserveAppointmentsPage.tsx, AppointmentEditPage.tsx, AppointmentDetailPage.tsx |
صفحات مصرفکننده |
وضعیت فعلی
AppointmentController (~686-698) — کلینیک و منشی اصلاً بررسی نمیشوند:
// canView/canManage: فقط بیمار (user)، پزشک مالک (doctor->getUser()) و ROLE_ADMIN.
// appointment->getClinic() هیچجا چک نمیشود.
سایر ناهماهنگیهای تأییدشده:
AppointmentController::listByDoctor(~626): فقط پزشکِ مالک یا ادمین — مدیر کلینیک برای پزشک عضو 403 میگیرد.PatientController::appointments(~940): برای کلینیک ازacceptedDoctorIdsByClinicاستفاده میکند؛ اگر عضویت پزشک غیرفعال شود، نوبتهای کلینیکیِ ثبتشده باappointment.clinic_idاز پرونده «گم» میشوند — باید بر اساسappointment.clinicکوئری شود نه عضویت فعلی.todayStats(~385): بدون شاخه ADMIN و بدون گیتcanViewمنشی — ناهماهنگ باmyAppointments.- کلید یکتای slot:
sprintf('%d:%d', doctorId, slotStart)— clinic در کلید نیست؛isSlotTaken/occupiedIntervals/bookAtomicallyهمه فقطa.doctorرا فیلتر میکنند. پزشکی که همزمان مطب شخصی و کلینیک دارد، رزرو در یک محیط، محیط دیگر را میبندد. - دو سبک موازی authorization:
PatientController::resolveEntityازUserActiveContextمیخواند ولیMyAppointmentsControllerشاخهبندی role دارد — رفتار منشی بین این دو ناسازگار است.
وظایف
۱. تمرکز authorization تکنوبت در یک سرویس
یک سرویس واحد (مثلاً src/Appointment/Security/AppointmentAccessChecker.php) بساز با دو متد canView(User, Appointment) و canManage(User, Appointment) و در هر ۴ endpoint تکنوبت (detail, update, status, events) جایگزین چکهای فعلی کن. منطق:
- ادمین: همیشه مجاز.
- بیمار (
appointment.user): فقطcanView+ لغو خودش (رفتار فعلی حفظ شود). - پزشک مالک (
appointment.doctor.user): مجاز. - مدیر کلینیک: اگر
appointment.getClinic() !== nullو کاربر مالک همان کلینیک است → مجاز (view + manage). - پزشک عضو کلینیک: اگر نوبت کلینیکی است و پزشک عضو همان کلینیک است → از
ClinicDoctorPermissionChecker::can(user, clinic, 'appointments', action)عبور کند (کهactive=falseرا خودش رد میکند). - منشی: از
UserActiveContext(مثلPatientController::resolveEntity) scope را دربیاور؛ اگر scope کلینیک است، نوبت باید متعلق به همان کلینیک و پزشکِ نوبت جزو پزشکان محولشده به منشی باشد؛ اگر scope پزشک است،appointment.doctorباید همان پزشک باشد. سپسSecretaryPermissionChecker::canبا action مناسب (edit/cancel/view).
۲. رفع listByDoctor و todayStats
listByDoctor: به مدیر کلینیک اجازه بده لیست نوبتهای پزشکِ عضو را ببیند — اما فقط نوبتهای همان کلینیک (a.clinic = :clinic).todayStats: شاخه ADMIN و گیتcanViewمنشی را همارزmyAppointmentsاضافه کن؛ برای کاربر بدون role معتبر، خروجی صفر/403 بده نه شمارش unscoped.
۳. رفع کوئری نوبتهای پرونده
در PatientController::appointments شاخه کلینیک را از «doctorIds عضو فعلی» به فیلتر مستقیم a.clinic = :clinicId تغییر بده تا با غیرفعال شدن پزشک، تاریخچه نوبتهای کلینیک از پرونده حذف نشود.
۴. کلید slot با محیط (clinic)
refreshActiveSlotKey را به doctorId:clinicIdOrZero:slotStart تغییر بده و isSlotTaken/occupiedIntervals/expireLapsedPending/bookAtomically را clinic-aware کن (پارامتر nullable clinic؛ IS NULL برای مطب شخصی). migration لازم است (تغییر مقدار ستون + بازتولید کلیدهای فعال موجود در migration data step). دقت: اگر منطق فعلی عمداً تداخل بینمحیطی را میبندد (پزشک فیزیکی یک نفر است)، این وظیفه را با بررسی تنظیمات زمانبندی (schedule هر محیط جدا است یا نه) تأیید کن — اگر schedule ها ذاتاً غیرهمپوشاناند، فقط مستند کن و تغییر نده.
۵. تست end-to-end با کاربر کلینیک
با 09024206041 (و طبق TEST_USERS.md برای منشی/پزشک عضو) از طریق API یا پنل، تکتک این سناریوها را اجرا و سبز کن:
- ساخت نوبت پنل → مشاهده جزئیات → ویرایش (زمان/سرویس/یادداشت) → جابهجایی slot → انتقال به رزرو (
is_reserve:true) → بازگشت از رزرو → جایگزینی بیمار → تمام گذارهای وضعیت مجاز (ALLOWED_TRANSITIONS). - «ثبت سرویس برای نوبت» (منوی عملیات →
findRecordUuid→/admin/patients/{recordUuid}/session/new): بررسی کنGET /api/v1/patient?search=در حالت کلینیک پرونده درست (entityType=clinic) را برمیگرداند و اگر پرونده وجود ندارد، فرانت پیام مناسب بدهد (نه crash). - همه با پاسخ envelope استاندارد
BaseController(success/error) و کد خطای معنادار، نه 500.
۶. فرانت: حذف فرضهای doctor-only
بعد از باز شدن backend، بررسی کن صفحات clinic-mode چیز دیگری نمیشکنند: AppointmentsPage (در clinic mode «dbUuid = clinic id» است و doctor از doctorUuid جدا میآید)، مودالهای AppointmentActions همه version را میفرستند (optimistic lock)، و خطای 409 نسخه با پیام فارسی مناسب toast شود.
نکات مهم
- همه controller ها از
BaseControllerارث میبرند؛ پاسخها فقط با$this->success()/error()/paginated(). - Voter وجود ندارد — الگوی پروژه سرویسهای checker است؛ همین الگو را ادامه بده، Voter جدید معرفی نکن.
ClinicDoctorPermission.can()وSecretaryPermissionCheckerهر دوactive=falseرا رد میکنند — منبع حقیقتِ «پایان همکاری» همین است؛ چک موازی دستی ننویس.- تغییر Entity ⇒ migration؛ تغییر هر endpoint ⇒ بهروزرسانی
docs/api/*در همین سشن. - این پرامپت پیشنیاز
appointment-confirm-flow.mdاست (دکمه قطعیکردن در حالت کلینیک به همینcanManageتکیه دارد).