Files
clinicpro/.claude/prompt/tenant-01-entity-context-unify.md
T
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

22 KiB
Raw Blame History

فاز ۱ — یکسان‌سازی تشخیص محیط (tenant) روی EntityContextResolver

این پرامپت اول از سری پنج‌تایی «نشانه‌گذاری tenant» است: ۱. یکسان‌سازی رزولور (همین فایل) → ۲. tenant-02-mark-booking-tables.md → ۳. tenant-03-unify-owner-columns.md → ۴. tenant-04-enforce-tenant-filter.md۵. tenant-05-audit-and-docs.md ترتیب اجباری است؛ فازهای بعدی روی خروجی همین فاز ساخته می‌شوند. این فاز هیچ تغییر اسکیمایی ندارد و عمداً اول آمده تا قبل از دست زدن به دیتابیس، تعریف tenant قطعی شود.

زمینه

ClinicPro از قبل مفهوم tenant دارد ولی نامش را نبرده است: هویت هر محیط کاری یک جفت (entity_type, entity_id) است با مقادیر doctor یا clinic. این جفت در ~۱۵ جدول ذخیره می‌شود (service_sections، patient_records، clinic_staff، tenant_tags، tenant_insurances، inventory_items، inventory_packages، invoices، claims، sms_wallets، sms_settings، entity_insurance_pricing، clinic_subscriptions، tenant_service_category_settings، mobile_verification_otp).

محیط فعالِ هر کاربر در user_active_context.db_uuid نگهداری می‌شود — یک uuid که یا مال کلینیک است یا مال پزشک.

نقطهٔ مرجع تشخیص محیط هم وجود دارد: App\Shared\Context\EntityContextResolver. اما فقط ۵ فایل از آن استفاده می‌کنند:

src/Secretary/Security/SecretaryAccessChecker.php
src/ClinicService/Controller/ClinicServiceController.php
src/Dashboard/Controller/DashboardController.php
src/Patient/Security/PatientRecordScopeResolver.php
src/Shared/Context/EntityContextResolver.php

بقیهٔ کد همان منطق را دستی و ناقص تکرار می‌کند.

مشکل / هدف

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

  1. AppointmentAccessChecker — الگوی «db_uuid بگیر → اول clinicRepo->findByUuid() → اگر null بود doctorRepo->findByUuid()» را سه بار در سه متد تکرار می‌کند (secretaryCanContext، viewableClinicFor، secretaryCan).
  2. ClinicDoctorAccessChecker — همان الگو در memberClinicId() و canOrNonMember().
  3. PatientRecordScopeResolver — در docblock خودش نوشته «دقیقاً مثل EntityContextResolver» ولی از آن استفاده نمی‌کند و منطق را دوباره پیاده کرده.

هر تکرار یعنی یک نقطهٔ ممکن برای واگرایی رفتار بین نقش‌ها. مصداق واقعی که در docblock EntityContextResolver ثبت شده: «کاربری که هم پزشک است و هم مالک کلینیک، همیشه به‌عنوان پزشک حل می‌شد و هرگز به سرویس‌های کلینیک خودش نمی‌رسید».

هدف: EntityContextResolver تنها منبع حقیقتِ تشخیص محیط شود، ماتریس نقش‌ها به‌صورت تست اجرایی ثبت شود، و شکاف‌های شناخته‌شدهٔ خود رزولور پر شود.

ماتریس نقش‌ها که باید پشتیبانی شود

# سناریو چطور در دیتابیس نمایش داده می‌شود محیط مؤثر (tenant)
۱ پزشک مستقل users.roles شامل ROLE_DOCTOR؛ در هیچ ردیف clinic_doctors نیست همیشه ('doctor', doctor.id)
۲ پزشک مستقل که عضو یک یا چند کلینیک هم هست ROLE_DOCTOR + ردیف در clinic_doctors + clinic_doctor_permissions متغیر: اگر user_active_context.db_uuid = uuid آن کلینیک → ('clinic', clinic.id)، وگرنه ('doctor', doctor.id)
۳ پزشکی که هم عضو و هم مالک کلینیک است ROLE_DOCTOR + ROLE_CLINIC + clinics.user_id = user.id طبق محیط فعال؛ در محیط کلینیک بدون محدودیت ClinicDoctorPermission (مالک قفل نمی‌شود)
۴ مدیر/مالک کلینیک (غیرپزشک) ROLE_CLINIC + clinics.user_id = user.id همیشه ('clinic', clinic.id)
۵ منشی (پوشش جانبی) ROLE_SECRETARY + ردیف doctor_secretaries با owner_type و clinic اختیاری طبق محیط فعال؛ بدون محیط فعال معتبر → unknown

