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

19 KiB
Raw Permalink Blame History

فاز ۳ — یکسان‌سازی نمایش‌های ناهمگون 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 مال چیست:

// src/Shared/Context/EntityContextResolver.php — fromActiveContext()
$clinic = $this->clinicRepo->findByUuid($active->getDbUuid());
if ($clinic !== null) { ... }

$doctor = $this->doctorRepo->findByUuid($active->getDbUuid());

مشکل ۳ — یک باگ واقعی در یکتایی doctor_secretaries. کلید یکتای فعلی:

#[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_identity_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

وضعیت فعلی

// 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;
// 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;
// src/Auth/Entity/UserActiveContext.php
#[ORM\Column(name: 'db_uuid', type: 'string', length: 36)]
private string $dbUuid;

وظایف

۱. discount_rules — تغییر نام ستون‌ها

ساده‌ترین مورد سری: هیچ تغییر معنایی ندارد، فقط نام.

// 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:

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، همهٔ نقاط مصرف را پیدا و اصلاح کن:

ddev exec grep -rn "getOwnerType\|getOwnerId\|setOwnerType\|setOwnerId\|owner_type\|owner_id" src assets --include="*.php" --include="*.tsx" --include="*.ts"

اگر owner_type در پاسخ API تخفیف‌ها ظاهر می‌شود، نام فیلد پاسخ را عوض نکن مگر اینکه پنل ادمین را هم در همان جلسه اصلاح کنی — این یک تغییر قرارداد است.

نحوه تست:

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 تهی (دادهٔ خراب) باید شناسایی و گزارش شود — بند «ردیف‌های ناسازگار»
#[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:

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:

$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 کوئری می‌زنند و حذفش دامنهٔ فاز را می‌ترکاند.

نحوه تست:

ddev exec php bin/phpunit tests/Secretary/

به‌علاوه یک تست جدید که باگ رفع‌شده را تثبیت کند — بدون این تست، وظیفه تمام نیست:

// tests/Secretary/SecretaryMultiClinicScopeTest.php
public function testSameSecretaryCanServeSameDoctorInTwoDifferentClinics(): void
{
    // دو کلینیک، یک پزشک عضو هر دو، یک منشی
    // هر دو ردیف باید ذخیره شوند و findActiveClinicRow برای هر کلینیک
    // ردیف مخصوص همان کلینیک را برگرداند
}

۳. user_active_context — رفع ابهام db_uuid

ستون db_type اضافه کن تا EntityContextResolver بداند uuid مال چیست و به‌جای دو کوئری، یکی بزند.

// 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 را در هر دو جدول بگرد:

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() را ساده کن:

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 را هم بدهد. نقاط را پیدا کن:

ddev exec grep -rn "new UserActiveContext(\|setDbUuid(" src --include="*.php"

نحوه تست:

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 دست نزن — بند «نکات مهم»

قبل از تغییر طول، مطمئن شو هیچ مقدار بلندتری در داده نیست:

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 را بگیرند تا تعریف تکراری حذف شود.

نحوه تست:

ddev exec php bin/console doctrine:migrations:diff --no-interaction   # باید فقط تغییر طول را ببیند
ddev exec php bin/phpunit tests/Inventory/ tests/Tag/

۵. تست ماتریس نقش، تکرار روی داده‌های یکسان‌شده

تست‌های فاز ۱ فقط رزولور را پوشش می‌دادند. حالا که ستون‌ها یکسان شده‌اند، یک تست یکپارچه بنویس که برای هر پنج نقش ماتریس، دادهٔ متعلق به محیط درست را از چند دامنه می‌گیرد:

// 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_typeentity_type)، هم پنل ادمین (assets/admin/)، هم nobat724_front/services/response.js و هم clinic-pro-tauri/src/service/response.js باید دستی بررسی شوند. پیشنهاد کم‌ریسک: نام ستون دیتابیس عوض شود ولی کلید JSON پاسخ دست‌نخورده بماند، مگر اینکه هر سه کلاینت در همین جلسه اصلاح شوند.
  • مستندات: docs/api/ هر دامنه‌ای که شکل پاسخش عوض شده (discount, secretary) همان جلسه با JSON واقعی به‌روز شود.