Files
clinicpro/.claude/prompt/tenant-03-unify-owner-columns.md
hamedandClaude Opus 5 1a7bf53577 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>
2026-07-28 10:59:22 +03:30

367 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# فاز ۳ — یکسان‌سازی نمایش‌های ناهمگون 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 واقعی به‌روز شود.