معیار پذیرش

  • موفق: با توکن کاربر سناریوی ۳ (پزشکِ مالکِ کلینیک) و user_active_context.db_uuid = uuid کلینیک، GET /api/v1/clinic-services/sections بخش‌های کلینیک را برمی‌گرداند؛ بعد از تغییر db_uuid به uuid خود پزشک، همان endpoint بخش‌های مطب شخصی را برمی‌گرداند. هر پنج سناریوی جدول بالا تست واحد سبز دارند.
  • خطا: کاربر سناریوی ۱ (پزشک مستقل) با clinic_uuid صریحِ کلینیکی که عضوش نیست → 403 با ERR_ACCESS_DENIED و envelope استاندارد {success:false, errors:[{code,message}]}.
  • ⚠️ مرزی: کاربری با ROLE_SECRETARY که user_active_context ندارد → EntityContext::unknown() و پاسخ endpointها 403 (نه ۵۰۰، نه لیست خالیِ گمراه‌کننده). همچنین پزشک عضوی که ClinicDoctorPermission.active = false شده → به محیط شخصی خودش برمی‌گردد، نه به کلینیک.

فایل‌های مرتبط

فایل نقش
src/Shared/Context/EntityContext.php VO محیط — type/id/clinic/doctor + toEntityPair()
src/Shared/Context/EntityContextResolver.php رزولور مرکزی — هدف این فاز
src/Appointment/Security/AppointmentAccessChecker.php سه بار تکرار منطق محیط
src/Clinic/Security/ClinicDoctorAccessChecker.php دو بار تکرار منطق محیط
src/Patient/Security/PatientRecordScopeResolver.php بازپیاده‌سازی مستقل همان منطق
src/Appointment/Service/BookingContextResolver.php نسخهٔ محدودِ همان مفهوم برای رزرو
src/Appointment/Controller/MyAppointmentsController.php خواندن مستقیم contextRepo در خط ۵۵۵
src/Auth/Entity/UserActiveContext.php منبع محیط فعال (db_uuid)
tests/Shared/ محل تست‌های جدید ماتریس نقش

وضعیت فعلی

EntityContextResolver — منطق درست، ولی کم‌استفاده:

// src/Shared/Context/EntityContextResolver.php
public function resolve(User $user, ?string $clinicUuid = null): EntityContext
{
    if ($clinicUuid !== null && $clinicUuid !== '') {
        $clinic = $this->clinicRepo->findByUuid($clinicUuid);
        if ($clinic === null) {
            throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
        }
        $this->assertCanActInClinic($user, $clinic);

        return EntityContext::forClinic($clinic);
    }

    $fromActive = $this->fromActiveContext($user);
    if ($fromActive !== null) {
        return $fromActive;
    }

    return $this->fromRole($user);
}

private function fromRole(User $user): EntityContext
{
    if ($user->hasRole('ROLE_DOCTOR')) {
        return EntityContext::forDoctor($this->doctorRepo->findByUser($user));
    }

    if ($user->hasRole('ROLE_CLINIC')) {
        $clinic = $this->clinicRepo->findByUser($user);

        return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown();
    }

    return EntityContext::unknown();
}

همان منطق، بازنویسی‌شده در AppointmentAccessChecker:

// src/Appointment/Security/AppointmentAccessChecker.php — secretaryCan()
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
    return false;
}

$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
    if ($appointment->getClinic()?->getId() !== $clinic->getId()) {
        return false;
    }
    // ...
}

$doctor = $this->doctorRepo->findByUuid($dbUuid);
if ($doctor === null || $doctor->getId() !== $appointment->getDoctor()->getId()) {
    return false;
}

و در ClinicDoctorAccessChecker:

// src/Clinic/Security/ClinicDoctorAccessChecker.php — memberClinicId()
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
    return null;
}

$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic === null) {
    return null;
}

