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

97 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# رفع کامل عملیات نوبت در حالت کلینیک (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) — کلینیک و منشی اصلاً بررسی نمی‌شوند:
```php
// 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` تکیه دارد).