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