$doctor = $this->doctorRepo->findByUser($user);
if ($doctor === null || !$clinic->hasDoctor($doctor)) {
    return null;
}

return $clinic->getId();

وظایف

۱. تکمیل EntityContextResolver برای نقش منشی

fromRole() فعلاً برای ROLE_SECRETARY مستقیم unknown() برمی‌گرداند. این درست است (منشی بدون محیط فعال نباید جایی برود) اما نانوشته است و باعث شده PatientRecordScopeResolver منطق منشی را جدا پیاده کند.

منطق منشی را به خود رزولور اضافه کن، بدون تغییر رفتار فعلی: محیط فعال معتبر لازم است و در نبودش unknown.

// src/Shared/Context/EntityContextResolver.php
private function fromRole(User $user): EntityContext
{
    if ($user->hasRole('ROLE_DOCTOR')) {
        return EntityContext::forDoctor($this->doctorRepo->findByUser($user));
    }

    if ($user->hasRole('ROLE_CLINIC')) {
        $clinic = $this->clinicRepo->findByUser($user);

        return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown();
    }

    // منشی هرگز fallback نقشی ندارد: محیطش فقط از UserActiveContext می‌آید،
    // چون یک منشی می‌تواند هم‌زمان به چند پزشک/کلینیک وصل باشد.
    return EntityContext::unknown();
}

کامنت بالا را عیناً اضافه کن — این تصمیم معماری است، نه توضیح بدیهی.

نحوه تست: تست واحد در tests/Shared/EntityContextResolverTest.php — کاربر با ROLE_SECRETARY بدون UserActiveContext$ctx->type === 'unknown' و $ctx->isResolved() === false.


۲. تست ماتریس نقش‌ها — پیش‌نیاز هر refactor

قبل از دست زدن به checkerها، پنج سناریوی جدول «ماتریس نقش‌ها» را به‌صورت تست ثبت کن. این تست‌ها قرارداد فازهای بعدی‌اند.

فایل جدید: tests/Shared/EntityContextResolverTest.php با extends ApiTestCase (الگوی موجود tests/Appointment/SlotUniquenessTest.php را دنبال کن — همان helperهای makeDoctor() و ساخت داده).

سناریوهای الزامی:

public function testIndependentDoctorAlwaysResolvesToOwnPractice(): void;
public function testClinicMemberDoctorResolvesToClinicWhenActiveContextIsClinic(): void;
public function testClinicMemberDoctorFallsBackToOwnPracticeWhenActiveContextIsSelf(): void;
public function testDoctorWhoOwnsClinicResolvesToClinicAndIsNotRestrictedByMemberPermissions(): void;
public function testClinicManagerAlwaysResolvesToClinic(): void;
public function testSecretaryWithoutActiveContextResolvesToUnknown(): void;
public function testExplicitClinicUuidForNonMemberThrowsAccessDenied(): void;
public function testInactiveMemberPermissionFallsBackToOwnPractice(): void;

هر تست باید روی toEntityPair() assert کند، نه روی جزئیات داخلی:

self::assertSame(['clinic', $clinic->getId()], $ctx->toEntityPair());

نحوه تست: ddev exec php bin/phpunit tests/Shared/EntityContextResolverTest.php — همه سبز.


۳. حذف تکرار در AppointmentAccessChecker

سه متد secretaryCanContext()، viewableClinicFor() و secretaryCan() را طوری بازنویسی کن که محیط را از EntityContextResolver بگیرند، نه از contextRepo + دو findByUuid.

EntityContextResolver را با constructor injection تزریق کن (نه new)، و UserActiveContextRepository را از constructor حذف کن اگر بعد از refactor مصرف دیگری ندارد.

// قبل
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
    return false;
}
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) { /* شاخهٔ کلینیک */ }
$doctor = $this->doctorRepo->findByUuid($dbUuid);
/* شاخهٔ مطب شخصی */

// بعد
$ctx = $this->contextResolver->resolve($user);
if (!$ctx->isResolved()) {
    return false;
}
if ($ctx->isClinic()) { /* شاخهٔ کلینیک — $ctx->clinic در دسترس است */ }
/* شاخهٔ مطب شخصی — $ctx->doctor در دسترس است */

