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