diff --git a/.claude/prompt/tenant-01-entity-context-unify.md b/.claude/prompt/tenant-01-entity-context-unify.md new file mode 100644 index 00000000..0cf161c6 --- /dev/null +++ b/.claude/prompt/tenant-01-entity-context-unify.md @@ -0,0 +1,361 @@ +# فاز ۱ — یکسان‌سازی تشخیص محیط (tenant) روی `EntityContextResolver` + +> این پرامپت اول از سری پنج‌تایی «نشانه‌گذاری tenant» است: +> **۱. یکسان‌سازی رزولور (همین فایل)** → ۲. `tenant-02-mark-booking-tables.md` → ۳. `tenant-03-unify-owner-columns.md` → ۴. `tenant-04-enforce-tenant-filter.md` → ۵. `tenant-05-audit-and-docs.md` +> ترتیب اجباری است؛ فازهای بعدی روی خروجی همین فاز ساخته می‌شوند. این فاز **هیچ تغییر اسکیمایی ندارد** و عمداً اول آمده تا قبل از دست زدن به دیتابیس، تعریف tenant قطعی شود. + +## زمینه + +ClinicPro از قبل مفهوم tenant دارد ولی نامش را نبرده است: هویت هر محیط کاری یک جفت `(entity_type, entity_id)` است با مقادیر `doctor` یا `clinic`. این جفت در ~۱۵ جدول ذخیره می‌شود (`service_sections`، `patient_records`، `clinic_staff`، `tenant_tags`، `tenant_insurances`، `inventory_items`، `inventory_packages`، `invoices`، `claims`، `sms_wallets`، `sms_settings`، `entity_insurance_pricing`، `clinic_subscriptions`، `tenant_service_category_settings`، `mobile_verification_otp`). + +محیط فعالِ هر کاربر در `user_active_context.db_uuid` نگهداری می‌شود — یک uuid که یا مال کلینیک است یا مال پزشک. + +نقطهٔ مرجع تشخیص محیط هم وجود دارد: `App\Shared\Context\EntityContextResolver`. اما فقط **۵ فایل** از آن استفاده می‌کنند: + +``` +src/Secretary/Security/SecretaryAccessChecker.php +src/ClinicService/Controller/ClinicServiceController.php +src/Dashboard/Controller/DashboardController.php +src/Patient/Security/PatientRecordScopeResolver.php +src/Shared/Context/EntityContextResolver.php +``` + +بقیهٔ کد همان منطق را دستی و ناقص تکرار می‌کند. + +## مشکل / هدف + +**مشکل:** «کاربر الان در کدام محیط است؟» در چند فایل جداگانه و با منطق‌های کمی متفاوت پاسخ داده می‌شود. سه نمونهٔ واقعی از تکرار: + +1. [`AppointmentAccessChecker`](../../src/Appointment/Security/AppointmentAccessChecker.php) — الگوی «`db_uuid` بگیر → اول `clinicRepo->findByUuid()` → اگر null بود `doctorRepo->findByUuid()`» را **سه بار** در سه متد تکرار می‌کند (`secretaryCanContext`، `viewableClinicFor`، `secretaryCan`). +2. [`ClinicDoctorAccessChecker`](../../src/Clinic/Security/ClinicDoctorAccessChecker.php) — همان الگو در `memberClinicId()` و `canOrNonMember()`. +3. [`PatientRecordScopeResolver`](../../src/Patient/Security/PatientRecordScopeResolver.php) — در docblock خودش نوشته «دقیقاً مثل EntityContextResolver» ولی از آن استفاده نمی‌کند و منطق را دوباره پیاده کرده. + +هر تکرار یعنی یک نقطهٔ ممکن برای واگرایی رفتار بین نقش‌ها. مصداق واقعی که در docblock `EntityContextResolver` ثبت شده: «کاربری که هم پزشک است و هم مالک کلینیک، همیشه به‌عنوان پزشک حل می‌شد و هرگز به سرویس‌های کلینیک خودش نمی‌رسید». + +**هدف:** `EntityContextResolver` تنها منبع حقیقتِ تشخیص محیط شود، ماتریس نقش‌ها به‌صورت تست اجرایی ثبت شود، و شکاف‌های شناخته‌شدهٔ خود رزولور پر شود. + +### ماتریس نقش‌ها که باید پشتیبانی شود + +| # | سناریو | چطور در دیتابیس نمایش داده می‌شود | محیط مؤثر (tenant) | +|---|---|---|---| +| ۱ | پزشک مستقل | `users.roles` شامل `ROLE_DOCTOR`؛ در هیچ ردیف `clinic_doctors` نیست | همیشه `('doctor', doctor.id)` | +| ۲ | پزشک مستقل که عضو یک یا چند کلینیک هم هست | `ROLE_DOCTOR` + ردیف در `clinic_doctors` + `clinic_doctor_permissions` | **متغیر:** اگر `user_active_context.db_uuid` = uuid آن کلینیک → `('clinic', clinic.id)`، وگرنه `('doctor', doctor.id)` | +| ۳ | پزشکی که هم عضو و هم مالک کلینیک است | `ROLE_DOCTOR` + `ROLE_CLINIC` + `clinics.user_id = user.id` | طبق محیط فعال؛ در محیط کلینیک **بدون** محدودیت `ClinicDoctorPermission` (مالک قفل نمی‌شود) | +| ۴ | مدیر/مالک کلینیک (غیرپزشک) | `ROLE_CLINIC` + `clinics.user_id = user.id` | همیشه `('clinic', clinic.id)` | +| ۵ | منشی (پوشش جانبی) | `ROLE_SECRETARY` + ردیف `doctor_secretaries` با `owner_type` و `clinic` اختیاری | طبق محیط فعال؛ **بدون محیط فعال معتبر → `unknown`** | + +## معیار پذیرش + +- ✅ موفق: با توکن کاربر سناریوی ۳ (پزشکِ مالکِ کلینیک) و `user_active_context.db_uuid` = uuid کلینیک، `GET /api/v1/clinic-services/sections` بخش‌های **کلینیک** را برمی‌گرداند؛ بعد از تغییر `db_uuid` به uuid خود پزشک، همان endpoint بخش‌های **مطب شخصی** را برمی‌گرداند. هر پنج سناریوی جدول بالا تست واحد سبز دارند. +- ❌ خطا: کاربر سناریوی ۱ (پزشک مستقل) با `clinic_uuid` صریحِ کلینیکی که عضوش نیست → `403` با `ERR_ACCESS_DENIED` و envelope استاندارد `{success:false, errors:[{code,message}]}`. +- ⚠️ مرزی: کاربری با `ROLE_SECRETARY` که `user_active_context` ندارد → `EntityContext::unknown()` و پاسخ endpointها `403` (نه ۵۰۰، نه لیست خالیِ گمراه‌کننده). همچنین پزشک عضوی که `ClinicDoctorPermission.active = false` شده → به محیط شخصی خودش برمی‌گردد، نه به کلینیک. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Shared/Context/EntityContext.php` | VO محیط — `type`/`id`/`clinic`/`doctor` + `toEntityPair()` | +| `src/Shared/Context/EntityContextResolver.php` | رزولور مرکزی — هدف این فاز | +| `src/Appointment/Security/AppointmentAccessChecker.php` | سه بار تکرار منطق محیط | +| `src/Clinic/Security/ClinicDoctorAccessChecker.php` | دو بار تکرار منطق محیط | +| `src/Patient/Security/PatientRecordScopeResolver.php` | بازپیاده‌سازی مستقل همان منطق | +| `src/Appointment/Service/BookingContextResolver.php` | نسخهٔ محدودِ همان مفهوم برای رزرو | +| `src/Appointment/Controller/MyAppointmentsController.php` | خواندن مستقیم `contextRepo` در خط ۵۵۵ | +| `src/Auth/Entity/UserActiveContext.php` | منبع محیط فعال (`db_uuid`) | +| `tests/Shared/` | محل تست‌های جدید ماتریس نقش | + +## وضعیت فعلی + +`EntityContextResolver` — منطق درست، ولی کم‌استفاده: + +```php +// src/Shared/Context/EntityContextResolver.php +public function resolve(User $user, ?string $clinicUuid = null): EntityContext +{ + if ($clinicUuid !== null && $clinicUuid !== '') { + $clinic = $this->clinicRepo->findByUuid($clinicUuid); + if ($clinic === null) { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); + } + $this->assertCanActInClinic($user, $clinic); + + return EntityContext::forClinic($clinic); + } + + $fromActive = $this->fromActiveContext($user); + if ($fromActive !== null) { + return $fromActive; + } + + return $this->fromRole($user); +} + +private function fromRole(User $user): EntityContext +{ + if ($user->hasRole('ROLE_DOCTOR')) { + return EntityContext::forDoctor($this->doctorRepo->findByUser($user)); + } + + if ($user->hasRole('ROLE_CLINIC')) { + $clinic = $this->clinicRepo->findByUser($user); + + return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown(); + } + + return EntityContext::unknown(); +} +``` + +همان منطق، بازنویسی‌شده در `AppointmentAccessChecker`: + +```php +// src/Appointment/Security/AppointmentAccessChecker.php — secretaryCan() +$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); +if ($dbUuid === null) { + return false; +} + +$clinic = $this->clinicRepo->findByUuid($dbUuid); +if ($clinic !== null) { + if ($appointment->getClinic()?->getId() !== $clinic->getId()) { + return false; + } + // ... +} + +$doctor = $this->doctorRepo->findByUuid($dbUuid); +if ($doctor === null || $doctor->getId() !== $appointment->getDoctor()->getId()) { + return false; +} +``` + +و در `ClinicDoctorAccessChecker`: + +```php +// src/Clinic/Security/ClinicDoctorAccessChecker.php — memberClinicId() +$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); +if ($dbUuid === null) { + return null; +} + +$clinic = $this->clinicRepo->findByUuid($dbUuid); +if ($clinic === null) { + return null; +} + +$doctor = $this->doctorRepo->findByUser($user); +if ($doctor === null || !$clinic->hasDoctor($doctor)) { + return null; +} + +return $clinic->getId(); +``` + +## وظایف + +### ۱. تکمیل `EntityContextResolver` برای نقش منشی + +`fromRole()` فعلاً برای `ROLE_SECRETARY` مستقیم `unknown()` برمی‌گرداند. این درست است (منشی بدون محیط فعال نباید جایی برود) اما نانوشته است و باعث شده `PatientRecordScopeResolver` منطق منشی را جدا پیاده کند. + +منطق منشی را به خود رزولور اضافه کن، **بدون** تغییر رفتار فعلی: محیط فعال معتبر لازم است و در نبودش `unknown`. + +```php +// src/Shared/Context/EntityContextResolver.php +private function fromRole(User $user): EntityContext +{ + if ($user->hasRole('ROLE_DOCTOR')) { + return EntityContext::forDoctor($this->doctorRepo->findByUser($user)); + } + + if ($user->hasRole('ROLE_CLINIC')) { + $clinic = $this->clinicRepo->findByUser($user); + + return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown(); + } + + // منشی هرگز fallback نقشی ندارد: محیطش فقط از UserActiveContext می‌آید، + // چون یک منشی می‌تواند هم‌زمان به چند پزشک/کلینیک وصل باشد. + return EntityContext::unknown(); +} +``` + +کامنت بالا را عیناً اضافه کن — این تصمیم معماری است، نه توضیح بدیهی. + +**نحوه تست:** تست واحد در `tests/Shared/EntityContextResolverTest.php` — کاربر با `ROLE_SECRETARY` بدون `UserActiveContext` → `$ctx->type === 'unknown'` و `$ctx->isResolved() === false`. + +--- + +### ۲. تست ماتریس نقش‌ها — پیش‌نیاز هر refactor + +قبل از دست زدن به checkerها، پنج سناریوی جدول «ماتریس نقش‌ها» را به‌صورت تست ثبت کن. این تست‌ها **قرارداد** فازهای بعدی‌اند. + +فایل جدید: `tests/Shared/EntityContextResolverTest.php` با `extends ApiTestCase` (الگوی موجود `tests/Appointment/SlotUniquenessTest.php` را دنبال کن — همان helperهای `makeDoctor()` و ساخت داده). + +سناریوهای الزامی: + +```php +public function testIndependentDoctorAlwaysResolvesToOwnPractice(): void; +public function testClinicMemberDoctorResolvesToClinicWhenActiveContextIsClinic(): void; +public function testClinicMemberDoctorFallsBackToOwnPracticeWhenActiveContextIsSelf(): void; +public function testDoctorWhoOwnsClinicResolvesToClinicAndIsNotRestrictedByMemberPermissions(): void; +public function testClinicManagerAlwaysResolvesToClinic(): void; +public function testSecretaryWithoutActiveContextResolvesToUnknown(): void; +public function testExplicitClinicUuidForNonMemberThrowsAccessDenied(): void; +public function testInactiveMemberPermissionFallsBackToOwnPractice(): void; +``` + +هر تست باید روی `toEntityPair()` assert کند، نه روی جزئیات داخلی: + +```php +self::assertSame(['clinic', $clinic->getId()], $ctx->toEntityPair()); +``` + +**نحوه تست:** `ddev exec php bin/phpunit tests/Shared/EntityContextResolverTest.php` — همه سبز. + +--- + +### ۳. حذف تکرار در `AppointmentAccessChecker` + +سه متد `secretaryCanContext()`، `viewableClinicFor()` و `secretaryCan()` را طوری بازنویسی کن که محیط را از `EntityContextResolver` بگیرند، نه از `contextRepo` + دو `findByUuid`. + +`EntityContextResolver` را با constructor injection تزریق کن (نه `new`)، و `UserActiveContextRepository` را از constructor حذف کن اگر بعد از refactor مصرف دیگری ندارد. + +```php +// قبل +$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); +if ($dbUuid === null) { + return false; +} +$clinic = $this->clinicRepo->findByUuid($dbUuid); +if ($clinic !== null) { /* شاخهٔ کلینیک */ } +$doctor = $this->doctorRepo->findByUuid($dbUuid); +/* شاخهٔ مطب شخصی */ + +// بعد +$ctx = $this->contextResolver->resolve($user); +if (!$ctx->isResolved()) { + return false; +} +if ($ctx->isClinic()) { /* شاخهٔ کلینیک — $ctx->clinic در دسترس است */ } +/* شاخهٔ مطب شخصی — $ctx->doctor در دسترس است */ +``` + +**مهم:** `EntityContext` هم `id` و هم خود entity (`clinic`/`doctor`) را نگه می‌دارد، پس بعد از refactor نباید هیچ `findByUuid` اضافه‌ای لازم باشد. اگر جایی لازم شد، یعنی refactor ناقص است. + +**رفتار نباید عوض شود.** یک تفاوت ظریف هست که باید عمداً حفظ شود: `EntityContextResolver::resolve()` بدون `clinicUuid`، اگر محیط فعال نبود به `fromRole()` می‌افتد، در حالی که کد فعلی `AppointmentAccessChecker` در نبود `db_uuid` مستقیم `false` برمی‌گرداند. برای منشی این دو یکی است (نقش منشی fallback ندارد)، ولی برای مسیرهای دیگر نه — پس در متدهای مخصوص منشی، بعد از resolve حتماً `ROLE_SECRETARY` را هم چک کن یا از شرط `isResolved()` استفاده کن، نه بیشتر. + +**نحوه تست:** `ddev exec php bin/phpunit tests/Appointment/` — همهٔ تست‌های موجود نوبت باید بدون تغییر سبز بمانند. اگر تستی قرمز شد، یعنی رفتار عوض شده و refactor غلط است. + +--- + +### ۴. حذف تکرار در `ClinicDoctorAccessChecker` + +همان کار وظیفهٔ ۳ برای `memberClinicId()` و `canOrNonMember()`. + +```php +public function memberClinicId(User $user): ?int +{ + $ctx = $this->contextResolver->resolve($user); + if (!$ctx->isClinic()) { + return null; + } + + $doctor = $this->doctorRepo->findByUser($user); + + return $doctor !== null && $ctx->clinic->hasDoctor($doctor) ? $ctx->clinic->getId() : null; +} +``` + +**نحوه تست:** `ddev exec php bin/phpunit tests/Clinic/ tests/Secretary/` سبز + یک تست جدید که سناریوی ۳ (پزشکِ مالک) را پوشش دهد: مالک کلینیک نباید با `ClinicDoctorPermission` محدود شود حتی اگر ردیف permission برایش وجود داشته باشد. + +--- + +### ۵. بازنویسی `PatientRecordScopeResolver` روی `EntityContextResolver` + +این کلاس دو مسئولیت دارد که باید تفکیک شوند (SOLID / S): + +1. **کدام محیط؟** → کار `EntityContextResolver` +2. **درون آن محیط، دیدن محدود به بیمارانِ کدام پزشکان؟** → مسئولیت واقعی و منحصربه‌فرد این کلاس + +`forDoctorUser()` و `forSecretary()` را طوری بازنویسی کن که بخش اول را از رزولور بگیرند و فقط بخش دوم را خودشان حساب کنند. + +```php +public function resolve(User $user): PatientRecordScope +{ + $ctx = $this->contextResolver->resolve($user); + if (!$ctx->isResolved()) { + return PatientRecordScope::unknown(); + } + + if (!$ctx->isClinic()) { + return PatientRecordScope::forDoctor($ctx->id); + } + + return $this->restrictionInClinic($user, $ctx->clinic); +} + +/** مالک → بدون محدودیت؛ پزشک عضو → فقط بیماران خودش؛ منشی → بیماران پزشکان تخصیص‌یافته. */ +private function restrictionInClinic(User $user, Clinic $clinic): PatientRecordScope +{ + if ($clinic->getUser()->getId() === $user->getId()) { + return PatientRecordScope::forClinic($clinic->getId()); + } + // ... منطق موجود forDoctorUser/forSecretary، فقط بخش محدودیت +} +``` + +`PatientRecordScope` خودش دست‌نخورده می‌ماند — `toLegacyTuple()` هم می‌ماند چون `PatientController` در ۲۰+ نقطه به آن وابسته است. **حذف `toLegacyTuple()` جزو این فاز نیست** و اگر انجام شود دامنهٔ فاز را می‌ترکاند. + +**نحوه تست:** `ddev exec php bin/phpunit tests/Patient/` سبز. علاوه بر آن، تست دستی با دو کاربر واقعی از `clinicpro-qa-accounts`: یک پزشک عضو کلینیک و یک مالک کلینیک — `GET /api/v1/patients` باید برای اولی فقط بیماران خودش و برای دومی همهٔ بیماران کلینیک را برگرداند. + +--- + +### ۶. یکسان‌سازی `BookingContextResolver` + +[`BookingContextResolver`](../../src/Appointment/Service/BookingContextResolver.php) همان تصمیم را برای مسیر رزرو می‌گیرد ولی خروجی‌اش `?Clinic` است، نه `EntityContext`: + +```php +public function resolve(Doctor $doctor, ?string $clinicUuid): ?Clinic +{ + if ($clinicUuid === null || trim($clinicUuid) === '') { + return null; + } + + $clinic = $this->clinicRepo->findByUuid(trim($clinicUuid)); + if ($clinic === null || !$clinic->hasDoctor($doctor)) { + throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'محل نوبت‌دهی یافت نشد', 404); + } + + return $clinic; +} +``` + +**این کلاس را حذف نکن.** قرارداد متفاوتی دارد و عمداً هم متفاوت است: ورودی‌اش `Doctor` است نه `User` (رزرو ممکن است توسط بیمار انجام شود، نه خود پزشک)، و `null` در آن معنای صریحِ «مطب شخصی» دارد، نه «نامشخص». docblock خودش این را توضیح داده. + +کاری که باید بکنی: در docblock هر دو کلاس، ارجاع متقابل بنویس تا معلوم باشد کدام برای کدام مسیر است و چرا دو تا هستند. حذف یکی به نفع دیگری در این فاز، تغییر رفتارِ مسیر رزرو عمومی است و ریسکش بی‌دلیل است. + +**نحوه تست:** بدون تغییر رفتار — `ddev exec php bin/phpunit tests/Appointment/` سبز. + +--- + +### ۷. حذف دسترسی مستقیم به `UserActiveContextRepository` + +بعد از وظایف ۳ تا ۵، این دستور باید فقط خودِ `EntityContextResolver` را برگرداند: + +```bash +ddev exec grep -rln "UserActiveContextRepository" src --include="*.php" +``` + +اگر فایل دیگری ماند (مثلاً `MyAppointmentsController` خط ۵۵۵)، آن را هم به رزولور منتقل کن. اگر انتقال ممکن نبود، **دلیلش را در docblock همان نقطه بنویس** — تا فاز بعدی بداند این استثنا عمدی است. + +**نحوه تست:** خروجی دستور بالا فقط `src/Shared/Context/EntityContextResolver.php` باشد (به‌علاوه استثناهای مستند‌شده). + +## نکات مهم + +- **الگو: Value Object + Resolver.** `EntityContext` یک VO تغییرناپذیر است و `EntityContextResolver` تنها سازندهٔ آن. دلیل انتخاب: تصمیم «کدام محیط» یک بار گرفته می‌شود و بعد به‌صورت داده جابه‌جا می‌شود؛ اگر هر مصرف‌کننده خودش تصمیم بگیرد (وضع فعلی) واگرایی رفتار بین نقش‌ها اجتناب‌ناپذیر است. این الگو **از قبل در پروژه هست** — این فاز فقط آن را فراگیر می‌کند، نه اینکه چیز جدیدی بسازد. +- **هیچ interface جدیدی نساز.** فقط یک پیاده‌سازی وجود دارد و نیاز مشخصی برای تعویض نیست (guidelines §۵). +- **این فاز نباید هیچ رفتار قابل مشاهده‌ای را عوض کند.** خروجی موفق = همان رفتار، با یک منبع حقیقت. هر تست موجودی که قرمز شود، نشانهٔ خطای refactor است نه نیاز به به‌روزرسانی تست. +- **`db_uuid` مبهم است و در این فاز درست نمی‌شود.** یک uuid که ممکن است کلینیک باشد یا پزشک، همیشه دو lookup لازم دارد. افزودن `db_type` به `user_active_context` کار فاز ۳ است (`tenant-03-unify-owner-columns.md`)؛ اینجا فقط تمرکز lookup در یک نقطه است. +- **`EntityContext::toEntityPair()` قرارداد اتصال به فاز ۲ است.** فاز بعدی همین جفت را روی `appointments`، `weekly_schedules` و `date_overrides` می‌نویسد. امضای این متد را عوض نکن. +- **کلاینت‌های متأثر:** این فاز قرارداد API را عوض نمی‌کند، پس `nobat724_front` و `clinic-pro-tauri` نباید تغییری ببینند. اگر حین refactor مجبور شدی شکل پاسخ یک endpoint را عوض کنی، یعنی از دامنهٔ فاز خارج شده‌ای — متوقف شو و گزارش بده. +- **مستندات:** این فاز endpoint جدید نمی‌سازد، پس `docs/api/*` تغییر نمی‌کند. اما اگر رفتار مستندشده‌ای در `docs/api/patient.md` یا `docs/api/appointment.md` با رفتار واقعی نمی‌خواند، همان جلسه اصلاحش کن (guidelines §۴). diff --git a/.claude/prompt/tenant-02-mark-booking-tables.md b/.claude/prompt/tenant-02-mark-booking-tables.md new file mode 100644 index 00000000..ae1cf4fd --- /dev/null +++ b/.claude/prompt/tenant-02-mark-booking-tables.md @@ -0,0 +1,348 @@ +# فاز ۲ — نشانه‌گذاری tenant روی جدول‌های نوبت‌دهی + +> پرامپت دوم از سری پنج‌تایی. **پیش‌نیاز: `tenant-01-entity-context-unify.md` باید کامل و سبز شده باشد.** +> ۱. `tenant-01-entity-context-unify.md` → **۲. نشانه‌گذاری جدول‌های نوبت (همین فایل)** → ۳. `tenant-03-unify-owner-columns.md` → ۴. `tenant-04-enforce-tenant-filter.md` → ۵. `tenant-05-audit-and-docs.md` + +## زمینه + +پس از فاز ۱، تشخیص محیط یک نقطه دارد و `EntityContext::toEntityPair()` جفت `(entity_type, entity_id)` را برمی‌گرداند. اما جدول‌های هستهٔ نوبت‌دهی هنوز این جفت را **ذخیره نمی‌کنند** — آن‌ها با `doctor_id` اجباری و `clinic_id` تهی‌پذیر کار می‌کنند: + +```php +// src/Appointment/Entity/Appointment.php +#[ORM\ManyToOne(targetEntity: Doctor::class)] +private Doctor $doctor; // اجباری + +#[ORM\ManyToOne(targetEntity: \App\Clinic\Entity\Clinic::class)] +private ?\App\Clinic\Entity\Clinic $clinic = null; // NULL = مطب شخصی +``` + +نتیجه: هر کوئری‌ای که می‌خواهد «دادهٔ این محیط» را بگیرد، باید خودش `clinic_id IS NULL ? doctor : clinic` را بازسازی کند. همین بازسازی است که در فاز ۱ به‌عنوان تکرار منطق حذف شد — ولی در **لایهٔ کوئری** هنوز باقی است و فاز ۴ (فیلتر خودکار Doctrine) بدون ستون واقعی روی این جدول‌ها اصلاً کار نمی‌کند. + +## مشکل / هدف + +**مشکل:** `appointments`، `weekly_schedules` و `date_overrides` محیط خود را به‌صورت ضمنی و با دو ستون متفاوت نگه می‌دارند؛ `weekly_schedules` و `date_overrides` برای رفع مشکل یکتاییِ `NULL` مجبور به یک ستون تولیدشدهٔ مخصوص MariaDB شده‌اند: + +```php +// src/Appointment/Entity/WeeklySchedule.php و DateOverride.php +#[ORM\Column(name: 'clinic_key', type: 'integer', insertable: false, updatable: false, + generated: 'ALWAYS', columnDefinition: 'INT AS (IFNULL(clinic_id, 0)) STORED')] +private int $clinicKey = 0; +``` + +**هدف:** جفت `(entity_type, entity_id)` روی این سه جدول ذخیره شود، ایندکس‌ها با tenant به‌عنوان ستون پیشرو بازسازی شوند، و هک `clinic_key` حذف شود. + +### ⚠️ دو چیزی که عمداً تغییر نمی‌کنند + +این دو مورد در کد فعلی دلیل مکتوب دارند و **نباید** به بهانهٔ یکسان‌سازی خراب شوند: + +**۱. کلید یکتایی اسلات، سطح پزشک می‌ماند — نه سطح tenant.** + +```php +// src/Appointment/Entity/Appointment.php +/** + * کلید عمداً clinic ندارد و فقط doctor+slotStart است: برنامهٔ هفتگی هر محیط + * جداست (WeeklySchedule با UNIQUE(doctor_id, clinic_key)) و می‌تواند با محیط + * دیگر هم‌پوشانی داشته باشد، ولی پزشک یک نفر است. افزودن clinic به کلید یعنی + * اجازهٔ رزرو هم‌زمان همان پزشک در مطب و کلینیک — نه رفع باگ. + */ +private function refreshActiveSlotKey(): void +{ + $this->activeSlotKey = !$this->isReserve && in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true) + ? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart) + : null; +} +``` + +افزودن tenant به `active_slot_key` یک باگ امنیتی-تجاری می‌سازد: همان پزشک هم‌زمان در مطب شخصی و کلینیک رزرو می‌شود. **دست نزن.** + +**۲. جدول `holidays` نشانه‌گذاری tenant نمی‌شود.** + +```php +// src/Appointment/Entity/Holiday.php +/** + * تعطیلی پزشک. برخلاف WeeklySchedule و DateOverride، تعطیلی پیش‌فرضاً سراسری است: + * «پزشک آن روز نیست» یک واقعیت فیزیکی است و هم‌زمان روی مطب شخصی و همهٔ کلینیک‌ها + * اثر می‌گذارد (clinic = null). مقدار غیر-NULL یعنی پزشک فقط در همان کلینیک نیست. + */ +``` + +اینجا `clinic = NULL` معنایش «مطب شخصی» **نیست**، معنایش «همهٔ محیط‌ها» است. جفت `(entity_type, entity_id)` نمی‌تواند این را بیان کند و تبدیلش، تعطیلی‌های سراسری را به تعطیلی مطب شخصی تنزل می‌دهد. `holidays` با همان `clinic_id` تهی‌پذیر می‌ماند و در فاز ۴ در whitelist «جدول‌های خارج از فیلتر tenant» قرار می‌گیرد. + +## معیار پذیرش + +- ✅ موفق: بعد از migration، برای هر ردیف `appointments` رابطهٔ زیر برقرار است — قابل تأیید با کوئری در «نحوه تست» وظیفهٔ ۲: + `clinic_id IS NULL → (entity_type='doctor', entity_id=doctor_id)` و `clinic_id IS NOT NULL → (entity_type='clinic', entity_id=clinic_id)`. + ثبت یک نوبت جدید از پنل (هم در محیط کلینیک، هم در محیط مطب شخصی) جفت را خودکار و درست پر می‌کند. +- ❌ خطا: تلاش برای ذخیرهٔ `Appointment` بدون محیطِ حل‌شده (`EntityContext::unknown()`) → `AppException` با کد از `ErrorCodes`، نه `SQLSTATE` خام و نه ردیف با `entity_id = 0`. +- ⚠️ مرزی: دو پزشک **متفاوت** در **یک کلینیک** هر کدام `WeeklySchedule` خودشان را دارند → هر دو با `(entity_type='clinic', entity_id=<همان clinic>)` ذخیره می‌شوند و یکتایی نباید نقض شود (به همین دلیل کلید یکتا باید `doctor_id` را هم داشته باشد — وظیفهٔ ۳). همچنین: نوبتی که `clinic_id` دارد ولی آن کلینیک بعداً حذف شده → migration نباید بشکند (وظیفهٔ ۲، بند «ردیف‌های یتیم»). + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Appointment/Entity/Appointment.php` | افزودن جفت tenant + بازسازی ایندکس‌ها | +| `src/Appointment/Entity/WeeklySchedule.php` | افزودن جفت + حذف `clinic_key` + بازسازی یکتایی | +| `src/Appointment/Entity/DateOverride.php` | همان کار `WeeklySchedule` | +| `src/Appointment/Entity/Holiday.php` | **بدون تغییر** — فقط افزودن یادداشت استثنا در docblock | +| `src/Shared/Context/EntityContext.php` | منبع جفت (`toEntityPair()`) | +| `src/Appointment/Repository/AppointmentRepository.php` | کوئری‌هایی که با `clinic_id IS NULL` محیط را بازسازی می‌کنند | +| `src/Appointment/Repository/WeeklyScheduleRepository.php` | کوئری‌های مبتنی بر `clinic_key` | +| `src/Appointment/Repository/DateOverrideRepository.php` | همان | +| `migrations/` | migration جدید (schema + backfill) | +| `tests/Appointment/SlotUniquenessTest.php` | تست‌های موجود که نباید بشکنند | + +## وضعیت فعلی + +ایندکس‌های فعلی `appointments` — هیچ‌کدام tenant را نمی‌شناسند: + +```php +#[ORM\Entity(repositoryClass: AppointmentRepository::class)] +#[ORM\Table(name: 'appointments')] +#[ORM\Index(columns: ['doctor_id', 'slot_start'], name: 'idx_appointments_doctor_slot')] +#[ORM\Index(columns: ['user_id', 'status'], name: 'idx_appointments_user_status')] +#[ORM\Index(columns: ['status', 'expires_at'], name: 'idx_appointments_status_expires')] +class Appointment +{ + #[ORM\Column(name: 'active_slot_key', type: 'string', length: 64, nullable: true, unique: true)] + private ?string $activeSlotKey = null; +``` + +یکتایی فعلی `weekly_schedules` و `date_overrides` — وابسته به ستون تولیدشده: + +```php +#[ORM\Table(name: 'weekly_schedules')] +#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor_clinic', columns: ['doctor_id', 'clinic_key'])] + +#[ORM\Table(name: 'date_overrides')] +#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_clinic_date', columns: ['doctor_id', 'clinic_key', 'date'])] +``` + +## وظایف + +### ۱. ساخت trait مشترک برای جفت tenant + +سه entity همان دو ستون را می‌گیرند. به‌جای کپی، یک trait در `src/Shared/Tenant/` بساز. + +```php +// src/Shared/Tenant/TenantOwnedTrait.php +namespace App\Shared\Tenant; + +use App\Shared\Context\EntityContext; +use Doctrine\ORM\Mapping as ORM; + +/** + * جفت مالکیتِ محیط — همان قراردادی که ServiceSection و PatientRecord از قبل دارند. + * مقدارها فقط از EntityContext::toEntityPair() می‌آیند تا با فاز ۱ یک منبع بمانند. + */ +trait TenantOwnedTrait +{ + #[ORM\Column(name: 'entity_type', type: 'string', length: 10)] + private string $entityType; + + #[ORM\Column(name: 'entity_id', type: 'integer')] + private int $entityId; + + public function getEntityType(): string { return $this->entityType; } + public function getEntityId(): int { return $this->entityId; } + + public function assignTenant(EntityContext $context): void + { + if (!$context->isResolved()) { + throw new \InvalidArgumentException('Cannot assign an unresolved tenant context.'); + } + + [$this->entityType, $this->entityId] = $context->toEntityPair(); + } +} +``` + +طول `length: 10` عمدی است — با `service_sections` و `clinic_staff` یکی است تا کوئری‌های JOIN بین جدول‌ها collation mismatch ندهند. (`tenant_tags` طول ۲۰ دارد؛ آن ناهماهنگی در فاز ۳ رسیدگی می‌شود، اینجا نه.) + +**نحوه تست:** `ddev exec php vendor/bin/phpstan analyse src/Shared/Tenant` بدون خطا؛ و تست واحد که `assignTenant(EntityContext::unknown())` استثنا پرتاب کند. + +--- + +### ۲. افزودن جفت به `Appointment` + backfill + +trait را به entity اضافه کن و در سازنده از `EntityContext` پرش کن. `doctor` و `clinic` **حذف نمی‌شوند** — جفت جدید یک denormalization عمدی است، نه جایگزین: `doctor_id` هنوز برای یکتایی اسلات و تقویم لازم است. + +migration دو مرحله‌ای بنویس (schema سپس backfill): + +```sql +-- گام ۱: ستون‌ها تهی‌پذیر اضافه شوند +ALTER TABLE appointments + ADD entity_type VARCHAR(10) NULL, + ADD entity_id INT NULL; + +-- گام ۲: backfill — کلینیک اولویت دارد، در نبودش مطب شخصی +UPDATE appointments +SET entity_type = IF(clinic_id IS NULL, 'doctor', 'clinic'), + entity_id = IFNULL(clinic_id, doctor_id); + +-- گام ۳: ردیف‌های یتیم (clinic_id به کلینیک حذف‌شده اشاره می‌کند) +UPDATE appointments a + LEFT JOIN clinics c ON c.id = a.clinic_id +SET a.entity_type = 'doctor', a.entity_id = a.doctor_id +WHERE a.clinic_id IS NOT NULL AND c.id IS NULL; + +-- گام ۴: تأیید صفر بودن باقی‌مانده — اگر صفر نبود migration باید fail کند +-- SELECT COUNT(*) FROM appointments WHERE entity_type IS NULL OR entity_id IS NULL; + +-- گام ۵: تازه حالا NOT NULL +ALTER TABLE appointments + MODIFY entity_type VARCHAR(10) NOT NULL, + MODIFY entity_id INT NOT NULL; +``` + +در متد `up()` migration، بین گام ۴ و ۵ یک `abortIf()` بگذار تا اگر ردیف پر نشده‌ای ماند، migration به‌جای ساختن دادهٔ خراب متوقف شود: + +```php +$remaining = (int) $this->connection->fetchOne( + 'SELECT COUNT(*) FROM appointments WHERE entity_type IS NULL OR entity_id IS NULL' +); +$this->abortIf($remaining > 0, "Backfill left {$remaining} appointments without a tenant."); +``` + +**نحوه تست:** +```bash +ddev exec php bin/console doctrine:migrations:diff --no-interaction +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +# صحت backfill — هر سه کوئری باید 0 برگردانند +ddev exec php bin/console dbal:run-sql "SELECT COUNT(*) FROM appointments WHERE entity_type IS NULL" +ddev exec php bin/console dbal:run-sql "SELECT COUNT(*) FROM appointments WHERE clinic_id IS NULL AND (entity_type<>'doctor' OR entity_id<>doctor_id)" +ddev exec php bin/console dbal:run-sql "SELECT COUNT(*) FROM appointments a JOIN clinics c ON c.id=a.clinic_id WHERE a.entity_type<>'clinic' OR a.entity_id<>a.clinic_id" +``` + +--- + +### ۳. `WeeklySchedule` و `DateOverride` — افزودن جفت و حذف `clinic_key` + +اینجا جفت tenant **جایگزین** `clinic_key` می‌شود، ولی `doctor_id` در کلید یکتا می‌ماند. + +**چرا `doctor_id` باید در کلید بماند:** در یک کلینیک چند پزشک وجود دارد و هر کدام برنامهٔ هفتگی خودش را دارد. اگر کلید فقط `(entity_type, entity_id)` باشد، پزشک دوم همان کلینیک با نقض یکتایی مواجه می‌شود. این همان حالت مرزی معیار پذیرش است. + +```php +#[ORM\Table(name: 'weekly_schedules')] +#[ORM\UniqueConstraint(name: 'uniq_weekly_schedule_doctor_tenant', + columns: ['doctor_id', 'entity_type', 'entity_id'])] + +#[ORM\Table(name: 'date_overrides')] +#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_tenant_date', + columns: ['doctor_id', 'entity_type', 'entity_id', 'date'])] +``` + +migration: + +```sql +ALTER TABLE weekly_schedules ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL; +UPDATE weekly_schedules +SET entity_type = IF(clinic_id IS NULL, 'doctor', 'clinic'), + entity_id = IFNULL(clinic_id, doctor_id); +-- abortIf روی باقی‌ماندهٔ NULL +ALTER TABLE weekly_schedules + MODIFY entity_type VARCHAR(10) NOT NULL, + MODIFY entity_id INT NOT NULL; + +DROP INDEX idx_weekly_schedules_doctor_clinic ON weekly_schedules; +CREATE UNIQUE INDEX uniq_weekly_schedule_doctor_tenant + ON weekly_schedules (doctor_id, entity_type, entity_id); +ALTER TABLE weekly_schedules DROP COLUMN clinic_key; +``` + +**ترتیب حیاتی:** `clinic_key` فقط بعد از ساخته شدن ایندکس جدید حذف شود، وگرنه بین دو دستور، جدول بدون هیچ محافظ یکتایی می‌ماند. همین ترتیب برای `date_overrides`. + +`clinic_id` را نگه دار — چند نقطهٔ کد (از جمله `AssignScheduleClinicCommand` و کوئری‌های `SlotCalculatorService`) هنوز مستقیم به آن وابسته‌اند و حذفش دامنهٔ این فاز را می‌ترکاند. حذفش کار فاز ۵ است، بعد از اینکه فاز ۴ ثابت کرد جفت tenant همه‌جا کافی است. + +**نحوه تست:** +```bash +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +ddev exec php bin/phpunit tests/Appointment/ +# حالت مرزی: دو پزشک در یک کلینیک +ddev exec php bin/console dbal:run-sql "SELECT entity_type, entity_id, COUNT(DISTINCT doctor_id) d FROM weekly_schedules GROUP BY 1,2 HAVING d > 1" +# ↑ باید حداقل یک ردیف برگرداند (یعنی چند پزشک در یک کلینیک) و migration نشکسته باشد +ddev exec php bin/console dbal:run-sql "SHOW COLUMNS FROM weekly_schedules LIKE 'clinic_key'" # خالی +``` + +--- + +### ۴. بازسازی ایندکس‌های `appointments` با tenant پیشرو + +هر ایندکسی که برای کوئری‌های «لیست نوبت‌های این محیط» استفاده می‌شود باید tenant را به‌عنوان **ستون اول** داشته باشد، وگرنه MariaDB نمی‌تواند از آن برای فیلتر tenant استفاده کند. + +```php +#[ORM\Table(name: 'appointments')] +// tenant پیشرو — برای لیست‌های پنل که همیشه محیط‌محورند +#[ORM\Index(columns: ['entity_type', 'entity_id', 'slot_start'], name: 'idx_appointments_tenant_slot')] +#[ORM\Index(columns: ['entity_type', 'entity_id', 'status'], name: 'idx_appointments_tenant_status')] +// بدون tenant — عمدی: یکتایی و تقویم سطح پزشک‌اند، نه محیط +#[ORM\Index(columns: ['doctor_id', 'slot_start'], name: 'idx_appointments_doctor_slot')] +#[ORM\Index(columns: ['user_id', 'status'], name: 'idx_appointments_user_status')] +#[ORM\Index(columns: ['status', 'expires_at'], name: 'idx_appointments_status_expires')] +``` + +`idx_appointments_doctor_slot` را **حذف نکن** — `AppointmentRepository::isSlotTaken()` و `SlotCalculatorService` روی آن کوئری می‌زنند و آن کوئری‌ها ذاتاً سطح پزشک‌اند. + +بعد از migration، پلن کوئری لیست نوبت‌ها را بررسی کن که واقعاً ایندکس جدید را انتخاب می‌کند. + +**نحوه تست:** +```bash +ddev exec php bin/console dbal:run-sql "EXPLAIN SELECT * FROM appointments WHERE entity_type='clinic' AND entity_id=1 AND slot_start > UNIX_TIMESTAMP() ORDER BY slot_start LIMIT 20" +# ستون key باید idx_appointments_tenant_slot باشد، نه NULL و نه ALL +``` + +--- + +### ۵. اتصال نقطهٔ نوشتن به `EntityContext` + +هر جایی که `Appointment`، `WeeklySchedule` یا `DateOverride` **ساخته** می‌شود باید `assignTenant()` را با `EntityContext` صدا بزند. نقاط ساخت را با این دستور پیدا کن: + +```bash +ddev exec grep -rn "new Appointment(\|new WeeklySchedule(\|new DateOverride(" src --include="*.php" +``` + +برای مسیر رزرو عمومی (بیمار، نه کاربر پنل)، محیط از `BookingContextResolver` می‌آید که `?Clinic` برمی‌گرداند؛ تبدیلش: + +```php +$context = $clinic !== null + ? EntityContext::forClinic($clinic) + : EntityContext::forDoctor($doctor); + +$appointment->assignTenant($context); +``` + +**هیچ مقدار پیش‌فرضی نگذار.** اگر محیط حل نشد، `AppException` با کد از `ErrorCodes` پرتاب شود — نه `entity_id = 0` و نه حدس زدن. این همان معیار پذیرش ❌ است. + +**نحوه تست:** ثبت نوبت واقعی از پنل با کاربر تست (`clinicpro-qa-accounts`) در هر دو محیط، سپس: +```bash +ddev exec php bin/console dbal:run-sql "SELECT id, doctor_id, clinic_id, entity_type, entity_id FROM appointments ORDER BY id DESC LIMIT 5" +``` +جفت باید با محیطی که نوبت در آن ثبت شده بخواند. + +--- + +### ۶. یادداشت استثنا روی `Holiday` + +`holidays` نشانه‌گذاری نمی‌شود. برای اینکه فاز ۴ (تست ساختاری روی همهٔ entityها) این را استثنای عمدی بشناسد نه فراموشی، در docblock کلاس یادداشت بگذار: + +```php +/** + * ...docblock موجود... + * + * عمداً جفت (entity_type, entity_id) ندارد: در این جدول clinic = NULL یعنی + * «همهٔ محیط‌ها»، نه «مطب شخصی». جفت tenant نمی‌تواند «همه» را بیان کند و + * تبدیلش، تعطیلی سراسری را به تعطیلی مطب شخصی تنزل می‌دهد. + * در whitelist فاز ۴ (TenantFilter) ثبت شده است. + */ +``` + +**نحوه تست:** بازبینی دستی؛ فاز ۴ این یادداشت را به whitelist تبدیل می‌کند. + +## نکات مهم + +- **الگو: denormalization عمدی + trait مشترک.** جفت tenant روی `appointments` تکرار اطلاعاتی است که از `clinic_id`/`doctor_id` قابل استخراج بود. دلیل پذیرش تکرار: فیلتر خودکار فاز ۴ و ایندکس tenant-پیشرو، هر دو به یک ستون واقعی نیاز دارند و با شرط `IF(clinic_id IS NULL, ...)` قابل ساخت نیستند. trait به‌جای کلاس پایه انتخاب شده چون این سه entity هیچ رفتار مشترک دیگری ندارند و ارث‌بری Doctrine هزینهٔ نقشه‌برداری اضافه می‌کند. +- **`active_slot_key` را دست نزن** (بخش «دو چیزی که عمداً تغییر نمی‌کنند»). اگر تستی از تو خواست آن را عوض کنی، تست غلط است نه کد. +- **`clinic_id` و `doctor_id` حذف نمی‌شوند.** این فاز فقط اضافه می‌کند. حذف ستون‌های قدیمی بعد از اثبات کفایت جفت tenant در فاز ۵ بررسی می‌شود. +- **ترتیب migration:** ستون تهی‌پذیر → backfill → `abortIf` روی باقی‌ماندهٔ NULL → `NOT NULL` → ایندکس جدید → حذف ایندکس/ستون قدیمی. هر جابه‌جایی در این ترتیب یعنی پنجره‌ای که جدول بدون محافظ یکتایی می‌ماند. +- **قبل از migrate روی دادهٔ واقعی، بکاپ بگیر:** `ddev export-db --file=/tmp/pre-tenant-phase2.sql.gz`. این migration دادهٔ موجود را می‌نویسد و rollback خودکار ندارد. +- **کلاینت‌های متأثر:** `entity_type`/`entity_id` نباید در پاسخ API ظاهر شوند مگر جایی که از قبل ظاهر می‌شدند. اگر `toArray()` نوبت را عوض کردی، `nobat724_front/services/response.js` و `clinic-pro-tauri/src/service/response.js` باید دستی بررسی شوند — build آن‌ها خطا نمی‌دهد. +- **مستندات:** اگر شکل پاسخ نوبت تغییر کرد، `docs/api/appointment.md` همان جلسه به‌روز شود با JSON واقعیِ خروجی اجرا، نه دست‌ساز (guidelines §۴). diff --git a/.claude/prompt/tenant-03-unify-owner-columns.md b/.claude/prompt/tenant-03-unify-owner-columns.md new file mode 100644 index 00000000..e0f085e5 --- /dev/null +++ b/.claude/prompt/tenant-03-unify-owner-columns.md @@ -0,0 +1,366 @@ +# فاز ۳ — یکسان‌سازی نمایش‌های ناهمگون tenant + +> پرامپت سوم از سری پنج‌تایی. **پیش‌نیاز: فازهای ۱ و ۲ کامل و سبز.** +> ۱. `tenant-01-entity-context-unify.md` → ۲. `tenant-02-mark-booking-tables.md` → **۳. یکسان‌سازی ستون‌ها (همین فایل)** → ۴. `tenant-04-enforce-tenant-filter.md` → ۵. `tenant-05-audit-and-docs.md` + +## زمینه + +بعد از فاز ۲، جدول‌های نوبت‌دهی هم جفت `(entity_type, entity_id)` دارند. اما همان مفهوم در بقیهٔ دیتابیس با **چهار املای متفاوت** نوشته شده و فاز ۴ (فیلتر خودکار) نمی‌تواند روی چهار املا کار کند — فیلتر Doctrine روی نام فیلد تصمیم می‌گیرد. + +نقشهٔ وضعیت فعلی: + +| املا | جدول‌ها | +|---|---| +| `entity_type` + `entity_id` (**هدف**) | `service_sections`, `patient_records`, `clinic_staff`, `clinic_subscriptions`, `sms_wallets`, `sms_settings`, `entity_insurance_pricing`, `tenant_insurances`, `tenant_service_category_settings`, `invoices`, `claims`, `inventory_items`, `inventory_packages`, `tenant_tags` + (از فاز ۲) `appointments`, `weekly_schedules`, `date_overrides` | +| `owner_type` + `owner_id` | `discount_rules` | +| `owner_type` + `clinic_id` تهی‌پذیر | `doctor_secretaries` | +| `db_uuid` مبهم (کلینیک یا پزشک؟) | `user_active_context` | + +به‌علاوه یک ناهماهنگی طولی: `entity_type` در ۱۱ جدول `VARCHAR(10)` و در ۴ جدول `VARCHAR(20)` است. + +## مشکل / هدف + +**مشکل ۱ — فیلتر خودکار روی چهار املا کار نمی‌کند.** `TenantFilter` فاز ۴ با `$meta->hasField('entityType')` تصمیم می‌گیرد؛ `discount_rules` و `doctor_secretaries` بی‌صدا از فیلتر جا می‌مانند — یعنی دقیقاً همان نشتی خاموشی که کل این پروژه برای جلوگیری از آن است. + +**مشکل ۲ — `db_uuid` هر بار دو کوئری می‌خواهد.** الگوی زیر در `EntityContextResolver` اجتناب‌ناپذیر است چون خود ستون نمی‌گوید uuid مال چیست: + +```php +// src/Shared/Context/EntityContextResolver.php — fromActiveContext() +$clinic = $this->clinicRepo->findByUuid($active->getDbUuid()); +if ($clinic !== null) { ... } + +$doctor = $this->doctorRepo->findByUuid($active->getDbUuid()); +``` + +**مشکل ۳ — یک باگ واقعی در یکتایی `doctor_secretaries`.** کلید یکتای فعلی: + +```php +#[ORM\Table(name: 'doctor_secretaries')] +#[ORM\UniqueConstraint(name: 'idx_doctor_secretary_scope', columns: ['doctor_id', 'secretary_id', 'owner_type'])] +``` + +`owner_type` فقط `'doctor'` یا `'clinic'` است و `clinic_id` در کلید نیست. نتیجه: **یک منشی نمی‌تواند به یک پزشک در دو کلینیک متفاوت تخصیص یابد** — ردیف دوم با `(doctor_id, secretary_id, 'clinic')` تکراری می‌شود و رد می‌شود. این با سناریوی ۲ ماتریس نقش‌ها (پزشکی که عضو چند کلینیک است) مستقیماً در تضاد است. یکسان‌سازی این کلید را درست می‌کند. + +**هدف:** یک املای واحد `(entity_type, entity_id)` با طول یکسان در کل دیتابیس، به‌علاوه رفع باگ یکتایی بالا. + +## معیار پذیرش + +- ✅ موفق: بعد از migration، این دستور خروجی خالی می‌دهد (هیچ `owner_type` باقی نمانده): + `ddev exec grep -rn "owner_type\|ownerType" src --include="*.php"` + و همهٔ ستون‌های `entity_type` طول یکسان دارند. قوانین تخفیف و منشی‌های موجود بدون تغییر رفتار کار می‌کنند. +- ❌ خطا: ساخت `DiscountRule` با محیط حل‌نشده (`EntityContext::unknown()`) → استثنا، نه ردیف با `entity_id = 0`. +- ⚠️ مرزی: **همان منشی، همان پزشک، دو کلینیک متفاوت** → هر دو ردیف با موفقیت ذخیره می‌شوند (قبل از این فاز، ردیف دوم رد می‌شد). این تست باید صریحاً نوشته شود چون رفع یک باگ است، نه صرفاً refactor. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Discount/Entity/DiscountRule.php` | `owner_type`/`owner_id` → `entity_type`/`entity_id` | +| `src/Discount/Controller/DiscountController.php` | مصرف‌کنندهٔ ستون‌های قدیمی | +| `src/Secretary/Entity/DoctorSecretary.php` | `owner_type` + `clinic_id` → جفت + رفع یکتایی | +| `src/Secretary/Repository/DoctorSecretaryRepository.php` | کوئری‌های `owner_type` | +| `src/Secretary/Security/SecretaryAccessChecker.php` | مصرف‌کننده | +| `src/Auth/Entity/UserActiveContext.php` | افزودن `db_type` | +| `src/Shared/Context/EntityContextResolver.php` | حذف حدس دوگانه در `fromActiveContext()` | +| `src/Tag/Entity/TenantTag.php`, `src/Inventory/Entity/InventoryItem.php`, `src/Inventory/Entity/InventoryPackage.php` | طول `entity_type` = ۲۰ → ۱۰ | +| `src/Auth/Entity/MobileVerificationOtp.php` | `entity_type` دارد ولی **معنایش tenant نیست** — بند «نکات مهم» | +| `src/Shared/Tenant/TenantOwnedTrait.php` | trait ساخته‌شده در فاز ۲ | +| `migrations/` | migrationهای rename + backfill | + +## وضعیت فعلی + +```php +// src/Discount/Entity/DiscountRule.php +#[ORM\Table(name: 'discount_rules')] +#[ORM\Index(columns: ['owner_type', 'owner_id', 'active'], name: 'idx_discount_rules_owner')] +class DiscountRule +{ + #[ORM\Column(name: 'owner_type', type: 'string', length: 10)] + private string $ownerType; + + #[ORM\Column(name: 'owner_id', type: 'integer')] + private int $ownerId; +``` + +```php +// src/Secretary/Entity/DoctorSecretary.php +#[ORM\Table(name: 'doctor_secretaries')] +#[ORM\UniqueConstraint(name: 'idx_doctor_secretary_scope', columns: ['doctor_id', 'secretary_id', 'owner_type'])] +class DoctorSecretary +{ + public const OWNER_DOCTOR = 'doctor'; + public const OWNER_CLINIC = 'clinic'; + + #[ORM\Column(name: 'owner_type', type: 'string', length: 10, options: ['default' => 'doctor'])] + private string $ownerType; + + #[ORM\ManyToOne(targetEntity: Clinic::class)] + #[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')] + private ?Clinic $clinic = null; +``` + +```php +// src/Auth/Entity/UserActiveContext.php +#[ORM\Column(name: 'db_uuid', type: 'string', length: 36)] +private string $dbUuid; +``` + +## وظایف + +### ۱. `discount_rules` — تغییر نام ستون‌ها + +ساده‌ترین مورد سری: هیچ تغییر معنایی ندارد، فقط نام. + +```php +// src/Discount/Entity/DiscountRule.php +use App\Shared\Tenant\TenantOwnedTrait; + +#[ORM\Table(name: 'discount_rules')] +#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_discount_rules_tenant')] +class DiscountRule +{ + use TenantOwnedTrait; + // ownerType / ownerId حذف می‌شوند +``` + +migration: + +```sql +ALTER TABLE discount_rules + CHANGE owner_type entity_type VARCHAR(10) NOT NULL, + CHANGE owner_id entity_id INT NOT NULL; + +DROP INDEX idx_discount_rules_owner ON discount_rules; +CREATE INDEX idx_discount_rules_tenant ON discount_rules (entity_type, entity_id, active); +``` + +`CHANGE` به‌جای `ADD`+`UPDATE`+`DROP` استفاده می‌شود چون داده جابه‌جا نمی‌شود، فقط نام ستون — بدون ریسک backfill. + +بعد از تغییر entity، همهٔ نقاط مصرف را پیدا و اصلاح کن: + +```bash +ddev exec grep -rn "getOwnerType\|getOwnerId\|setOwnerType\|setOwnerId\|owner_type\|owner_id" src assets --include="*.php" --include="*.tsx" --include="*.ts" +``` + +اگر `owner_type` در پاسخ API تخفیف‌ها ظاهر می‌شود، **نام فیلد پاسخ را عوض نکن** مگر اینکه پنل ادمین را هم در همان جلسه اصلاح کنی — این یک تغییر قرارداد است. + +**نحوه تست:** +```bash +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +ddev exec php bin/phpunit tests/ # کل سوئیت +ddev exec php bin/console dbal:run-sql "SELECT entity_type, COUNT(*) FROM discount_rules GROUP BY 1" +``` +به‌علاوه تست دستی: صفحهٔ تخفیف‌های پنل ادمین باز شود، یک قانون ساخته و ویرایش شود. + +--- + +### ۲. `doctor_secretaries` — یکسان‌سازی + رفع باگ یکتایی + +اینجا فقط تغییر نام نیست: `owner_type` + `clinic_id` تهی‌پذیر باید به جفت واقعی تبدیل شود و کلید یکتا اصلاح گردد. + +نگاشت داده: + +| وضعیت فعلی | جفت جدید | +|---|---| +| `owner_type='doctor'` (و `clinic_id` تهی) | `('doctor', doctor_id)` | +| `owner_type='clinic'` و `clinic_id = X` | `('clinic', X)` | +| `owner_type='clinic'` ولی `clinic_id` تهی (دادهٔ خراب) | باید شناسایی و گزارش شود — بند «ردیف‌های ناسازگار» | + +```php +#[ORM\Table(name: 'doctor_secretaries')] +#[ORM\UniqueConstraint(name: 'uniq_doctor_secretary_scope', + columns: ['doctor_id', 'secretary_id', 'entity_type', 'entity_id'])] +class DoctorSecretary +{ + use TenantOwnedTrait; +``` + +migration: + +```sql +ALTER TABLE doctor_secretaries ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL; + +UPDATE doctor_secretaries +SET entity_type = owner_type, + entity_id = IF(owner_type = 'clinic', clinic_id, doctor_id); + +-- ردیف‌های ناسازگار: owner_type='clinic' ولی clinic_id تهی +-- SELECT id, doctor_id, secretary_id FROM doctor_secretaries +-- WHERE owner_type='clinic' AND clinic_id IS NULL; + +ALTER TABLE doctor_secretaries + MODIFY entity_type VARCHAR(10) NOT NULL, + MODIFY entity_id INT NOT NULL; + +DROP INDEX idx_doctor_secretary_scope ON doctor_secretaries; +CREATE UNIQUE INDEX uniq_doctor_secretary_scope + ON doctor_secretaries (doctor_id, secretary_id, entity_type, entity_id); + +ALTER TABLE doctor_secretaries DROP COLUMN owner_type; +``` + +**ردیف‌های ناسازگار:** در `up()` قبل از `NOT NULL` یک `abortIf` بگذار. اگر چنین ردیف‌هایی وجود داشت، migration باید متوقف شود و شناسه‌ها را در پیام خطا بیاورد — تصمیم دربارهٔ دادهٔ خراب کار انسان است، نه migration: + +```php +$broken = $this->connection->fetchFirstColumn( + "SELECT id FROM doctor_secretaries WHERE owner_type='clinic' AND clinic_id IS NULL" +); +$this->abortIf($broken !== [], 'Inconsistent doctor_secretaries rows: ' . implode(',', $broken)); +``` + +`clinic_id` را نگه دار — `SecretaryAccessChecker` و `DoctorSecretaryRepository::findActiveClinicRow()` مستقیم روی رابطهٔ Doctrine کوئری می‌زنند و حذفش دامنهٔ فاز را می‌ترکاند. + +**نحوه تست:** +```bash +ddev exec php bin/phpunit tests/Secretary/ +``` +به‌علاوه یک تست جدید که باگ رفع‌شده را تثبیت کند — بدون این تست، وظیفه تمام نیست: + +```php +// tests/Secretary/SecretaryMultiClinicScopeTest.php +public function testSameSecretaryCanServeSameDoctorInTwoDifferentClinics(): void +{ + // دو کلینیک، یک پزشک عضو هر دو، یک منشی + // هر دو ردیف باید ذخیره شوند و findActiveClinicRow برای هر کلینیک + // ردیف مخصوص همان کلینیک را برگرداند +} +``` + +--- + +### ۳. `user_active_context` — رفع ابهام `db_uuid` + +ستون `db_type` اضافه کن تا `EntityContextResolver` بداند uuid مال چیست و به‌جای دو کوئری، یکی بزند. + +```php +// src/Auth/Entity/UserActiveContext.php +#[ORM\Column(name: 'db_type', type: 'string', length: 10)] +private string $dbType; // 'doctor' | 'clinic' + +#[ORM\Column(name: 'db_uuid', type: 'string', length: 36)] +private string $dbUuid; +``` + +backfill از روی خود داده — uuid را در هر دو جدول بگرد: + +```sql +ALTER TABLE user_active_context ADD db_type VARCHAR(10) NULL; + +UPDATE user_active_context uac + JOIN clinics c ON c.uuid = uac.db_uuid +SET uac.db_type = 'clinic'; + +UPDATE user_active_context uac + JOIN doctors d ON d.uuid = uac.db_uuid +SET uac.db_type = 'doctor' +WHERE uac.db_type IS NULL; + +-- ردیف‌های یتیم (uuid به موجودیت حذف‌شده اشاره می‌کند) پاک شوند: +DELETE FROM user_active_context WHERE db_type IS NULL; + +ALTER TABLE user_active_context MODIFY db_type VARCHAR(10) NOT NULL; +``` + +حذف ردیف‌های یتیم اینجا بی‌خطر است: `user_active_context` صرفاً «آخرین محیط انتخاب‌شده» را کش می‌کند و نبودش یعنی `EntityContextResolver` به `fromRole()` می‌افتد — همان رفتار کاربر تازه‌وارد. **این تنها جدول این سری است که حذف ردیف در آن مجاز است**؛ در هیچ migration دیگری `DELETE` ننویس. + +سپس `fromActiveContext()` را ساده کن: + +```php +private function fromActiveContext(User $user): ?EntityContext +{ + $active = $this->activeContextRepo->findByUser($user); + if ($active === null) { + return null; + } + + if ($active->getDbType() === EntityContext::TYPE_CLINIC) { + $clinic = $this->clinicRepo->findByUuid($active->getDbUuid()); + + return $clinic !== null && $this->canActInClinic($user, $clinic) + ? EntityContext::forClinic($clinic) + : null; + } + + $doctor = $this->doctorRepo->findByUuid($active->getDbUuid()); + + return $doctor !== null && $doctor->getUser()->getId() === $user->getId() + ? EntityContext::forDoctor($doctor) + : null; +} +``` + +هر نقطه‌ای که `UserActiveContext` می‌سازد یا `setDbUuid()` صدا می‌زند باید `db_type` را هم بدهد. نقاط را پیدا کن: + +```bash +ddev exec grep -rn "new UserActiveContext(\|setDbUuid(" src --include="*.php" +``` + +**نحوه تست:** +```bash +ddev exec php bin/phpunit tests/Shared/EntityContextResolverTest.php # از فاز ۱ +ddev exec php bin/console dbal:run-sql "SELECT db_type, COUNT(*) FROM user_active_context GROUP BY 1" +``` +تست دستی: سوییچ محیط از پنل ادمین (کاربر سناریوی ۳ — پزشکِ مالک کلینیک) و بررسی اینکه `db_type` درست ذخیره می‌شود. + +--- + +### ۴. یکسان‌سازی طول `entity_type` + +چهار جدول `VARCHAR(20)` دارند در حالی که یازده جدول `VARCHAR(10)`: + +| جدول | فایل | اقدام | +|---|---|---| +| `tenant_tags` | `src/Tag/Entity/TenantTag.php` | → ۱۰ | +| `inventory_items` | `src/Inventory/Entity/InventoryItem.php` | → ۱۰ | +| `inventory_packages` | `src/Inventory/Entity/InventoryPackage.php` | → ۱۰ | +| `mobile_verification_otp` | `src/Auth/Entity/MobileVerificationOtp.php` | **دست نزن** — بند «نکات مهم» | + +قبل از تغییر طول، مطمئن شو هیچ مقدار بلندتری در داده نیست: + +```sql +SELECT DISTINCT entity_type, LENGTH(entity_type) FROM tenant_tags; +SELECT DISTINCT entity_type, LENGTH(entity_type) FROM inventory_items; +SELECT DISTINCT entity_type, LENGTH(entity_type) FROM inventory_packages; +``` + +اگر مقداری غیر از `doctor`/`clinic` بود، **متوقف شو و گزارش بده** — یعنی این ستون معنای دیگری هم دارد و فرض این فاز غلط است. + +سپس این سه entity هم `TenantOwnedTrait` را بگیرند تا تعریف تکراری حذف شود. + +**نحوه تست:** +```bash +ddev exec php bin/console doctrine:migrations:diff --no-interaction # باید فقط تغییر طول را ببیند +ddev exec php bin/phpunit tests/Inventory/ tests/Tag/ +``` + +--- + +### ۵. تست ماتریس نقش، تکرار روی داده‌های یکسان‌شده + +تست‌های فاز ۱ فقط رزولور را پوشش می‌دادند. حالا که ستون‌ها یکسان شده‌اند، یک تست یکپارچه بنویس که برای **هر پنج نقش** ماتریس، دادهٔ متعلق به محیط درست را از چند دامنه می‌گیرد: + +```php +// tests/Shared/TenantIsolationMatrixTest.php +/** + * @dataProvider roleScenarios + */ +public function testEachRoleSeesOnlyItsOwnTenantData(string $scenario): void +{ + // برای هر نقش: GET روی discounts, patients, services, staff, inventory + // هیچ رکورد متعلق به tenant دیگر نباید در پاسخ باشد +} +``` + +این تست ستون فقرات فاز ۴ است — فیلتر خودکار باید همین تست را سبز نگه دارد. + +**نحوه تست:** `ddev exec php bin/phpunit tests/Shared/TenantIsolationMatrixTest.php` + +## نکات مهم + +- **`mobile_verification_otp.entity_type` را دست نزن.** با اینکه نامش یکی است، معنایش tenant نیست — نوع موجودیتی است که کد تأیید برایش صادر شده. قبل از هر تغییری معنایش را از `src/Auth/` تأیید کن؛ اگر واقعاً tenant نبود، در فاز ۴ باید در whitelist استثناها ثبت شود، نه در فیلتر. +- **`CHANGE` در برابر `ADD`+`UPDATE`+`DROP`:** برای `discount_rules` که فقط نام عوض می‌شود، `CHANGE` امن و سریع است. برای `doctor_secretaries` که مقدار جدید از دو ستون ساخته می‌شود، باید سه‌مرحله‌ای باشد. این تفاوت را رعایت کن. +- **رفع باگ یکتایی `doctor_secretaries` یک تغییر رفتار عمدی است** — تنها تغییر رفتاری کل این سری. در گزارش پایانی صریحاً ذکرش کن؛ یعنی از این به بعد ترکیبی که قبلاً رد می‌شد پذیرفته می‌شود. +- **بکاپ قبل از migrate:** `ddev export-db --file=/tmp/pre-tenant-phase3.sql.gz`. +- **الگو: Trait مشترک (ادامهٔ فاز ۲).** دلیل: ستون‌ها و منطق `assignTenant()` یکی‌اند و هفده entity آن را می‌گیرند؛ تکرار تعریف در هر کدام یعنی هفده نقطهٔ واگرایی برای فیلتر فاز ۴. +- **کلاینت‌های متأثر:** اگر نام فیلدی در پاسخ API عوض شد (`owner_type` → `entity_type`)، هم پنل ادمین (`assets/admin/`)، هم `nobat724_front/services/response.js` و هم `clinic-pro-tauri/src/service/response.js` باید دستی بررسی شوند. **پیشنهاد کم‌ریسک:** نام ستون دیتابیس عوض شود ولی کلید JSON پاسخ دست‌نخورده بماند، مگر اینکه هر سه کلاینت در همین جلسه اصلاح شوند. +- **مستندات:** `docs/api/` هر دامنه‌ای که شکل پاسخش عوض شده (`discount`, `secretary`) همان جلسه با JSON واقعی به‌روز شود. diff --git a/.claude/prompt/tenant-04-enforce-tenant-filter.md b/.claude/prompt/tenant-04-enforce-tenant-filter.md new file mode 100644 index 00000000..a7d748a4 --- /dev/null +++ b/.claude/prompt/tenant-04-enforce-tenant-filter.md @@ -0,0 +1,416 @@ +# فاز ۴ — اجباری‌کردن جداسازی tenant در لایهٔ Doctrine + +> پرامپت چهارم از سری پنج‌تایی. **پیش‌نیاز: فازهای ۱ تا ۳ کامل و سبز.** +> ۱. `tenant-01-entity-context-unify.md` → ۲. `tenant-02-mark-booking-tables.md` → ۳. `tenant-03-unify-owner-columns.md` → **۴. اجبار فیلتر (همین فایل)** → ۵. `tenant-05-audit-and-docs.md` + +## زمینه + +بعد از فاز ۳، هر جدولِ tenant-دار یک جفت `(entity_type, entity_id)` با املا و طول یکسان دارد. اما جداسازی هنوز **اختیاری** است: هر کوئری باید خودش `WHERE entity_type = ... AND entity_id = ...` بگذارد. یک `WHERE` فراموش‌شده در یک repository = نشت دادهٔ پزشکی بین دو کلینیک. + +MariaDB معادل Row Level Security پستگرس را ندارد، پس این تضمین باید در لایهٔ ORM ساخته شود. Doctrine برای همین `SQLFilter` دارد: شرطی که خودکار به هر کوئری DQL روی entityهای هدف چسبانده می‌شود. + +## مشکل / هدف + +**مشکل:** جداسازی tenant به یادآوری برنامه‌نویس وابسته است. با ۱۱۸ entity و ۱۴۸ فایل تست، تضمین اینکه هیچ‌کس هرگز فراموش نکند، ممکن نیست. + +**هدف:** فیلتر خودکار Doctrine + listener تخصیص خودکار + یک تست ساختاری که جلوی رگرسیون آینده را بگیرد. + +### آنچه فیلتر تضمین می‌کند و آنچه نمی‌کند + +این جدول باید در docblock فیلتر هم نوشته شود — امنیت کاذب خطرناک‌تر از نبود امنیت است: + +| مسیر | فیلتر اعمال می‌شود؟ | چه باید کرد | +|---|---|---| +| DQL و QueryBuilder | ✅ | — | +| بارگذاری تنبل کالکشن‌ها (`OneToMany`/`ManyToMany`) | ✅ | — | +| `EntityManager::find($class, $id)` | ❌ **نه** | دریافت با DQL از repository، یا چک صریح tenant بعد از `find()` | +| `getReference()` | ❌ نه | همان بالا | +| entity که از قبل در identity map است | ❌ نه | آگاه باش | +| SQL خام DBAL (۸ فایل پروژه) | ❌ نه | `WHERE` دستی — فاز ۵ | + +پس فیلتر **تور ایمنی** است، نه جایگزین `AppointmentAccessChecker` و `PatientRecordScopeResolver`. آن‌ها باقی می‌مانند. + +## معیار پذیرش + +- ✅ موفق: با توکن مالک کلینیک A، `GET /api/v1/patients`، `GET /api/v1/inventory/items`، `GET /api/v1/discounts` و `GET /api/v1/clinic-services/sections` هیچ رکورد متعلق به کلینیک B یا به مطب شخصی هیچ پزشکی برنمی‌گردانند — حتی اگر `WHERE` دستی از کوئری repository حذف شود. (این آخری معیار واقعی است: در تست، فیلتر باید به‌تنهایی کافی باشد.) +- ❌ خطا: ساخت entity از یک entityِ tenant-دار بدون محیط حل‌شده (مثلاً از داخل messenger consumer که کاربر ندارد) → استثنای صریح با پیام قابل فهم، نه ردیف با `entity_id = 0` و نه خطای `SQLSTATE[23000]` خام. +- ⚠️ مرزی: endpointهای عمومی سایت (`nobat724_front`) که عمداً cross-tenant هستند — جستجوی پزشک، صفحهٔ پزشک، لیست شهرها — همچنان دادهٔ همهٔ کلینیک‌ها را برمی‌گردانند و فیلتر آن‌ها را خفه نمی‌کند. تست این حالت اجباری است، وگرنه سایت عمومی بی‌صدا خالی می‌شود. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Shared/Tenant/TenantFilter.php` | فیلتر SQL — جدید | +| `src/Shared/Tenant/TenantAssignmentListener.php` | تخصیص خودکار در `prePersist` — جدید | +| `src/Shared/Tenant/TenantFilterSubscriber.php` | فعال‌سازی فیلتر در `kernel.request` — جدید | +| `src/Shared/Tenant/GlobalTables.php` | whitelist جدول‌های خارج از tenant — جدید | +| `src/Shared/Tenant/TenantOwnedTrait.php` | از فاز ۲ | +| `src/Shared/Context/EntityContextResolver.php` | منبع محیط | +| `config/packages/doctrine.yaml` | ثبت فیلتر | +| `tests/Shared/TenantIsolationMatrixTest.php` | از فاز ۳ — باید سبز بماند | +| `tests/Shared/TenantSchemaCoverageTest.php` | تست ساختاری — جدید | + +## وضعیت فعلی + +`doctrine.yaml` فعلاً هیچ فیلتری ثبت نکرده — فقط دو تابع DQL برای JSON: + +```yaml +# config/packages/doctrine.yaml +doctrine: + orm: + naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware + auto_mapping: true + mappings: + App: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src' + prefix: 'App' + alias: App + dql: + string_functions: + JSON_CONTAINS: App\Shared\Doctrine\JsonContains + JSON_EXTRACT: App\Shared\Doctrine\JsonExtract +``` + +## وظایف + +### ۱. whitelist جدول‌های سراسری + +قبل از فیلتر، فهرست صریحِ چیزهایی که **نباید** tenant داشته باشند لازم است. بدون این فهرست، تست ساختاری وظیفهٔ ۵ قابل نوشتن نیست. + +```php +// src/Shared/Tenant/GlobalTables.php +namespace App\Shared\Tenant; + +/** + * Entityهایی که عمداً به هیچ tenant تعلق ندارند. هر افزوده‌ای به این فهرست + * باید دلیل داشته باشد — این فهرست تنها راه فرار از تست پوشش tenant است. + */ +final class GlobalTables +{ + /** @var array کلاس => دلیل */ + public const ENTITIES = [ + // دادهٔ مرجع مشترک بین همهٔ tenantها + \App\Category\Entity\Category::class => 'تاکسونومی سراسری (استان/شهر/تخصص/بیمه)', + \App\Location\Entity\Province::class => 'دادهٔ جغرافیایی کشور', + \App\Location\Entity\City::class => 'دادهٔ جغرافیایی کشور', + \App\Specialty\Entity\Specialty::class => 'تاکسونومی سراسری تخصص‌ها', + \App\Insurance\Entity\Insurance::class => 'فهرست بیمه‌های کشور', + \App\Tag\Entity\Tag::class => 'تاکسونومی سراسری (در برابر TenantTag که tenant دارد)', + + // هویت — بیمار می‌تواند مشتری چند tenant باشد + \App\Auth\Entity\User::class => 'هویت سراسری؛ رابطهٔ بیمار↔محیط از patient_records می‌آید', + \App\UserProfile\Entity\UserProfile::class => 'پروفایل کاربر، نه دادهٔ محیط', + + // محتوای عمومی سایت + \App\Blog\Entity\Blog::class => 'محتوای عمومی مارکت‌پلیس', + + // خودِ موجودیت‌های tenant — نمی‌توانند به خودشان اشاره کنند + \App\Doctor\Entity\Doctor::class => 'خودش یک tenant است', + \App\Clinic\Entity\Clinic::class => 'خودش یک tenant است', + + // استثناهای مستند در فازهای قبلی + \App\Appointment\Entity\Holiday::class => 'clinic=NULL یعنی «همهٔ محیط‌ها»، نه مطب شخصی — فاز ۲', + \App\Auth\Entity\MobileVerificationOtp::class => 'entity_type دارد ولی معنایش tenant نیست — فاز ۳', + \App\Auth\Entity\UserActiveContext::class => 'خودش تعیین‌کنندهٔ محیط است', + ]; +} +``` + +**این فهرست را از روی این پرامپت کورکورانه کپی نکن.** هر کلاس را باز کن و تأیید کن که واقعاً بی‌tenant است. اگر entityای پیدا کردی که نه tenant دارد و نه در این فهرست جا دارد (مثل `payments`، `settlements`، `financial_breakdowns`، `claim_items`)، **آن را در گزارش به‌عنوان کار باقی‌مانده اعلام کن** و در فهرست موقت با دلیل `'TODO فاز بعدی — هنوز طبقه‌بندی نشده'` بگذار. جدول‌های مالی عمداً در دامنهٔ این سری نیستند چون مالکیتشان (پرداخت‌کننده در برابر دریافت‌کننده) تحلیل جداگانه می‌خواهد. + +**نحوه تست:** بازبینی دستی + خروجی وظیفهٔ ۵. + +--- + +### ۲. `TenantFilter` + +```php +// src/Shared/Tenant/TenantFilter.php +namespace App\Shared\Tenant; + +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Query\Filter\SQLFilter; + +/** + * جداسازی خودکار محیط روی هر کوئری DQL. + * + * تور ایمنی است، نه جایگزین authorization: روی EntityManager::find()، + * getReference()، entityهای موجود در identity map و SQL خام DBAL اعمال نمی‌شود. + * checkerهای دامنه (AppointmentAccessChecker، PatientRecordScopeResolver) سر جایشان می‌مانند. + */ +final class TenantFilter extends SQLFilter +{ + public const NAME = 'tenant'; + + public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string + { + if (!$targetEntity->hasField('entityType') || !$targetEntity->hasField('entityId')) { + return ''; + } + + return sprintf( + '%s.entity_type = %s AND %s.entity_id = %s', + $targetTableAlias, + $this->getParameter('entity_type'), + $targetTableAlias, + $this->getParameter('entity_id'), + ); + } +} +``` + +`getParameter()` خودش مقدار را escape می‌کند؛ هرگز مقدار خام را داخل رشته نگذار. + +ثبت در پیکربندی — **پیش‌فرض خاموش**: + +```yaml +# config/packages/doctrine.yaml +doctrine: + orm: + filters: + tenant: + class: App\Shared\Tenant\TenantFilter + enabled: false +``` + +`enabled: false` عمدی است: فیلتر فقط وقتی روشن می‌شود که محیط حل شده باشد. بدون این، مسیرهای عمومی و کامندهای کنسول می‌شکنند. + +**نحوه تست:** `ddev exec php bin/console debug:container --parameter=doctrine.orm.filters` یا صرفاً بالا آمدن کانتینر بدون خطا؛ تست واقعی در وظیفهٔ ۳. + +--- + +### ۳. فعال‌سازی فیلتر در ابتدای هر درخواست + +```php +// src/Shared/Tenant/TenantFilterSubscriber.php +namespace App\Shared\Tenant; + +use App\Shared\Context\EntityContextResolver; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Bundle\SecurityBundle\Security; + +/** + * فیلتر tenant را فقط وقتی روشن می‌کند که کاربر احراز شده و محیطش حل شود. + * + * مسیرهای عمومی مارکت‌پلیس کاربر پنل ندارند، پس فیلتر خاموش می‌ماند و + * جستجوی cross-tenant سایت دست‌نخورده کار می‌کند — این رفتار عمدی است. + */ +final class TenantFilterSubscriber implements EventSubscriberInterface +{ + public function __construct( + private readonly Security $security, + private readonly EntityContextResolver $contextResolver, + private readonly EntityManagerInterface $em, + ) {} + + public static function getSubscribedEvents(): array + { + // بعد از فایروال (که روی ۸ اجرا می‌شود) تا توکن در دسترس باشد + return [KernelEvents::REQUEST => ['onRequest', 5]]; + } + + public function onRequest(RequestEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $user = $this->security->getUser(); + if (!$user instanceof \App\Auth\Entity\User) { + return; + } + + $context = $this->contextResolver->tryResolve($user); + if ($context === null || !$context->isResolved()) { + return; + } + + [$type, $id] = $context->toEntityPair(); + + $this->em->getFilters() + ->enable(TenantFilter::NAME) + ->setParameter('entity_type', $type, 'string') + ->setParameter('entity_id', $id, 'integer'); + } +} +``` + +`tryResolve()` (نه `resolve()`) استفاده می‌شود تا محیطِ حل‌نشده استثنا پرتاب نکند و درخواست را در همان `kernel.request` نکشد. + +**⚠️ نکتهٔ ادمین:** کاربر `ROLE_ADMIN` نباید فیلتر بخورد — پنل ادمین ذاتاً cross-tenant است. قبل از resolve بررسی کن: + +```php +if ($user->hasRole('ROLE_ADMIN')) { + return; // ادمین سراسری است +} +``` + +**نحوه تست:** +```bash +ddev exec php bin/phpunit tests/Shared/TenantIsolationMatrixTest.php # از فاز ۳ — باید سبز بماند +``` +و تست جدید نشتی که فیلتر را به‌تنهایی می‌سنجد: + +```php +// tests/Shared/TenantFilterLeakTest.php +public function testFilterAloneBlocksCrossTenantRowsWithoutManualWhere(): void +{ + // دو tenant با داده بساز، فیلتر را با tenant اول روشن کن، + // یک DQL بدون هیچ WHERE tenant بزن → فقط ردیف‌های tenant اول برگردند +} + +public function testPublicRouteWithoutAuthenticatedUserIsNotFiltered(): void +{ + // GET یک endpoint عمومی پزشکان → داده‌های چند tenant برگردد +} + +public function testAdminIsNotFiltered(): void; +``` + +--- + +### ۴. تخصیص خودکار محیط هنگام درج + +فراموش کردن `assignTenant()` هنگام ساخت entity، ردیفی می‌سازد که فیلتر بعداً آن را از دید همه پنهان می‌کند — بدترین نوع باگ چون خطا نمی‌دهد. + +```php +// src/Shared/Tenant/TenantAssignmentListener.php +namespace App\Shared\Tenant; + +use App\Shared\Context\EntityContextResolver; +use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; +use Doctrine\ORM\Event\PrePersistEventArgs; +use Doctrine\ORM\Events; +use Symfony\Bundle\SecurityBundle\Security; + +/** + * اگر entityِ tenant-دار بدون محیط ذخیره شود، محیط جاری را می‌نشاند؛ + * اگر محیطی هم نبود، صریح استثنا می‌دهد. ردیف بی‌tenant هرگز نباید ساخته شود: + * فیلتر فاز ۴ آن را از دید همه پنهان می‌کند بدون اینکه خطایی دیده شود. + */ +#[AsDoctrineListener(event: Events::prePersist)] +final class TenantAssignmentListener +{ + public function __construct( + private readonly Security $security, + private readonly EntityContextResolver $contextResolver, + ) {} + + public function prePersist(PrePersistEventArgs $args): void + { + $entity = $args->getObject(); + if (!in_array(TenantOwnedTrait::class, class_uses($entity) ?: [], true)) { + return; + } + if ($entity->getEntityId() !== 0 && $entity->getEntityType() !== '') { + return; // خودِ سرویس مقدار داده — دست نزن + } + + $user = $this->security->getUser(); + $context = $user instanceof \App\Auth\Entity\User + ? $this->contextResolver->tryResolve($user) + : null; + + if ($context === null || !$context->isResolved()) { + throw new \LogicException(sprintf( + 'Entity %s requires a tenant but none is set and no context could be resolved.', + $entity::class, + )); + } + + $entity->assignTenant($context); + } +} +``` + +`class_uses()` فقط traitهای مستقیم را برمی‌گرداند. اگر جایی ارث‌بری وارد شد، از `class_uses_recursive` معادل خودت استفاده کن — ولی الان هیچ‌کدام از entityهای tenant-دار پدر ندارند، پس سادگی درست است. + +**⚠️ مسیرهای بدون کاربر:** messenger consumerها و کامندهای کنسول کاربر ندارند. هر کدام که entityِ tenant-دار می‌سازند باید **خودشان** `assignTenant()` را صدا بزنند. آن‌ها را پیدا کن: + +```bash +ddev exec grep -rln "TenantOwnedTrait" src --include="*.php" | xargs -I{} basename {} .php +ddev exec grep -rn "new \(Appointment\|DiscountRule\|ServiceSection\|PatientRecord\|InventoryItem\)(" src/*/Command src/*/MessageHandler --include="*.php" +``` + +**نحوه تست:** +```bash +ddev exec php bin/phpunit tests/ +ddev exec php bin/console messenger:consume async --limit=1 -vv # نباید LogicException بدهد +ddev exec php bin/console app:cancel-expired-appointments --dry-run 2>/dev/null || true +``` +به‌علاوه تست واحد: `persist` یک `DiscountRule` بدون tenant و بدون کاربر → `LogicException`. + +--- + +### ۵. تست پوشش ساختاری — مهم‌ترین خروجی این فاز + +بدون این تست، فاز ۴ فقط وضعیت امروز را درست می‌کند؛ با آن، entity جدیدی که فردا کسی بسازد و tenant یادش برود، در CI قرمز می‌شود. + +```php +// tests/Shared/TenantSchemaCoverageTest.php +namespace App\Tests\Shared; + +use App\Shared\Tenant\GlobalTables; +use App\Shared\Tenant\TenantOwnedTrait; + +final class TenantSchemaCoverageTest extends ApiTestCase +{ + /** + * هر entity یا tenant دارد یا با دلیل در GlobalTables ثبت شده. + * شکست این تست یعنی entity جدیدی اضافه شده و طبقه‌بندی نشده — نه اینکه تست خراب است. + */ + public function testEveryEntityIsEitherTenantScopedOrExplicitlyGlobal(): void + { + $em = static::getContainer()->get('doctrine')->getManager(); + $unclassified = []; + + foreach ($em->getMetadataFactory()->getAllMetadata() as $meta) { + $class = $meta->getName(); + + if (isset(GlobalTables::ENTITIES[$class])) { + continue; + } + if (in_array(TenantOwnedTrait::class, class_uses($class) ?: [], true)) { + continue; + } + + $unclassified[] = $class; + } + + self::assertSame([], $unclassified, sprintf( + "این entityها نه tenant دارند نه در GlobalTables ثبت شده‌اند:\n%s", + implode("\n", $unclassified), + )); + } + + /** فهرست سراسری نباید بدون دلیل رشد کند. */ + public function testEveryGlobalEntityHasAReason(): void + { + foreach (GlobalTables::ENTITIES as $class => $reason) { + self::assertNotSame('', trim($reason), "دلیل سراسری بودن {$class} خالی است."); + self::assertTrue(class_exists($class), "کلاس ثبت‌شده وجود ندارد: {$class}"); + } + } +} +``` + +**واقع‌بینی:** اولین اجرای این تست قطعاً قرمز می‌شود، با فهرستی از entityهای مالی و فرزندان aggregate (مثل `patient_attachments`، `session_payments`، `claim_items`). این **خروجی مطلوب** است، نه شکست: همان فهرست، ورودی برنامه‌ریزی فاز بعدی است. آن‌ها را با دلیل `'TODO — طبقه‌بندی نشده'` در `GlobalTables` ثبت کن، فهرست را در گزارش پایانی بیاور، و تست را سبز کن. + +فرزندان aggregate (مثل `patient_attachments` که از طریق `record` به `patient_records` وصل است) گزینهٔ سومی هم دارند: به‌جای ستون tenant، از ریشه به ارث می‌رسند. اگر این مسیر را انتخاب کردی، در `GlobalTables` با دلیل صریح `'فرزند aggregate؛ tenant از PatientRecord به ارث می‌رسد'` ثبتشان کن — نه به‌عنوان TODO. + +**نحوه تست:** `ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php` + +## نکات مهم + +- **الگو: Filter + Event Listener (هر دو مکانیزم خود Doctrine/Symfony).** دلیل انتخاب: هدف «هیچ‌کس نتواند فراموش کند» است و این فقط با قلاب‌زدن به چرخهٔ عمر ORM ممکن است، نه با disciplineِ کدنویسی. جایگزین بررسی‌شده — trait روی repositoryها که خودش `WHERE` را اضافه کند — رد شد چون هر repository جدید باید یادش باشد trait را بگیرد؛ همان مسئله، یک لایه بالاتر. +- **فیلتر جایگزین checkerهای موجود نیست.** `AppointmentAccessChecker`، `ClinicDoctorAccessChecker`، `SecretaryAccessChecker` و `PatientRecordScopeResolver` باقی می‌مانند: آن‌ها «این کاربر با این نقش چه کاری مجاز است» را جواب می‌دهند، فیلتر فقط «کدام ردیف‌ها» را. حذف هیچ‌کدام جزو این فاز نیست. +- **جدول `SQLFilter` limitations را در docblock بنویس.** توسعه‌دهندهٔ بعدی که فرض کند فیلتر همه‌جا کار می‌کند، `find()` را امن می‌پندارد و باگ می‌سازد. +- **ادمین و مسیرهای عمومی از فیلتر مستثنی‌اند.** این را با تست تثبیت کن، نه فقط با کامنت — بدون تست، اولین refactor آینده سایت عمومی را بی‌صدا خالی می‌کند. +- **جدول‌های مالی عمداً خارج از دامنهٔ این سری‌اند.** `payments`، `settlements`، `financial_breakdowns`، `wallet_transactions`، `secretary_earnings` مالکیت دوگانه دارند (پرداخت‌کننده در برابر دریافت‌کننده) و طبقه‌بندی‌شان تحلیل جدا می‌خواهد. آن‌ها را با TODO ثبت کن و در گزارش پایانی به‌عنوان کار باقی‌مانده اعلام کن — بی‌صدا رد نشو. +- **کارایی:** فیلتر به هر کوئری دو شرط اضافه می‌کند. ایندکس‌های tenant-پیشرو فاز ۲ همین را جبران می‌کنند. بعد از فعال‌سازی، `EXPLAIN` سنگین‌ترین لیست پنل (نوبت‌ها و بیماران) را بگیر و مطمئن شو هنوز از ایندکس استفاده می‌شود و به `ALL` نیفتاده. +- **مستندات:** یک بخش کوتاه «جداسازی tenant» به `docs/api/README.md` (یا سند معماری معادل) اضافه کن که بگوید فیلتر چه تضمینی می‌دهد و چه تضمینی نمی‌دهد. این تنها سندی است که توسعه‌دهندهٔ بعدی قبل از نوشتن repository جدید می‌خواند. diff --git a/.claude/prompt/tenant-05-audit-and-docs.md b/.claude/prompt/tenant-05-audit-and-docs.md new file mode 100644 index 00000000..6e1f6909 --- /dev/null +++ b/.claude/prompt/tenant-05-audit-and-docs.md @@ -0,0 +1,248 @@ +# فاز ۵ — آدیت نقاط فرار، تأیید کلاینت‌ها و مستندسازی + +> پرامپت پنجم و آخر سری. **پیش‌نیاز: فازهای ۱ تا ۴ کامل و سبز.** +> ۱. `tenant-01-entity-context-unify.md` → ۲. `tenant-02-mark-booking-tables.md` → ۳. `tenant-03-unify-owner-columns.md` → ۴. `tenant-04-enforce-tenant-filter.md` → **۵. آدیت و مستندسازی (همین فایل)** + +## زمینه + +فاز ۴ فیلتر خودکار را روشن کرد، اما فیلتر Doctrine روی SQL خام DBAL اعمال نمی‌شود. پروژه هشت فایل دارد که مستقیم SQL می‌زنند: + +``` +src/Category/Service/CategoryImporter.php +src/Doctor/Command/PurgeDoctorsCommand.php +src/Doctor/Command/PurgeUnclaimedDoctorsCommand.php +src/Admin/Controller/AdminApiController.php +src/Shared/Controller/HealthController.php +src/Shared/Command/SeedDemoDataCommand.php +src/Representation/Controller/RepresentationActionController.php +src/Billing/Repository/ClaimRepository.php +``` + +هم‌زمان، تغییرات فاز ۲ و ۳ ممکن است شکل پاسخ برخی endpointها را عوض کرده باشند و **build هیچ‌کدام از سه کلاینت خطا نمی‌دهد** — نه پنل ادمین، نه `nobat724_front`، نه `clinic-pro-tauri`. + +## مشکل / هدف + +**مشکل:** سه دستهٔ ریسک باقی‌مانده که هیچ‌کدام خودکار کشف نمی‌شوند: + +1. **نقاط فرار از فیلتر** — SQL خام، `EntityManager::find()`، `getReference()` +2. **رگرسیون خاموش کلاینت‌ها** — تغییر قرارداد API که فقط در رانتایم می‌شکند +3. **دانش از دست رفته** — بدون سند، توسعه‌دهندهٔ بعدی repository جدیدی می‌نویسد که فرض می‌کند فیلتر همه‌جا کار می‌کند + +**هدف:** آدیت کامل، تأیید دستی سه کلاینت با هر پنج نقش، سند معماری، و ابزار بکاپ per-tenant که مزیت عملی جداسازی را در دسترس بگذارد. + +## معیار پذیرش + +- ✅ موفق: هر هشت فایل SQL خام آدیت و طبقه‌بندی شده‌اند (نیاز به `WHERE` دارد / سراسری است / ادمین است)؛ سایت عمومی `nobat724_front` با هر سه صفحهٔ اصلی (لیست پزشکان شهر، صفحهٔ پزشک، صفحهٔ کلینیک) داده برمی‌گرداند؛ پنل ادمین با هر پنج نقش ماتریس بدون خطای کنسول کار می‌کند. +- ❌ خطا: `ddev exec php bin/console app:tenant:dump --tenant=clinic:999` برای tenant ناموجود → پیام خطای واضح و خروج با کد غیرصفر، نه فایل خالی و نه stack trace. +- ⚠️ مرزی: `app:tenant:dump` برای tenantی که هیچ داده‌ای ندارد (کلینیک تازه ثبت‌نام‌شده) → فایل معتبر با ساختار جدول‌ها و صفر ردیف، بدون خطا. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/Category/Service/CategoryImporter.php` | `SET FOREIGN_KEY_CHECKS` + `DELETE FROM` روی جدول‌های مرجع | +| `src/Doctor/Command/PurgeDoctorsCommand.php` | حذف انبوه با SQL خام | +| `src/Doctor/Command/PurgeUnclaimedDoctorsCommand.php` | `DELETE c FROM ... JOIN` | +| `src/Admin/Controller/AdminApiController.php` | کوئری‌های ادمین — عمداً cross-tenant | +| `src/Shared/Controller/HealthController.php` | `SELECT 1` — بی‌خطر | +| `src/Shared/Command/SeedDemoDataCommand.php` | `INSERT` انبوه دادهٔ نمونه | +| `src/Representation/Controller/RepresentationActionController.php` | پنل نماینده | +| `src/Billing/Repository/ClaimRepository.php` | گزارش‌های صورتحساب | +| `src/Shared/Tenant/GlobalTables.php` | فهرست TODOهای فاز ۴ که اینجا تعیین تکلیف می‌شوند | +| `docs/api/` | مستندات endpointهای متأثر | +| `src/Shared/Command/TenantDumpCommand.php` | کامند بکاپ per-tenant — جدید | + +## وضعیت فعلی + +نمونهٔ SQL خامی که فیلتر tenant نمی‌بیند: + +```php +// src/Doctor/Command/PurgeUnclaimedDoctorsCommand.php +$this->conn->executeStatement('SET FOREIGN_KEY_CHECKS=0'); +// ... +$this->conn->executeStatement( + "DELETE c FROM {$t} c JOIN doctors d ON d.id = c.doctor_id WHERE {$where}", + $params, +); +$deleted = $this->conn->executeStatement("DELETE d FROM doctors d WHERE {$where}", $params); +``` + +```php +// src/Category/Service/CategoryImporter.php +$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 0'); +$this->db->executeStatement('DELETE FROM ' . $table); +foreach ($rows as $row) { + $this->db->insert($table, $row); +} +$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 1'); +``` + +## وظایف + +### ۱. آدیت هشت فایل SQL خام + +هر فایل را باز کن و هر دستور SQL را در یکی از این سه دسته بگذار، سپس **در docblock همان متد** دسته و دلیل را بنویس: + +| دسته | معنی | اقدام | +|---|---|---| +| **سراسری** | فقط روی جدول‌های `GlobalTables` کار می‌کند | یادداشت در docblock، بدون تغییر کد | +| **ادمین** | عمداً cross-tenant است و پشت `ROLE_ADMIN` قفل است | تأیید کن `#[IsGranted('ROLE_ADMIN')]` واقعاً هست | +| **نیازمند دامنه** | روی جدول tenant-دار کار می‌کند و کاربر غیرادمین به آن می‌رسد | `WHERE entity_type/entity_id` دستی اضافه کن | + +خروجی این وظیفه یک جدول در گزارش پایانی است، نه فقط تغییر کد. برای هر فایل: مسیر، دسته، دلیل. + +نکات از پیش معلوم: +- `HealthController` فقط `SELECT 1` است → سراسری، بدون اقدام. +- `CategoryImporter` روی `categories` کار می‌کند که در `GlobalTables` است → سراسری. اما تأیید کن که `$table` واقعاً از یک allowlist می‌آید و از ورودی کاربر نمی‌آید — اگر می‌آید، این یک مسئلهٔ امنیتی جدا از tenant است و باید گزارش شود. +- `AdminApiController` و `SeedDemoDataCommand` و دو `Purge*Command` مسیر ادمین/کنسول‌اند → دستهٔ ادمین، فقط تأیید گارد. +- `ClaimRepository` و `RepresentationActionController` را با دقت بخوان — این دو محتمل‌ترین موارد دستهٔ «نیازمند دامنه» هستند. + +**نحوه تست:** برای هر مورد دستهٔ «نیازمند دامنه»، یک تست بنویس که با کاربر tenant A فراخوانی شود و هیچ ردیف tenant B برنگردد. برای بقیه، تست لازم نیست ولی یادداشت docblock اجباری است. + +--- + +### ۲. آدیت `find()` و `getReference()` روی entityهای tenant-دار + +فیلتر روی این دو اعمال نمی‌شود. نقاط را پیدا کن: + +```bash +ddev exec grep -rn "->find(\|getReference(" src --include="*.php" | grep -v "findBy\|findOneBy\|findAll" +``` + +برای هر مورد، تعیین کن روی entityِ tenant-دار است یا نه. اگر بله، یکی از دو کار: + +- **جایگزینی با repository + DQL** (ترجیح) — فیلتر خودکار اعمال می‌شود +- **چک صریح بعد از `find()`** اگر جایگزینی ممکن نبود: + +```php +$item = $this->repo->find($id); +if ($item === null || $item->getEntityId() !== $context->id || $item->getEntityType() !== $context->type) { + throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, null, 403); +} +``` + +**نحوه تست:** برای هر نقطهٔ اصلاح‌شده، یک تست: کاربر tenant A با شناسهٔ رکورد tenant B → `403`، نه `200` و نه `404` مبهم. + +--- + +### ۳. تعیین تکلیف TODOهای `GlobalTables` + +فاز ۴ فهرستی از entityهای طبقه‌بندی‌نشده تولید کرد (احتمالاً جدول‌های مالی و فرزندان aggregate). هر کدام را در یکی از سه دسته قطعی کن: + +| دسته | مثال | اقدام | +|---|---|---| +| فرزند aggregate | `patient_attachments`, `session_payments`, `claim_items`, `invoice_items` | در `GlobalTables` با دلیل «tenant از ریشه به ارث می‌رسد»؛ تأیید کن کوئری‌هایشان همیشه از ریشه JOIN می‌خورند | +| نیازمند tenant | آنچه مستقیم کوئری می‌شود و ریشهٔ tenant-دار ندارد | ستون اضافه شود — **پرامپت فاز ۶ برایش نوشته شود، در این فاز پیاده نشود** | +| سراسری | دادهٔ مرجع | ثبت با دلیل | + +**دستهٔ «نیازمند tenant» را در این فاز پیاده نکن.** جدول‌های مالی مالکیت دوگانه دارند (پرداخت‌کننده در برابر دریافت‌کننده) و migration اشتباه روی داده‌های مالی قابل برگشت نیست. خروجی این وظیفه برای آن دسته فقط یک فهرست مستند در گزارش پایانی است، به‌علاوهٔ پیشنهاد اینکه فاز ۶ لازم است یا نه. + +**نحوه تست:** `ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php` سبز، و هیچ دلیلی در `GlobalTables` با متن `TODO` باقی نمانده باشد. + +--- + +### ۴. کامند بکاپ per-tenant + +مزیت عملی‌ای که کاربر از «دیتابیس مجزا» می‌خواست، بدون هزینهٔ آن: + +```php +// src/Shared/Command/TenantDumpCommand.php +#[AsCommand(name: 'app:tenant:dump', description: 'خروجی SQL از دادهٔ یک محیط (پزشک یا کلینیک)')] +``` + +ورودی: `--tenant=clinic:12` یا `--tenant=doctor:5`، خروجی: `--output=/path/file.sql`. + +پیاده‌سازی: فهرست جدول‌های tenant-دار را از metadata بگیر (همان منطق `TenantSchemaCoverageTest`)، و برای هر کدام `mysqldump --where` بزن: + +```bash +mysqldump db --where="entity_type='clinic' AND entity_id=12" +``` + +**اعتبارسنجی ورودی اجباری است:** `entity_type` باید یکی از `doctor`/`clinic` باشد و `entity_id` عدد صحیح — این مقادیر مستقیم داخل رشتهٔ `--where` می‌روند و بدون اعتبارسنجی، تزریق شل/SQL می‌شود. مقدار را با `in_array()` و `(int)` قفل کن، نه با escape. + +اگر tenant وجود نداشت (نه کلینیک نه پزشک با آن شناسه)، پیام خطای فارسی و `Command::FAILURE`. + +**نحوه تست:** +```bash +ddev exec php bin/console app:tenant:dump --tenant=clinic:1 --output=/tmp/c1.sql +ddev exec php bin/console app:tenant:dump --tenant=clinic:999999 --output=/tmp/x.sql # باید FAILURE بدهد +ddev exec php bin/console app:tenant:dump --tenant="clinic:1 OR 1=1" --output=/tmp/y.sql # باید رد شود +``` +و بررسی محتوای `/tmp/c1.sql`: هیچ ردیفی با `entity_id` غیر از ۱ نباشد. + +--- + +### ۵. تأیید دستی سه کلاینت + +هیچ تست خودکاری این را پوشش نمی‌دهد (guidelines §۳). هر سه را دستی بررسی کن و نتیجه را گزارش بده. + +**الف) پنل ادمین ClinicPro** — با هر پنج نقش ماتریس وارد شو (اعتبارنامه‌ها از `TEST_USERS.md` / حساب‌های QA محلی): + +| نقش | چه باید ببیند | +|---|---| +| پزشک مستقل | فقط دادهٔ مطب شخصی | +| پزشک عضو کلینیک، محیط = کلینیک | دادهٔ کلینیک، محدود به بیماران خودش | +| پزشک عضو کلینیک، محیط = شخصی | فقط دادهٔ مطب شخصی | +| پزشک مالک کلینیک | همهٔ دادهٔ کلینیک، بدون محدودیت permission | +| مدیر کلینیک | همهٔ دادهٔ کلینیک | + +برای هر نقش، صفحات نوبت‌ها، بیماران، خدمات، انبار و تخفیف‌ها را باز کن و کنسول مرورگر را برای خطا چک کن. برای این کار می‌توانی از skill `qa-clinicpro` استفاده کنی. + +**ب) `nobat724_front`** — مسیرهای عمومی نباید فیلتر بخورند: +- لیست پزشکان یک شهر +- صفحهٔ یک پزشک (`/doctor/{uuid}`) +- صفحهٔ یک کلینیک (`/clinic/{uuid}`) +- گرفتن نوبت به‌عنوان بیمار + +هر کدام که خالی برگشت، یعنی فیلتر روی مسیر عمومی روشن شده — باگ فاز ۴. + +**ج) `clinic-pro-tauri`** — `src/service/response.js` همان `/api/v1/...` را صدا می‌زند. اگر شکل پاسخ نوبت یا خدمات عوض شده، اینجا در رانتایم می‌شکند. حداقل مسیر ورود و لیست نوبت‌ها را بررسی کن. + +**نحوه تست:** همان بالا — گزارش با ذکر اینکه هر مورد تأیید شد یا نه. اگر موردی بررسی نشد، صریح بنویس بررسی نشد؛ ننویس «احتمالاً سالم است». + +--- + +### ۶. مستندسازی + +**الف) سند معماری** — فایل `docs/architecture/tenancy.md` (جدید): + +- تعریف tenant: جفت `(entity_type, entity_id)` با مقادیر `doctor`/`clinic` +- ماتریس پنج نقش با نحوهٔ حل محیط هر کدام +- `EntityContextResolver` تنها نقطهٔ تصمیم +- `TenantFilter` چه تضمین می‌کند و **چه تضمین نمی‌کند** (جدول محدودیت‌ها از فاز ۴) +- `GlobalTables` و قاعدهٔ افزودن به آن +- «entity جدید می‌سازی؟ این چک‌لیست» — سه خط + +**ب) `docs/api/`** — هر فایلی که endpointش شکل پاسخ عوض کرده، با **JSON واقعیِ خروجی اجرا** به‌روز شود، نه دست‌ساز. حداقل: `appointment.md`، `patient.md`، `discount.md`، `secretary.md`. + +**ج) `CLAUDE.md`** — یک بند کوتاه زیر «قواعد غیرقابل‌مذاکره» یا بخش الگوها: + +```markdown +## جداسازی محیط (tenant) +هر entity جدید یا `TenantOwnedTrait` می‌گیرد، یا با دلیل در `App\Shared\Tenant\GlobalTables` +ثبت می‌شود. تست `TenantSchemaCoverageTest` هر دو حالت را اجبار می‌کند. +محیط جاری همیشه از `EntityContextResolver` گرفته می‌شود، نه از نقش کاربر. +جزئیات: `docs/architecture/tenancy.md` +``` + +**نحوه تست:** سند را بخوان و مطمئن شو با کد واقعی می‌خواند — به‌ویژه جدول محدودیت‌های فیلتر. سند ناهم‌خوان بدتر از نبود سند است (guidelines §۴). + +--- + +### ۷. گزارش پایانی + +یک خلاصه بنویس شامل: + +1. جدول آدیت هشت فایل SQL خام (مسیر، دسته، اقدام) +2. فهرست نقاط `find()` اصلاح‌شده +3. entityهای دستهٔ «نیازمند tenant» که به فاز بعدی موکول شدند + پیشنهاد اینکه فاز ۶ لازم است یا نه +4. نتیجهٔ تأیید دستی سه کلاینت — با ذکر صریح هر موردی که بررسی نشد +5. هر رفتاری که عمداً عوض شد (مثل رفع باگ یکتایی `doctor_secretaries` در فاز ۳) + +## نکات مهم + +- **این فاز کد کمی دارد و بررسی زیاد.** وسوسهٔ رد شدن سریع از وظیفه‌های ۱ و ۵ همان چیزی است که نشتی را زنده نگه می‌دارد. اگر وقت کم آمد، وظیفهٔ ۴ (کامند بکاپ) را به تعویق بینداز، نه آدیت را. +- **هیچ migration جدیدی در این فاز نیست.** اگر لازم شد، یعنی فازهای قبلی ناقص مانده‌اند — برگرد و همان‌جا درست کن، اینجا وصله نزن. +- **اعتبارسنجی ورودی `app:tenant:dump` امنیتی است، نه سلیقه‌ای.** مقدار مستقیم داخل رشتهٔ shell و SQL می‌رود. +- **`SET FOREIGN_KEY_CHECKS=0` در سه فایل**: این الگو در MariaDB سراسری است و در طول اجرا تمام محافظت ارجاعی را برای همهٔ کانکشن‌ها خاموش می‌کند. جزو دامنهٔ این فاز نیست، ولی اگر روی جدول tenant-دار اجرا می‌شود، در گزارش به‌عنوان ریسک ذکرش کن. +- **کلاینت‌های cross-repo تست خودکار ندارند** (guidelines §۳) — تأیید دستی وظیفهٔ ۵ تنها پوشش موجود است. diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index d5e11319..8c302e4a 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -8,7 +8,6 @@ use App\Appointment\Repository\AppointmentRepository; use App\Appointment\Repository\SlotTakenException; use App\Appointment\Service\SlotCalculatorService; use App\Auth\Entity\User; -use App\Auth\Repository\UserActiveContextRepository; use App\Clinic\Repository\ClinicRepository; use App\Doctor\Entity\Doctor; use App\Doctor\Repository\DoctorRepository; @@ -35,7 +34,7 @@ class MyAppointmentsController extends BaseController private readonly DoctorRepository $doctorRepo, private readonly ClinicRepository $clinicRepo, private readonly DoctorSecretaryRepository $secretaryRepo, - private readonly UserActiveContextRepository $contextRepo, + private readonly \App\Shared\Context\EntityContextResolver $contextResolver, private readonly SlotCalculatorService $slotCalculator, private readonly \App\Appointment\Service\BookingContextResolver $bookingContext, private readonly \App\ClinicService\Repository\ServiceSectionRepository $sectionRepo, @@ -552,21 +551,19 @@ class MyAppointmentsController extends BaseController private function secretaryCanBookForDoctor(User $user, Doctor $doctor): bool { - $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); - if ($dbUuid === null) { + $context = $this->contextResolver->resolve($user); + if (!$context->isResolved()) { return false; } - $clinic = $this->clinicRepo->findByUuid($dbUuid); - if ($clinic !== null) { + if ($context->isClinic()) { // منشی فقط برای پزشکانِ تخصیص‌یافته‌ی خودش می‌تواند رزرو کند، نه کل کلینیک - $rel = $this->secretaryRepo->findActiveClinicRow($user, $clinic, $doctor); + $rel = $this->secretaryRepo->findActiveClinicRow($user, $context->clinic, $doctor); return $rel !== null && (bool) ($rel->getPermissions()['resources']['appointments']['create'] ?? false); } - $scopeDoctor = $this->doctorRepo->findByUuid($dbUuid); - if ($scopeDoctor !== null && $scopeDoctor->getId() === $doctor->getId()) { - $rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $scopeDoctor); + if ($context->id === $doctor->getId()) { + $rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor); return $rel !== null && (bool) ($rel->getPermissions()['resources']['appointments']['create'] ?? false); } @@ -581,35 +578,28 @@ class MyAppointmentsController extends BaseController */ private function resolveSecretaryFilter(User $user): ?array { - $activeCtx = $this->contextRepo->findByUser($user); - $dbUuid = $activeCtx?->getDbUuid(); - - if ($dbUuid === null) { + $context = $this->contextResolver->resolve($user); + if (!$context->isResolved()) { return null; } // بررسی scope کلینیک — فقط پزشکانِ تخصیص‌یافته به این منشی، نه کل کلینیک - $clinic = $this->clinicRepo->findByUuid($dbUuid); - if ($clinic !== null) { - $rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic); + if ($context->isClinic()) { + $rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $context->clinic); if ($rel === null) return null; $canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false); $doctorIds = array_map( fn(Doctor $d) => $d->getId(), - $this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic) + $this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $context->clinic) ); return ['clinic', $doctorIds, $canView]; } // بررسی scope مطب شخصی - $doctor = $this->doctorRepo->findByUuid($dbUuid); - if ($doctor !== null) { - $rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor); - if ($rel === null) return null; - $canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false); - return ['doctor', $doctor, $canView]; - } + $rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $context->doctor); + if ($rel === null) return null; + $canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false); - return null; + return ['doctor', $context->doctor, $canView]; } } diff --git a/src/Appointment/Security/AppointmentAccessChecker.php b/src/Appointment/Security/AppointmentAccessChecker.php index 355304d7..0737f074 100644 --- a/src/Appointment/Security/AppointmentAccessChecker.php +++ b/src/Appointment/Security/AppointmentAccessChecker.php @@ -4,12 +4,11 @@ namespace App\Appointment\Security; use App\Appointment\Entity\Appointment; use App\Auth\Entity\User; -use App\Auth\Repository\UserActiveContextRepository; use App\Clinic\Repository\ClinicRepository; use App\Clinic\Security\ClinicDoctorPermissionChecker; -use App\Doctor\Repository\DoctorRepository; use App\Secretary\Repository\DoctorSecretaryRepository; use App\Secretary\Security\SecretaryPermissionChecker; +use App\Shared\Context\EntityContextResolver; /** * تنها تصمیم‌گیرندهٔ دسترسی روی «یک نوبت مشخص». @@ -34,9 +33,8 @@ class AppointmentAccessChecker private readonly ClinicDoctorPermissionChecker $clinicPermissions, private readonly SecretaryPermissionChecker $secretaryPermissions, private readonly DoctorSecretaryRepository $secretaryRepo, - private readonly UserActiveContextRepository $contextRepo, + private readonly EntityContextResolver $contextResolver, private readonly ClinicRepository $clinicRepo, - private readonly DoctorRepository $doctorRepo, ) {} public function canView(Appointment $appointment, User $user): bool @@ -108,29 +106,23 @@ class AppointmentAccessChecker */ private function secretaryCanContext(User $user, \App\Doctor\Entity\Doctor $doctor, ?\App\Clinic\Entity\Clinic $clinic): bool { - $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); - if ($dbUuid === null) { + $context = $this->contextResolver->resolve($user); + if (!$context->isResolved()) { return false; } - $ctxClinic = $this->clinicRepo->findByUuid($dbUuid); - if ($ctxClinic !== null) { - if ($clinic === null || $ctxClinic->getId() !== $clinic->getId()) { + if ($context->isClinic()) { + if ($clinic === null || $context->id !== $clinic->getId()) { return false; } - $relation = $this->secretaryRepo->findActiveClinicRow($user, $ctxClinic, $doctor); + $relation = $this->secretaryRepo->findActiveClinicRow($user, $context->clinic, $doctor); return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, self::ACTION_UPDATE_STATUS); } // محیطِ مطب شخصی: نوبت هم باید در همان مطب شخصی باشد (clinic == null). - if ($clinic !== null) { - return false; - } - - $ctxDoctor = $this->doctorRepo->findByUuid($dbUuid); - if ($ctxDoctor === null || $ctxDoctor->getId() !== $doctor->getId()) { + if ($clinic !== null || $context->id !== $doctor->getId()) { return false; } @@ -145,12 +137,12 @@ class AppointmentAccessChecker */ public function viewableClinicFor(User $user, \App\Doctor\Entity\Doctor $doctor): ?\App\Clinic\Entity\Clinic { - $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); - $clinic = $dbUuid !== null ? $this->clinicRepo->findByUuid($dbUuid) : null; + $context = $this->contextResolver->resolve($user); - if ($clinic === null) { - $clinic = $this->clinicRepo->findByUser($user); - } + // بیرون از محیط کلینیک، کلینیکِ تحتِ مالکیت بازمی‌گردد: کاربری که هم پزشک + // است و هم مالک کلینیک، بدون محیط فعال به‌عنوان پزشک حل می‌شود ولی هنوز + // باید لیست نوبت‌های کلینیک خودش را ببیند. + $clinic = $context->isClinic() ? $context->clinic : $this->clinicRepo->findByUser($user); if ($clinic === null || !$clinic->hasDoctor($doctor)) { return null; @@ -168,24 +160,23 @@ class AppointmentAccessChecker */ private function secretaryCan(Appointment $appointment, User $user, string $action): bool { - $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); - if ($dbUuid === null) { + $context = $this->contextResolver->resolve($user); + if (!$context->isResolved()) { return false; } - $clinic = $this->clinicRepo->findByUuid($dbUuid); - if ($clinic !== null) { - if ($appointment->getClinic()?->getId() !== $clinic->getId()) { + if ($context->isClinic()) { + if ($appointment->getClinic()?->getId() !== $context->id) { return false; } - $relation = $this->secretaryRepo->findActiveClinicRow($user, $clinic, $appointment->getDoctor()); + $relation = $this->secretaryRepo->findActiveClinicRow($user, $context->clinic, $appointment->getDoctor()); return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, $action); } - $doctor = $this->doctorRepo->findByUuid($dbUuid); - if ($doctor === null || $doctor->getId() !== $appointment->getDoctor()->getId()) { + $doctor = $appointment->getDoctor(); + if ($context->id !== $doctor->getId()) { return false; } diff --git a/src/Appointment/Service/BookingContextResolver.php b/src/Appointment/Service/BookingContextResolver.php index 6b03f523..3035c041 100644 --- a/src/Appointment/Service/BookingContextResolver.php +++ b/src/Appointment/Service/BookingContextResolver.php @@ -13,6 +13,11 @@ use App\Shared\Exception\AppException; * * نبودِ clinic_uuid هرگز به معنی «هر محلی که پیدا شد» نیست. با چند برنامهٔ هم‌زمان، * حدس‌زدن محل یعنی ثبت خاموشِ نوبت در جای اشتباه — پس یا محل صریح است، یا شخصی. + * + * چرا جدا از {@see \App\Shared\Context\EntityContextResolver}: ورودی آن User است و + * محیطِ کاربرِ پنل را حل می‌کند؛ اینجا ورودی Doctor است چون رزرو عمومی را بیمار + * انجام می‌دهد و کاربرِ درخواست هیچ محیط کاری ندارد. به همین دلیل هم null اینجا + * معنای صریحِ «مطب شخصی» دارد، نه «نامشخص». */ class BookingContextResolver { diff --git a/src/Clinic/Security/ClinicDoctorAccessChecker.php b/src/Clinic/Security/ClinicDoctorAccessChecker.php index def89cf1..497f1764 100644 --- a/src/Clinic/Security/ClinicDoctorAccessChecker.php +++ b/src/Clinic/Security/ClinicDoctorAccessChecker.php @@ -3,10 +3,9 @@ namespace App\Clinic\Security; use App\Auth\Entity\User; -use App\Auth\Repository\UserActiveContextRepository; -use App\Clinic\Repository\ClinicRepository; use App\Doctor\Repository\DoctorRepository; use App\Shared\Constant\ErrorCodes; +use App\Shared\Context\EntityContextResolver; use App\Shared\Exception\AppException; /** @@ -20,8 +19,7 @@ use App\Shared\Exception\AppException; class ClinicDoctorAccessChecker { public function __construct( - private readonly UserActiveContextRepository $contextRepo, - private readonly ClinicRepository $clinicRepo, + private readonly EntityContextResolver $contextResolver, private readonly DoctorRepository $doctorRepo, private readonly ClinicDoctorPermissionChecker $permissions, ) {} @@ -33,22 +31,14 @@ class ClinicDoctorAccessChecker */ public function memberClinicId(User $user): ?int { - $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); - if ($dbUuid === null) { - return null; - } - - $clinic = $this->clinicRepo->findByUuid($dbUuid); - if ($clinic === null) { + $context = $this->contextResolver->resolve($user); + if (!$context->isClinic()) { return null; } $doctor = $this->doctorRepo->findByUser($user); - if ($doctor === null || !$clinic->hasDoctor($doctor)) { - return null; - } - return $clinic->getId(); + return $doctor !== null && $context->clinic->hasDoctor($doctor) ? $context->id : null; } /** @@ -56,16 +46,13 @@ class ClinicDoctorAccessChecker */ public function canOrNonMember(User $user, string $resource, string $action): bool { - $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); - if ($dbUuid === null) { + $context = $this->contextResolver->resolve($user); + if (!$context->isClinic()) { + // محیطِ مطب شخصی یا نامشخص → این checker مالِ او نیست. return true; } - $clinic = $this->clinicRepo->findByUuid($dbUuid); - if ($clinic === null) { - // محیطِ مطب شخصی (db_uuid پزشک است، نه کلینیک) → محدود نمی‌کنیم. - return true; - } + $clinic = $context->clinic; // مالکِ کلینیک هرگز با مجوزهای عضویت قفل نمی‌شود. if ($clinic->getUser()->getId() === $user->getId()) { diff --git a/src/Dashboard/Controller/DashboardController.php b/src/Dashboard/Controller/DashboardController.php index 52186770..01c88772 100644 --- a/src/Dashboard/Controller/DashboardController.php +++ b/src/Dashboard/Controller/DashboardController.php @@ -3,7 +3,6 @@ namespace App\Dashboard\Controller; use App\Auth\Entity\User; -use App\Auth\Repository\UserActiveContextRepository; use App\Clinic\Repository\ClinicRepository; use App\Doctor\Repository\DoctorRepository; use App\Patient\Repository\PatientRecordRepository; @@ -33,7 +32,6 @@ class DashboardController extends BaseController private readonly SmsWalletService $smsWalletService, private readonly PatientRecordRepository $patientRecordRepo, private readonly PatientSessionRepository $patientSessionRepo, - private readonly UserActiveContextRepository $contextRepo, private readonly EntityContextResolver $contextResolver, private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker, private readonly \App\Doctor\Repository\DoctorAddressRepository $addressRepo, @@ -532,29 +530,22 @@ class DashboardController extends BaseController #[IsGranted('ROLE_SECRETARY')] public function secretary(#[CurrentUser] User $user): JsonResponse { - $activeCtx = $this->contextRepo->findByUser($user); - $dbUuid = $activeCtx?->getDbUuid(); + $context = $this->contextResolver->resolve($user); - if ($dbUuid === null) { + if (!$context->isResolved()) { return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403); } - // تعیین scope بر اساس db_uuid فعال - $clinic = $this->clinicRepo->findByUuid($dbUuid); - if ($clinic !== null) { - return $this->secretaryClinicDashboard($user, $clinic); + if ($context->isClinic()) { + return $this->secretaryClinicDashboard($user, $context->clinic); } - $doctor = $this->doctorRepo->findByUuid($dbUuid); - if ($doctor !== null) { - $rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor); - if ($rel === null) { - return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403); - } - return $this->secretaryDoctorDashboard($user, $rel); + $rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $context->doctor); + if ($rel === null) { + return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403); } - return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'context نامعتبر است', 403); + return $this->secretaryDoctorDashboard($user, $rel); } private function secretaryDoctorDashboard(User $user, DoctorSecretary $rel): JsonResponse diff --git a/src/Inventory/Controller/InventoryController.php b/src/Inventory/Controller/InventoryController.php index 1795b522..b41d99f8 100644 --- a/src/Inventory/Controller/InventoryController.php +++ b/src/Inventory/Controller/InventoryController.php @@ -286,7 +286,14 @@ class InventoryController extends BaseController return $package; } - /** @return array{0: string, 1: int|null} [entityType, entityId] */ + /** + * عمداً از EntityContextResolver استفاده نمی‌کند: اینجا نقشِ پزشک بی‌قید اول + * بررسی می‌شود، پس پزشکِ عضو حتی در محیط کلینیک هم انبارِ شخصی خودش را می‌بیند. + * رزولور مشترک محیط فعال را مقدم می‌داند و انتقال به آن، دادهٔ نمایش‌داده‌شده به + * پزشکِ عضو را عوض می‌کند — تصمیمی محصولی که به فاز نشانه‌گذاری tenant موکول شد. + * + * @return array{0: string, 1: int|null} [entityType, entityId] + */ private function resolveEntity(User $user): array { if ($user->hasRole('ROLE_DOCTOR')) { diff --git a/src/Patient/Security/PatientRecordScopeResolver.php b/src/Patient/Security/PatientRecordScopeResolver.php index 1e220d47..fac30116 100644 --- a/src/Patient/Security/PatientRecordScopeResolver.php +++ b/src/Patient/Security/PatientRecordScopeResolver.php @@ -3,18 +3,20 @@ namespace App\Patient\Security; use App\Auth\Entity\User; -use App\Auth\Repository\UserActiveContextRepository; -use App\Clinic\Repository\ClinicRepository; +use App\Clinic\Entity\Clinic; use App\Clinic\Security\ClinicDoctorPermissionChecker; use App\Doctor\Repository\DoctorRepository; use App\Secretary\Repository\DoctorSecretaryRepository; +use App\Shared\Context\EntityContext; +use App\Shared\Context\EntityContextResolver; /** - * «پرونده‌های کدام محیط را این کاربر می‌بیند؟» + * «پرونده‌های کدام محیط را این کاربر می‌بیند، و درون آن محیط محدود به بیمارانِ + * کدام پزشکان؟» * - * پیش از این، نگاشت تک‌مقصدی بود: نقش پزشک همیشه به پروندهٔ مطب شخصی می‌رسید، پس - * پزشکِ دعوت‌شده به کلینیک پرونده‌های بیمارانش در آن کلینیک را اصلاً نمی‌دید. محیط - * فعال (UserActiveContext) تعیین‌کننده است، دقیقاً مثل EntityContextResolver. + * پرسش اول را EntityContextResolver جواب می‌دهد؛ مسئولیت منحصربه‌فرد این کلاس + * فقط پرسش دوم است. پروندهٔ کلینیکی per-بیمار است نه per-پزشک، پس محدودسازیِ + * پزشکِ عضو و منشی نمی‌تواند روی خودِ محیط باشد. * * «پایان همکاری» منبع حقیقتِ جدا ندارد: ClinicDoctorPermission/DoctorSecretary با * active=false خودشان رد می‌کنند. @@ -24,79 +26,52 @@ class PatientRecordScopeResolver private const RESOURCE = 'patients'; public function __construct( + private readonly EntityContextResolver $contextResolver, private readonly DoctorRepository $doctorRepo, - private readonly ClinicRepository $clinicRepo, - private readonly UserActiveContextRepository $contextRepo, private readonly DoctorSecretaryRepository $secretaryRepo, private readonly ClinicDoctorPermissionChecker $clinicPermissions, ) {} public function resolve(User $user): PatientRecordScope { - if ($user->hasRole('ROLE_DOCTOR')) { - return $this->forDoctorUser($user); + $context = $this->contextResolver->resolve($user); + + if ($context->isClinic()) { + return $this->restrictionInClinic($user, $context->clinic); } - if ($user->hasRole('ROLE_CLINIC')) { - $clinic = $this->clinicRepo->findByUser($user); - - return PatientRecordScope::forClinic($clinic?->getId()); - } - - if ($user->hasRole('ROLE_SECRETARY')) { - return $this->forSecretary($user); - } - - return PatientRecordScope::unknown(); + // پزشکی که هنوز رکورد Doctor ندارد، محیطش «doctor» با شناسهٔ تهی است — + // assertPatientGate همان را ۴۰۳ می‌کند. + return $context->type === EntityContext::TYPE_DOCTOR + ? PatientRecordScope::forDoctor($context->id) + : PatientRecordScope::unknown(); } /** - * پزشک در محیط کلینیکِ فعالش پرونده‌های همان کلینیک را می‌بیند — محدود به - * بیمارانِ خودش. بیرون از آن محیط، فقط پروندهٔ مطب شخصی. + * مالک کلینیک همهٔ پرونده‌ها را می‌بیند؛ پزشکِ عضو فقط بیمارانِ خودش؛ منشی + * فقط بیمارانِ پزشکانِ تخصیص‌یافته به او — عضویت در کلینیک به‌تنهایی یعنی + * منشیِ یک پزشک پروندهٔ بیماران پزشک دیگر را ببیند. */ - private function forDoctorUser(User $user): PatientRecordScope + private function restrictionInClinic(User $user, Clinic $clinic): PatientRecordScope { - $doctor = $this->doctorRepo->findByUser($user); - if ($doctor === null) { - return PatientRecordScope::forDoctor(null); - } - - $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); - $clinic = $dbUuid !== null ? $this->clinicRepo->findByUuid($dbUuid) : null; - - if ($clinic === null || !$clinic->hasDoctor($doctor)) { - return PatientRecordScope::forDoctor($doctor->getId()); - } - - // مالک کلینیکی که خودش پزشک هم هست، محدود نمی‌شود. if ($clinic->getUser()->getId() === $user->getId()) { return PatientRecordScope::forClinic($clinic->getId()); } - if (!$this->clinicPermissions->can($user, $clinic, self::RESOURCE, 'view')) { - return PatientRecordScope::forDoctor($doctor->getId()); - } - - return PatientRecordScope::forClinicRestrictedToDoctors($clinic->getId(), [$doctor->getId()]); - } - - /** - * منشی در محیط فعالش. در کلینیک، فقط بیمارانِ پزشکانِ تخصیص‌یافته به او — - * عضویت در کلینیک به‌تنهایی یعنی منشیِ یک پزشک پروندهٔ بیماران پزشک دیگر را ببیند. - */ - private function forSecretary(User $user): PatientRecordScope - { - $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); - if ($dbUuid === null) { - return PatientRecordScope::unknown(); - } - - $clinic = $this->clinicRepo->findByUuid($dbUuid); - if ($clinic !== null) { - if ($this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic) === null) { + if ($user->hasRole('ROLE_DOCTOR')) { + $doctor = $this->doctorRepo->findByUser($user); + if ($doctor === null) { return PatientRecordScope::unknown(); } + if (!$this->clinicPermissions->can($user, $clinic, self::RESOURCE, 'view')) { + return PatientRecordScope::forDoctor($doctor->getId()); + } + + return PatientRecordScope::forClinicRestrictedToDoctors($clinic->getId(), [$doctor->getId()]); + } + + if ($user->hasRole('ROLE_SECRETARY')) { $doctorIds = array_map( fn($d) => $d->getId(), $this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic), @@ -105,11 +80,6 @@ class PatientRecordScopeResolver return PatientRecordScope::forClinicRestrictedToDoctors($clinic->getId(), $doctorIds); } - $doctor = $this->doctorRepo->findByUuid($dbUuid); - if ($doctor !== null && $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor) !== null) { - return PatientRecordScope::forDoctor($doctor->getId()); - } - - return PatientRecordScope::unknown(); + return PatientRecordScope::forClinic($clinic->getId()); } } diff --git a/src/Secretary/Security/SecretaryAccessChecker.php b/src/Secretary/Security/SecretaryAccessChecker.php index 3d46bfd0..ffe1f53f 100644 --- a/src/Secretary/Security/SecretaryAccessChecker.php +++ b/src/Secretary/Security/SecretaryAccessChecker.php @@ -3,28 +3,23 @@ namespace App\Secretary\Security; use App\Auth\Entity\User; -use App\Auth\Repository\UserActiveContextRepository; -use App\Clinic\Repository\ClinicRepository; -use App\Doctor\Repository\DoctorRepository; use App\Secretary\Entity\DoctorSecretary; use App\Secretary\Repository\DoctorSecretaryRepository; use App\Shared\Constant\ErrorCodes; +use App\Shared\Context\EntityContextResolver; use App\Shared\Exception\AppException; /** * Single entry point that answers «آیا این منشی مجاز به resource/action هست؟». * * منبع حقیقت، ستون JSON `permission` روی ردیف فعالِ DoctorSecretary در محیطِ - * فعال کاربر (UserActiveContext.db_uuid) است — دقیقاً مثل PatientRecordScopeResolver - * و DashboardController::secretary. کنترلرهایی که چند نقش می‌گیرند فقط وقتی کاربر - * ROLE_SECRETARY دارد این checker را صدا می‌زنند؛ نقش‌های دیگر دست‌نخورده می‌مانند. + * فعالِ کاربر است. محیط از EntityContextResolver می‌آید — همان نقطه‌ای که همهٔ + * نقش‌ها از آن می‌گذرند؛ اینجا فقط مجوزها بررسی می‌شوند، نه محیط. */ class SecretaryAccessChecker { public function __construct( - private readonly UserActiveContextRepository $contextRepo, - private readonly ClinicRepository $clinicRepo, - private readonly DoctorRepository $doctorRepo, + private readonly EntityContextResolver $contextResolver, private readonly DoctorSecretaryRepository $secretaryRepo, private readonly SecretaryPermissionChecker $permissions, ) {} @@ -32,22 +27,14 @@ class SecretaryAccessChecker /** ردیف فعالِ منشی در محیط فعال؛ null اگر محیط تنظیم نشده یا رابطه‌ای نیست. */ public function activeRelation(User $user): ?DoctorSecretary { - $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); - if ($dbUuid === null) { + $context = $this->contextResolver->resolve($user); + if (!$context->isResolved()) { return null; } - $clinic = $this->clinicRepo->findByUuid($dbUuid); - if ($clinic !== null) { - return $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic); - } - - $doctor = $this->doctorRepo->findByUuid($dbUuid); - if ($doctor !== null) { - return $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor); - } - - return null; + return $context->isClinic() + ? $this->secretaryRepo->findActiveBySecretaryForClinic($user, $context->clinic) + : $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $context->doctor); } public function can(User $user, string $resource, string $action): bool @@ -59,30 +46,14 @@ class SecretaryAccessChecker /** * جفتِ [entityType, entityId] مالکِ محیطِ فعالِ منشی — کلینیک یا پزشک. - * برای کنترلرهایی که دادهٔ tenant را با این جفت واکشی می‌کنند و resolverِ - * عمومی (EntityContextResolver) منشی را نمی‌شناسد. مجوز جدا با + * برای کنترلرهایی که دادهٔ tenant را با این جفت واکشی می‌کنند. مجوز جدا با * denyUnlessGranted بررسی می‌شود؛ این متد فقط owner را حل می‌کند. * * @return array{0: string, 1: int|null} ['clinic'|'doctor'|'unknown', id|null] */ public function resolveOwnerEntity(User $user): array { - $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); - if ($dbUuid === null) { - return ['unknown', null]; - } - - $clinic = $this->clinicRepo->findByUuid($dbUuid); - if ($clinic !== null) { - return ['clinic', $clinic->getId()]; - } - - $doctor = $this->doctorRepo->findByUuid($dbUuid); - if ($doctor !== null) { - return ['doctor', $doctor->getId()]; - } - - return ['unknown', null]; + return $this->contextResolver->resolve($user)->toEntityPair(); } /** @@ -97,28 +68,23 @@ class SecretaryAccessChecker string $resource, string $action ): bool { - $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); - if ($dbUuid === null) { + $context = $this->contextResolver->resolve($user); + if (!$context->isResolved()) { return false; } - $ctxClinic = $this->clinicRepo->findByUuid($dbUuid); - if ($ctxClinic !== null) { + if ($context->isClinic()) { // محیطِ کلینیک: تنظیم باید در همان کلینیک باشد و پزشکش جزو پزشکانِ منشی. - if ($clinic === null || $ctxClinic->getId() !== $clinic->getId()) { + if ($clinic === null || $context->id !== $clinic->getId()) { return false; } - $relation = $this->secretaryRepo->findActiveClinicRow($user, $ctxClinic, $doctor); + $relation = $this->secretaryRepo->findActiveClinicRow($user, $context->clinic, $doctor); return $relation !== null && $this->permissions->can($relation, $resource, $action); } // محیطِ مطب شخصی: تنظیم هم باید شخصی باشد (clinic == null). - if ($clinic !== null) { - return false; - } - $ctxDoctor = $this->doctorRepo->findByUuid($dbUuid); - if ($ctxDoctor === null || $ctxDoctor->getId() !== $doctor->getId()) { + if ($clinic !== null || $context->id !== $doctor->getId()) { return false; } $relation = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor); diff --git a/src/Shared/Context/EntityContextResolver.php b/src/Shared/Context/EntityContextResolver.php index 51b79421..174fe140 100644 --- a/src/Shared/Context/EntityContextResolver.php +++ b/src/Shared/Context/EntityContextResolver.php @@ -6,7 +6,9 @@ use App\Auth\Entity\User; use App\Auth\Repository\UserActiveContextRepository; use App\Clinic\Entity\Clinic; use App\Clinic\Repository\ClinicRepository; +use App\Doctor\Entity\Doctor; use App\Doctor\Repository\DoctorRepository; +use App\Secretary\Repository\DoctorSecretaryRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Exception\AppException; @@ -18,6 +20,12 @@ use App\Shared\Exception\AppException; * نقش به‌تنهایی برای کاربری که هم پزشک است و هم مالک کلینیک جواب نمی‌دهد: چنین * کاربری همیشه به‌عنوان پزشک حل می‌شد و هرگز به سرویس‌های کلینیک خودش نمی‌رسید. * UserActiveContext تعیین‌کننده است و نقش فقط fallback آخر. + * + * این کلاس فقط به «کدام محیط» جواب می‌دهد. «چه کاری در آن محیط مجاز است» کار + * ClinicDoctorPermissionChecker و SecretaryPermissionChecker است و اینجا بررسی نمی‌شود. + * + * برای مسیر رزرو عمومی (که کاربر پنل ندارد و ورودی‌اش Doctor است، نه User) + * قرینه‌ای جدا وجود دارد: {@see \App\Appointment\Service\BookingContextResolver}. */ class EntityContextResolver { @@ -25,6 +33,7 @@ class EntityContextResolver private readonly DoctorRepository $doctorRepo, private readonly ClinicRepository $clinicRepo, private readonly UserActiveContextRepository $activeContextRepo, + private readonly DoctorSecretaryRepository $secretaryRepo, ) {} /** @@ -64,7 +73,7 @@ class EntityContextResolver } } - /** مالک کلینیک، ادمین، یا پزشکِ عضو همان کلینیک. */ + /** مالک کلینیک، ادمین، پزشکِ عضو همان کلینیک، یا منشیِ دارای رابطهٔ فعال در آن. */ public function canActInClinic(User $user, Clinic $clinic): bool { if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) { @@ -72,8 +81,11 @@ class EntityContextResolver } $doctor = $this->doctorRepo->findByUser($user); + if ($doctor !== null && $clinic->hasDoctor($doctor)) { + return true; + } - return $doctor !== null && $clinic->hasDoctor($doctor); + return $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic) !== null; } public function assertCanActInClinic(User $user, Clinic $clinic): void @@ -100,11 +112,24 @@ class EntityContextResolver } $doctor = $this->doctorRepo->findByUuid($active->getDbUuid()); - if ($doctor !== null && $doctor->getUser()->getId() === $user->getId()) { - return EntityContext::forDoctor($doctor); + + return $doctor !== null && $this->canActForDoctor($user, $doctor) + ? EntityContext::forDoctor($doctor) + : null; + } + + /** + * محیطِ مطب شخصی: خودِ پزشک، یا منشیِ دارای رابطهٔ فعال با او. + * قرینهٔ canActInClinic برای شاخهٔ غیرکلینیکی؛ ادمین عمداً اینجا نیست چون + * مسیرهای ادمین سراسری‌اند و از محیط فعالِ یک پزشک عبور نمی‌کنند. + */ + private function canActForDoctor(User $user, Doctor $doctor): bool + { + if ($doctor->getUser()->getId() === $user->getId()) { + return true; } - return null; + return $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor) !== null; } private function fromRole(User $user): EntityContext @@ -119,6 +144,9 @@ class EntityContextResolver return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown(); } + // منشی fallback نقشی ندارد: محیطش فقط از UserActiveContext می‌آید، چون یک + // منشی می‌تواند هم‌زمان به چند پزشک و کلینیک وصل باشد و نقش تنها، انتخاب + // بین آن‌ها را تعیین نمی‌کند. return EntityContext::unknown(); } } diff --git a/src/Subscription/Controller/SubscriptionController.php b/src/Subscription/Controller/SubscriptionController.php index e1416a22..4bdade64 100644 --- a/src/Subscription/Controller/SubscriptionController.php +++ b/src/Subscription/Controller/SubscriptionController.php @@ -257,6 +257,13 @@ class SubscriptionController extends BaseController // ── Helpers ───────────────────────────────────────────────────────────── + /** + * عمداً از EntityContextResolver استفاده نمی‌کند: اشتراک به مالکِ حقوقی تعلق + * دارد، نه به محیطِ کاری لحظه‌ای — پزشکِ عضو در محیط کلینیک همچنان اشتراک + * خودش را می‌بیند، نه اشتراک کلینیک. انتقال به رزولور این را عوض می‌کند. + * + * @return array{0: string, 1: int|null} [entityType, entityId] + */ private function resolveEntity(User $user): array { if ($user->hasRole('ROLE_DOCTOR')) { diff --git a/src/Tag/Controller/TenantTagController.php b/src/Tag/Controller/TenantTagController.php index a0f082cf..6b291a00 100644 --- a/src/Tag/Controller/TenantTagController.php +++ b/src/Tag/Controller/TenantTagController.php @@ -154,7 +154,14 @@ class TenantTagController extends BaseController return $tag; } - /** @return array{0: string, 1: int|null} [entityType, entityId] */ + /** + * عمداً از EntityContextResolver استفاده نمی‌کند: اینجا نقشِ پزشک بی‌قید اول + * بررسی می‌شود، پس پزشکِ عضو حتی در محیط کلینیک هم برچسب‌های شخصی خودش را + * می‌بیند. رزولور مشترک محیط فعال را مقدم می‌داند و انتقال به آن، دادهٔ + * نمایش‌داده‌شده را عوض می‌کند — به فاز نشانه‌گذاری tenant موکول شد. + * + * @return array{0: string, 1: int|null} [entityType, entityId] + */ private function resolveEntity(User $user): array { if ($user->hasRole('ROLE_DOCTOR')) { diff --git a/tests/ApiTestCase.php b/tests/ApiTestCase.php index f3212fac..a6ba86d5 100644 --- a/tests/ApiTestCase.php +++ b/tests/ApiTestCase.php @@ -3,6 +3,7 @@ namespace App\Tests; use App\Auth\Entity\User; +use App\Subscription\Entity\SubscriptionPlan; use Doctrine\ORM\EntityManagerInterface; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; use Symfony\Bundle\FrameworkBundle\KernelBrowser; @@ -24,6 +25,31 @@ abstract class ApiTestCase extends WebTestCase { $this->client = static::createClient(); $this->em = static::getContainer()->get(EntityManagerInterface::class); + $this->ensureFreePlan(); + } + + /** + * The «free» plan is SubscriptionService::getEffectivePlan's fallback for any + * tenant without a paid subscription. Without it every hasFeature() is false + * and the patient / service / insurance endpoints answer 403 instead of doing + * their job. db_test is never reset, so the insert is idempotent. + * + * Mirrors the row shipped in the dev database. + */ + private function ensureFreePlan(): void + { + $repo = $this->em->getRepository(SubscriptionPlan::class); + if ($repo->findOneBy(['name' => 'free']) !== null) { + return; + } + + $this->em->persist(new SubscriptionPlan('free', 0, 1, [ + 'patient_records' => true, + 'services' => true, + 'sms_panel' => true, + 'insurance' => true, + ])); + $this->em->flush(); } /** diff --git a/tests/Shared/EntityContextResolverTest.php b/tests/Shared/EntityContextResolverTest.php new file mode 100644 index 00000000..44a8ce71 --- /dev/null +++ b/tests/Shared/EntityContextResolverTest.php @@ -0,0 +1,255 @@ +get(EntityContextResolver::class); + } + + private function makeDoctor(?User $user = null): Doctor + { + $doctor = new Doctor($user ?? $this->createUser(['ROLE_DOCTOR']), 'دکتر تست'); + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + private function makeClinic(?User $owner = null): Clinic + { + $clinic = new Clinic($owner ?? $this->createUser(['ROLE_CLINIC'])); + $clinic->setName('کلینیک تست'); + $this->em->persist($clinic); + $this->em->flush(); + + return $clinic; + } + + private function joinClinic(Clinic $clinic, Doctor $doctor): void + { + $clinic->getDoctors()->add($doctor); + $this->em->persist(new ClinicDoctorPermission($clinic, $doctor)); + $this->em->flush(); + } + + private function setActiveContext(User $user, string $dbUuid): void + { + $this->em->persist(new UserActiveContext($user, $dbUuid)); + $this->em->flush(); + } + + // ── سناریو ۱: پزشک مستقل ──────────────────────────────────────────────── + + public function testIndependentDoctorAlwaysResolvesToOwnPractice(): void + { + $doctor = $this->makeDoctor(); + + $context = $this->resolver()->resolve($doctor->getUser()); + + self::assertSame(['doctor', $doctor->getId()], $context->toEntityPair()); + } + + // ── سناریو ۲: پزشکِ مستقلِ عضو کلینیک ─────────────────────────────────── + + public function testClinicMemberDoctorResolvesToClinicWhenActiveContextIsClinic(): void + { + $doctor = $this->makeDoctor(); + $clinic = $this->makeClinic(); + $this->joinClinic($clinic, $doctor); + $this->setActiveContext($doctor->getUser(), $clinic->getUuid()); + + $context = $this->resolver()->resolve($doctor->getUser()); + + self::assertSame(['clinic', $clinic->getId()], $context->toEntityPair()); + } + + public function testClinicMemberDoctorFallsBackToOwnPracticeWhenActiveContextIsSelf(): void + { + $doctor = $this->makeDoctor(); + $clinic = $this->makeClinic(); + $this->joinClinic($clinic, $doctor); + $this->setActiveContext($doctor->getUser(), $doctor->getUuid()); + + $context = $this->resolver()->resolve($doctor->getUser()); + + self::assertSame(['doctor', $doctor->getId()], $context->toEntityPair()); + } + + /** + * مجوزِ غیرفعال محیط را عوض نمی‌کند — عضویت در clinic_doctors تعیین‌کنندهٔ + * «کجا»ست و ClinicDoctorPermission تعیین‌کنندهٔ «چه کاری». محدودسازی در + * PatientRecordScopeResolver اتفاق می‌افتد، نه اینجا. + */ + public function testInactivePermissionStillResolvesToClinicEnvironment(): void + { + $doctor = $this->makeDoctor(); + $clinic = $this->makeClinic(); + $this->joinClinic($clinic, $doctor); + + $permission = $this->em->getRepository(ClinicDoctorPermission::class) + ->findOneBy(['clinic' => $clinic, 'doctor' => $doctor]); + $permission->setActive(false); + $this->em->flush(); + + $this->setActiveContext($doctor->getUser(), $clinic->getUuid()); + + $context = $this->resolver()->resolve($doctor->getUser()); + + self::assertSame(['clinic', $clinic->getId()], $context->toEntityPair()); + } + + // ── سناریو ۳: پزشکی که هم عضو و هم مالک کلینیک است ────────────────────── + + public function testDoctorWhoOwnsClinicResolvesToClinicWithoutMembershipRow(): void + { + $user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']); + $doctor = $this->makeDoctor($user); + $clinic = $this->makeClinic($user); + $this->setActiveContext($user, $clinic->getUuid()); + + $context = $this->resolver()->resolve($user); + + self::assertSame(['clinic', $clinic->getId()], $context->toEntityPair()); + self::assertTrue($this->resolver()->canActInClinic($user, $clinic)); + } + + public function testDoctorWhoOwnsClinicResolvesToOwnPracticeWhenActiveContextIsSelf(): void + { + $user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']); + $doctor = $this->makeDoctor($user); + $this->makeClinic($user); + $this->setActiveContext($user, $doctor->getUuid()); + + $context = $this->resolver()->resolve($user); + + self::assertSame(['doctor', $doctor->getId()], $context->toEntityPair()); + } + + // ── سناریو ۴: مدیر/مالک کلینیک (غیرپزشک) ──────────────────────────────── + + public function testClinicManagerAlwaysResolvesToClinic(): void + { + $clinic = $this->makeClinic(); + + $context = $this->resolver()->resolve($clinic->getUser()); + + self::assertSame(['clinic', $clinic->getId()], $context->toEntityPair()); + } + + // ── سناریو ۵: منشی ────────────────────────────────────────────────────── + + public function testSecretaryWithoutActiveContextResolvesToUnknown(): void + { + $user = $this->createUser(['ROLE_SECRETARY']); + + $context = $this->resolver()->resolve($user); + + self::assertSame(EntityContext::TYPE_UNKNOWN, $context->type); + self::assertFalse($context->isResolved()); + } + + public function testSecretaryWithActiveClinicRelationResolvesToThatClinic(): void + { + $doctor = $this->makeDoctor(); + $clinic = $this->makeClinic(); + $this->joinClinic($clinic, $doctor); + $secretary = $this->createUser(['ROLE_SECRETARY']); + + $this->em->persist(new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic)); + $this->em->flush(); + $this->setActiveContext($secretary, $clinic->getUuid()); + + $context = $this->resolver()->resolve($secretary); + + self::assertSame(['clinic', $clinic->getId()], $context->toEntityPair()); + } + + public function testSecretaryWithActiveDoctorRelationResolvesToThatPractice(): void + { + $doctor = $this->makeDoctor(); + $secretary = $this->createUser(['ROLE_SECRETARY']); + + $this->em->persist(new DoctorSecretary($doctor, $secretary)); + $this->em->flush(); + $this->setActiveContext($secretary, $doctor->getUuid()); + + $context = $this->resolver()->resolve($secretary); + + self::assertSame(['doctor', $doctor->getId()], $context->toEntityPair()); + } + + /** رابطهٔ غیرفعال = پایان همکاری؛ محیط دیگر حل نمی‌شود. */ + public function testSecretaryWithInactiveRelationResolvesToUnknown(): void + { + $doctor = $this->makeDoctor(); + $clinic = $this->makeClinic(); + $this->joinClinic($clinic, $doctor); + $secretary = $this->createUser(['ROLE_SECRETARY']); + + $relation = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic); + $relation->setActive(false); + $this->em->persist($relation); + $this->em->flush(); + $this->setActiveContext($secretary, $clinic->getUuid()); + + $context = $this->resolver()->resolve($secretary); + + self::assertFalse($context->isResolved()); + } + + // ── مسیر clinic_uuid صریح ─────────────────────────────────────────────── + + public function testExplicitClinicUuidForNonMemberThrowsAccessDenied(): void + { + $doctor = $this->makeDoctor(); + $clinic = $this->makeClinic(); + + $this->expectException(AppException::class); + $this->resolver()->resolve($doctor->getUser(), $clinic->getUuid()); + } + + public function testTryResolveReturnsNullInsteadOfThrowingForNonMember(): void + { + $doctor = $this->makeDoctor(); + $clinic = $this->makeClinic(); + + self::assertNull($this->resolver()->tryResolve($doctor->getUser(), $clinic->getUuid())); + } + + public function testExplicitClinicUuidWinsOverStoredActiveContext(): void + { + $doctor = $this->makeDoctor(); + $clinicA = $this->makeClinic(); + $clinicB = $this->makeClinic(); + $this->joinClinic($clinicA, $doctor); + $this->joinClinic($clinicB, $doctor); + $this->setActiveContext($doctor->getUser(), $clinicA->getUuid()); + + $context = $this->resolver()->resolve($doctor->getUser(), $clinicB->getUuid()); + + self::assertSame(['clinic', $clinicB->getId()], $context->toEntityPair()); + } +}