# فاز ۱ — یکسان‌سازی تشخیص محیط (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 §۴).