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