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>
22 KiB
فاز ۱ — یکسانسازی تشخیص محیط (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
بقیهٔ کد همان منطق را دستی و ناقص تکرار میکند.
مشکل / هدف
مشکل: «کاربر الان در کدام محیط است؟» در چند فایل جداگانه و با منطقهای کمی متفاوت پاسخ داده میشود. سه نمونهٔ واقعی از تکرار:
AppointmentAccessChecker— الگوی «db_uuidبگیر → اولclinicRepo->findByUuid()→ اگر null بودdoctorRepo->findByUuid()» را سه بار در سه متد تکرار میکند (secretaryCanContext،viewableClinicFor،secretaryCan).ClinicDoctorAccessChecker— همان الگو درmemberClinicId()وcanOrNonMember().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):
- کدام محیط؟ → کار
EntityContextResolver - درون آن محیط، دیدن محدود به بیمارانِ کدام پزشکان؟ → مسئولیت واقعی و منحصربهفرد این کلاس
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 §۴).