Phase 1 of the tenant-marking series. The "which environment is this user working in?" decision was reimplemented in six places, each reading UserActiveContext.db_uuid and then guessing whether the uuid belongs to a clinic or a doctor. Every copy was a place the roles could silently diverge. EntityContextResolver already encoded the right precedence (explicit clinic_uuid > stored active context > role) but only five files used it, and it did not recognise secretaries at all: canActInClinic accepted admins, clinic owners and member doctors, so a secretary's active clinic context always collapsed to unknown. That gap is why SecretaryAccessChecker carried its own copy of the logic. - canActInClinic now also accepts an active DoctorSecretary relation, and a matching canActForDoctor covers the personal-practice branch. - AppointmentAccessChecker, ClinicDoctorAccessChecker, SecretaryAccessChecker, PatientRecordScopeResolver, MyAppointmentsController and the secretary dashboard all resolve through it now. - PatientRecordScopeResolver keeps only its real responsibility: which doctors' patients are visible inside the resolved environment. - The resolver answers "where"; ClinicDoctorPermissionChecker and SecretaryPermissionChecker still answer "what may you do". Left deliberately untouched, with the reason recorded at each site: SubscriptionController, InventoryController and TenantTagController check ROLE_DOCTOR unconditionally and ignore the active context, so a member doctor sees personal inventory/tags/subscription even inside a clinic. Switching them changes what users see, which is a product decision, not a refactor. AuthController keeps its repository because it writes the active context. tests/ApiTestCase now seeds the "free" subscription plan. db_test had no such row, so getEffectivePlan returned null, every hasFeature() was false and 83 tests across Patient, ClinicService, Insurance and Appointment failed with 403. No schema, route, request, response or error code changed. Tests: 813 passing (was 730 passing / 83 failing). PHPStan clean on all changed files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
362 lines
22 KiB
Markdown
362 lines
22 KiB
Markdown
# فاز ۱ — یکسانسازی تشخیص محیط (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 §۴).
|