refactor(tenant): make EntityContextResolver the single context resolver

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>
This commit is contained in:
hamed
2026-07-28 10:59:22 +03:30
co-authored by Claude Opus 5
parent 748dc81ea9
commit 1a7bf53577
18 changed files with 2185 additions and 216 deletions
@@ -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 §۴).
@@ -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 §۴).
@@ -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 واقعی به‌روز شود.
@@ -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<class-string, string> کلاس => دلیل */
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 جدید می‌خواند.
+248
View File
@@ -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 <table> --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 §۳) — تأیید دستی وظیفهٔ ۵ تنها پوشش موجود است.