Files
clinicpro/.claude/prompt/clinic-appointment-operations-fix.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

10 KiB
Raw Permalink Blame History

رفع کامل عملیات نوبت در حالت کلینیک (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() هیچ‌جا چک نمی‌شود.

سایر ناهماهنگی‌های تأییدشده:

  1. AppointmentController::listByDoctor (~626): فقط پزشکِ مالک یا ادمین — مدیر کلینیک برای پزشک عضو 403 می‌گیرد.
  2. PatientController::appointments (~940): برای کلینیک از acceptedDoctorIdsByClinic استفاده می‌کند؛ اگر عضویت پزشک غیرفعال شود، نوبت‌های کلینیکیِ ثبت‌شده با appointment.clinic_id از پرونده «گم» می‌شوند — باید بر اساس appointment.clinic کوئری شود نه عضویت فعلی.
  3. todayStats (~385): بدون شاخه ADMIN و بدون گیت canView منشی — ناهماهنگ با myAppointments.
  4. کلید یکتای slot: sprintf('%d:%d', doctorId, slotStart) — clinic در کلید نیست؛ isSlotTaken/occupiedIntervals/bookAtomically همه فقط a.doctor را فیلتر می‌کنند. پزشکی که همزمان مطب شخصی و کلینیک دارد، رزرو در یک محیط، محیط دیگر را می‌بندد.
  5. دو سبک موازی 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 تکیه دارد).