مهم: EntityContext هم id و هم خود entity (clinic/doctor) را نگه می‌دارد، پس بعد از refactor نباید هیچ findByUuid اضافه‌ای لازم باشد. اگر جایی لازم شد، یعنی refactor ناقص است.

رفتار نباید عوض شود. یک تفاوت ظریف هست که باید عمداً حفظ شود: EntityContextResolver::resolve() بدون clinicUuid، اگر محیط فعال نبود به fromRole() می‌افتد، در حالی که کد فعلی AppointmentAccessChecker در نبود db_uuid مستقیم false برمی‌گرداند. برای منشی این دو یکی است (نقش منشی fallback ندارد)، ولی برای مسیرهای دیگر نه — پس در متدهای مخصوص منشی، بعد از resolve حتماً ROLE_SECRETARY را هم چک کن یا از شرط isResolved() استفاده کن، نه بیشتر.

نحوه تست: ddev exec php bin/phpunit tests/Appointment/ — همهٔ تست‌های موجود نوبت باید بدون تغییر سبز بمانند. اگر تستی قرمز شد، یعنی رفتار عوض شده و refactor غلط است.


۴. حذف تکرار در ClinicDoctorAccessChecker

همان کار وظیفهٔ ۳ برای memberClinicId() و canOrNonMember().

public function memberClinicId(User $user): ?int
{
    $ctx = $this->contextResolver->resolve($user);
    if (!$ctx->isClinic()) {
        return null;
    }

    $doctor = $this->doctorRepo->findByUser($user);

    return $doctor !== null && $ctx->clinic->hasDoctor($doctor) ? $ctx->clinic->getId() : null;
}

نحوه تست: ddev exec php bin/phpunit tests/Clinic/ tests/Secretary/ سبز + یک تست جدید که سناریوی ۳ (پزشکِ مالک) را پوشش دهد: مالک کلینیک نباید با ClinicDoctorPermission محدود شود حتی اگر ردیف permission برایش وجود داشته باشد.


۵. بازنویسی PatientRecordScopeResolver روی EntityContextResolver

این کلاس دو مسئولیت دارد که باید تفکیک شوند (SOLID / S):

  1. کدام محیط؟ → کار EntityContextResolver
  2. درون آن محیط، دیدن محدود به بیمارانِ کدام پزشکان؟ → مسئولیت واقعی و منحصربه‌فرد این کلاس

forDoctorUser() و forSecretary() را طوری بازنویسی کن که بخش اول را از رزولور بگیرند و فقط بخش دوم را خودشان حساب کنند.

public function resolve(User $user): PatientRecordScope
{
    $ctx = $this->contextResolver->resolve($user);
    if (!$ctx->isResolved()) {
        return PatientRecordScope::unknown();
    }

    if (!$ctx->isClinic()) {
        return PatientRecordScope::forDoctor($ctx->id);
    }

    return $this->restrictionInClinic($user, $ctx->clinic);
}

/** مالک → بدون محدودیت؛ پزشک عضو → فقط بیماران خودش؛ منشی → بیماران پزشکان تخصیص‌یافته. */
private function restrictionInClinic(User $user, Clinic $clinic): PatientRecordScope
{
    if ($clinic->getUser()->getId() === $user->getId()) {
        return PatientRecordScope::forClinic($clinic->getId());
    }
    // ... منطق موجود forDoctorUser/forSecretary، فقط بخش محدودیت
}

PatientRecordScope خودش دست‌نخورده می‌ماند — toLegacyTuple() هم می‌ماند چون PatientController در ۲۰+ نقطه به آن وابسته است. حذف toLegacyTuple() جزو این فاز نیست و اگر انجام شود دامنهٔ فاز را می‌ترکاند.

نحوه تست: ddev exec php bin/phpunit tests/Patient/ سبز. علاوه بر آن، تست دستی با دو کاربر واقعی از clinicpro-qa-accounts: یک پزشک عضو کلینیک و یک مالک کلینیک — GET /api/v1/patients باید برای اولی فقط بیماران خودش و برای دومی همهٔ بیماران کلینیک را برگرداند.


۶. یکسان‌سازی BookingContextResolver

BookingContextResolver همان تصمیم را برای مسیر رزرو می‌گیرد ولی خروجی‌اش ?Clinic است، نه EntityContext:

