# فاز ۵ — آدیت نقاط فرار، تأیید کلاینت‌ها و مستندسازی

> پرامپت پنجم و آخر سری. **پیش‌نیاز: فازهای ۱ تا ۴ کامل و سبز.**
> ۱. `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 §۳) — تأیید دستی وظیفهٔ ۵ تنها پوشش موجود است.
