# فاز ۵ — آدیت نقاط فرار، تأیید کلاینتها و مستندسازی > پرامپت پنجم و آخر سری. **پیشنیاز: فازهای ۱ تا ۴ کامل و سبز.** > ۱. `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