public function resolve(Doctor $doctor, ?string $clinicUuid): ?Clinic
{
    if ($clinicUuid === null || trim($clinicUuid) === '') {
        return null;
    }

    $clinic = $this->clinicRepo->findByUuid(trim($clinicUuid));
    if ($clinic === null || !$clinic->hasDoctor($doctor)) {
        throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'محل نوبت‌دهی یافت نشد', 404);
    }

    return $clinic;
}

این کلاس را حذف نکن. قرارداد متفاوتی دارد و عمداً هم متفاوت است: ورودی‌اش Doctor است نه User (رزرو ممکن است توسط بیمار انجام شود، نه خود پزشک)، و null در آن معنای صریحِ «مطب شخصی» دارد، نه «نامشخص». docblock خودش این را توضیح داده.

کاری که باید بکنی: در docblock هر دو کلاس، ارجاع متقابل بنویس تا معلوم باشد کدام برای کدام مسیر است و چرا دو تا هستند. حذف یکی به نفع دیگری در این فاز، تغییر رفتارِ مسیر رزرو عمومی است و ریسکش بی‌دلیل است.

نحوه تست: بدون تغییر رفتار — ddev exec php bin/phpunit tests/Appointment/ سبز.


۷. حذف دسترسی مستقیم به UserActiveContextRepository

بعد از وظایف ۳ تا ۵، این دستور باید فقط خودِ EntityContextResolver را برگرداند:

ddev exec grep -rln "UserActiveContextRepository" src --include="*.php"

اگر فایل دیگری ماند (مثلاً MyAppointmentsController خط ۵۵۵)، آن را هم به رزولور منتقل کن. اگر انتقال ممکن نبود، دلیلش را در docblock همان نقطه بنویس — تا فاز بعدی بداند این استثنا عمدی است.

نحوه تست: خروجی دستور بالا فقط src/Shared/Context/EntityContextResolver.php باشد (به‌علاوه استثناهای مستند‌شده).

نکات مهم

  • الگو: Value Object + Resolver. EntityContext یک VO تغییرناپذیر است و EntityContextResolver تنها سازندهٔ آن. دلیل انتخاب: تصمیم «کدام محیط» یک بار گرفته می‌شود و بعد به‌صورت داده جابه‌جا می‌شود؛ اگر هر مصرف‌کننده خودش تصمیم بگیرد (وضع فعلی) واگرایی رفتار بین نقش‌ها اجتناب‌ناپذیر است. این الگو از قبل در پروژه هست — این فاز فقط آن را فراگیر می‌کند، نه اینکه چیز جدیدی بسازد.
  • هیچ interface جدیدی نساز. فقط یک پیاده‌سازی وجود دارد و نیاز مشخصی برای تعویض نیست (guidelines §۵).
  • این فاز نباید هیچ رفتار قابل مشاهده‌ای را عوض کند. خروجی موفق = همان رفتار، با یک منبع حقیقت. هر تست موجودی که قرمز شود، نشانهٔ خطای refactor است نه نیاز به به‌روزرسانی تست.
  • db_uuid مبهم است و در این فاز درست نمی‌شود. یک uuid که ممکن است کلینیک باشد یا پزشک، همیشه دو lookup لازم دارد. افزودن db_type به user_active_context کار فاز ۳ است (tenant-03-unify-owner-columns.md)؛ اینجا فقط تمرکز lookup در یک نقطه است.
  • EntityContext::toEntityPair() قرارداد اتصال به فاز ۲ است. فاز بعدی همین جفت را روی appointments، weekly_schedules و date_overrides می‌نویسد. امضای این متد را عوض نکن.
  • کلاینت‌های متأثر: این فاز قرارداد API را عوض نمی‌کند، پس nobat724_front و clinic-pro-tauri نباید تغییری ببینند. اگر حین refactor مجبور شدی شکل پاسخ یک endpoint را عوض کنی، یعنی از دامنهٔ فاز خارج شده‌ای — متوقف شو و گزارش بده.
  • مستندات: این فاز endpoint جدید نمی‌سازد، پس docs/api/* تغییر نمی‌کند. اما اگر رفتار مستندشده‌ای در docs/api/patient.md یا docs/api/appointment.md با رفتار واقعی نمی‌خواند، همان جلسه اصلاحش کن (guidelines §۴).