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>
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
# فاز ۱ — یکسانسازی تشخیص محیط (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`](../../src/Appointment/Security/AppointmentAccessChecker.php) — الگوی «`db_uuid` بگیر → اول `clinicRepo->findByUuid()` → اگر null بود `doctorRepo->findByUuid()`» را **سه بار** در سه متد تکرار میکند (`secretaryCanContext`، `viewableClinicFor`، `secretaryCan`).
|
||||
2. [`ClinicDoctorAccessChecker`](../../src/Clinic/Security/ClinicDoctorAccessChecker.php) — همان الگو در `memberClinicId()` و `canOrNonMember()`.
|
||||
3. [`PatientRecordScopeResolver`](../../src/Patient/Security/PatientRecordScopeResolver.php) — در 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` — منطق درست، ولی کماستفاده:
|
||||
|
||||
```php
|
||||
// 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`:
|
||||
|
||||
```php
|
||||
// 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`:
|
||||
|
||||
```php
|
||||
// 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`.
|
||||
|
||||
```php
|
||||
// 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()` و ساخت داده).
|
||||
|
||||
سناریوهای الزامی:
|
||||
|
||||
```php
|
||||
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 کند، نه روی جزئیات داخلی:
|
||||
|
||||
```php
|
||||
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 مصرف دیگری ندارد.
|
||||
|
||||
```php
|
||||
// قبل
|
||||
$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()`.
|
||||
|
||||
```php
|
||||
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()` را طوری بازنویسی کن که بخش اول را از رزولور بگیرند و فقط بخش دوم را خودشان حساب کنند.
|
||||
|
||||
```php
|
||||
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`](../../src/Appointment/Service/BookingContextResolver.php) همان تصمیم را برای مسیر رزرو میگیرد ولی خروجیاش `?Clinic` است، نه `EntityContext`:
|
||||
|
||||
```php
|
||||
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` را برگرداند:
|
||||
|
||||
```bash
|
||||
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 §۴).
|
||||
@@ -0,0 +1,348 @@
|
||||
# فاز ۲ — نشانهگذاری tenant روی جدولهای نوبتدهی
|
||||
|
||||
> پرامپت دوم از سری پنجتایی. **پیشنیاز: `tenant-01-entity-context-unify.md` باید کامل و سبز شده باشد.**
|
||||
> ۱. `tenant-01-entity-context-unify.md` → **۲. نشانهگذاری جدولهای نوبت (همین فایل)** → ۳. `tenant-03-unify-owner-columns.md` → ۴. `tenant-04-enforce-tenant-filter.md` → ۵. `tenant-05-audit-and-docs.md`
|
||||
|
||||
## زمینه
|
||||
|
||||
پس از فاز ۱، تشخیص محیط یک نقطه دارد و `EntityContext::toEntityPair()` جفت `(entity_type, entity_id)` را برمیگرداند. اما جدولهای هستهٔ نوبتدهی هنوز این جفت را **ذخیره نمیکنند** — آنها با `doctor_id` اجباری و `clinic_id` تهیپذیر کار میکنند:
|
||||
|
||||
```php
|
||||
// src/Appointment/Entity/Appointment.php
|
||||
#[ORM\ManyToOne(targetEntity: Doctor::class)]
|
||||
private Doctor $doctor; // اجباری
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: \App\Clinic\Entity\Clinic::class)]
|
||||
private ?\App\Clinic\Entity\Clinic $clinic = null; // NULL = مطب شخصی
|
||||
```
|
||||
|
||||
نتیجه: هر کوئریای که میخواهد «دادهٔ این محیط» را بگیرد، باید خودش `clinic_id IS NULL ? doctor : clinic` را بازسازی کند. همین بازسازی است که در فاز ۱ بهعنوان تکرار منطق حذف شد — ولی در **لایهٔ کوئری** هنوز باقی است و فاز ۴ (فیلتر خودکار Doctrine) بدون ستون واقعی روی این جدولها اصلاً کار نمیکند.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
**مشکل:** `appointments`، `weekly_schedules` و `date_overrides` محیط خود را بهصورت ضمنی و با دو ستون متفاوت نگه میدارند؛ `weekly_schedules` و `date_overrides` برای رفع مشکل یکتاییِ `NULL` مجبور به یک ستون تولیدشدهٔ مخصوص MariaDB شدهاند:
|
||||
|
||||
```php
|
||||
// src/Appointment/Entity/WeeklySchedule.php و DateOverride.php
|
||||
#[ORM\Column(name: 'clinic_key', type: 'integer', insertable: false, updatable: false,
|
||||
generated: 'ALWAYS', columnDefinition: 'INT AS (IFNULL(clinic_id, 0)) STORED')]
|
||||
private int $clinicKey = 0;
|
||||
```
|
||||
|
||||
**هدف:** جفت `(entity_type, entity_id)` روی این سه جدول ذخیره شود، ایندکسها با tenant بهعنوان ستون پیشرو بازسازی شوند، و هک `clinic_key` حذف شود.
|
||||
|
||||
### ⚠️ دو چیزی که عمداً تغییر نمیکنند
|
||||
|
||||
این دو مورد در کد فعلی دلیل مکتوب دارند و **نباید** به بهانهٔ یکسانسازی خراب شوند:
|
||||
|
||||
**۱. کلید یکتایی اسلات، سطح پزشک میماند — نه سطح tenant.**
|
||||
|
||||
```php
|
||||
// src/Appointment/Entity/Appointment.php
|
||||
/**
|
||||
* کلید عمداً clinic ندارد و فقط doctor+slotStart است: برنامهٔ هفتگی هر محیط
|
||||
* جداست (WeeklySchedule با UNIQUE(doctor_id, clinic_key)) و میتواند با محیط
|
||||
* دیگر همپوشانی داشته باشد، ولی پزشک یک نفر است. افزودن clinic به کلید یعنی
|
||||
* اجازهٔ رزرو همزمان همان پزشک در مطب و کلینیک — نه رفع باگ.
|
||||
*/
|
||||
private function refreshActiveSlotKey(): void
|
||||
{
|
||||
$this->activeSlotKey = !$this->isReserve && in_array($this->status, self::SLOT_OCCUPYING_STATUSES, true)
|
||||
? sprintf('%d:%d', $this->doctor->getId(), $this->slotStart)
|
||||
: null;
|
||||
}
|
||||
```
|
||||
|
||||
افزودن tenant به `active_slot_key` یک باگ امنیتی-تجاری میسازد: همان پزشک همزمان در مطب شخصی و کلینیک رزرو میشود. **دست نزن.**
|
||||
|
||||
**۲. جدول `holidays` نشانهگذاری tenant نمیشود.**
|
||||
|
||||
```php
|
||||
// src/Appointment/Entity/Holiday.php
|
||||
/**
|
||||
* تعطیلی پزشک. برخلاف WeeklySchedule و DateOverride، تعطیلی پیشفرضاً سراسری است:
|
||||
* «پزشک آن روز نیست» یک واقعیت فیزیکی است و همزمان روی مطب شخصی و همهٔ کلینیکها
|
||||
* اثر میگذارد (clinic = null). مقدار غیر-NULL یعنی پزشک فقط در همان کلینیک نیست.
|
||||
*/
|
||||
```
|
||||
|
||||
اینجا `clinic = NULL` معنایش «مطب شخصی» **نیست**، معنایش «همهٔ محیطها» است. جفت `(entity_type, entity_id)` نمیتواند این را بیان کند و تبدیلش، تعطیلیهای سراسری را به تعطیلی مطب شخصی تنزل میدهد. `holidays` با همان `clinic_id` تهیپذیر میماند و در فاز ۴ در whitelist «جدولهای خارج از فیلتر tenant» قرار میگیرد.
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق: بعد از migration، برای هر ردیف `appointments` رابطهٔ زیر برقرار است — قابل تأیید با کوئری در «نحوه تست» وظیفهٔ ۲:
|
||||
`clinic_id IS NULL → (entity_type='doctor', entity_id=doctor_id)` و `clinic_id IS NOT NULL → (entity_type='clinic', entity_id=clinic_id)`.
|
||||
ثبت یک نوبت جدید از پنل (هم در محیط کلینیک، هم در محیط مطب شخصی) جفت را خودکار و درست پر میکند.
|
||||
- ❌ خطا: تلاش برای ذخیرهٔ `Appointment` بدون محیطِ حلشده (`EntityContext::unknown()`) → `AppException` با کد از `ErrorCodes`، نه `SQLSTATE` خام و نه ردیف با `entity_id = 0`.
|
||||
- ⚠️ مرزی: دو پزشک **متفاوت** در **یک کلینیک** هر کدام `WeeklySchedule` خودشان را دارند → هر دو با `(entity_type='clinic', entity_id=<همان clinic>)` ذخیره میشوند و یکتایی نباید نقض شود (به همین دلیل کلید یکتا باید `doctor_id` را هم داشته باشد — وظیفهٔ ۳). همچنین: نوبتی که `clinic_id` دارد ولی آن کلینیک بعداً حذف شده → migration نباید بشکند (وظیفهٔ ۲، بند «ردیفهای یتیم»).
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Appointment/Entity/Appointment.php` | افزودن جفت tenant + بازسازی ایندکسها |
|
||||
| `src/Appointment/Entity/WeeklySchedule.php` | افزودن جفت + حذف `clinic_key` + بازسازی یکتایی |
|
||||
| `src/Appointment/Entity/DateOverride.php` | همان کار `WeeklySchedule` |
|
||||
| `src/Appointment/Entity/Holiday.php` | **بدون تغییر** — فقط افزودن یادداشت استثنا در docblock |
|
||||
| `src/Shared/Context/EntityContext.php` | منبع جفت (`toEntityPair()`) |
|
||||
| `src/Appointment/Repository/AppointmentRepository.php` | کوئریهایی که با `clinic_id IS NULL` محیط را بازسازی میکنند |
|
||||
| `src/Appointment/Repository/WeeklyScheduleRepository.php` | کوئریهای مبتنی بر `clinic_key` |
|
||||
| `src/Appointment/Repository/DateOverrideRepository.php` | همان |
|
||||
| `migrations/` | migration جدید (schema + backfill) |
|
||||
| `tests/Appointment/SlotUniquenessTest.php` | تستهای موجود که نباید بشکنند |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
ایندکسهای فعلی `appointments` — هیچکدام tenant را نمیشناسند:
|
||||
|
||||
```php
|
||||
#[ORM\Entity(repositoryClass: AppointmentRepository::class)]
|
||||
#[ORM\Table(name: 'appointments')]
|
||||
#[ORM\Index(columns: ['doctor_id', 'slot_start'], name: 'idx_appointments_doctor_slot')]
|
||||
#[ORM\Index(columns: ['user_id', 'status'], name: 'idx_appointments_user_status')]
|
||||
#[ORM\Index(columns: ['status', 'expires_at'], name: 'idx_appointments_status_expires')]
|
||||
class Appointment
|
||||
{
|
||||
#[ORM\Column(name: 'active_slot_key', type: 'string', length: 64, nullable: true, unique: true)]
|
||||
private ?string $activeSlotKey = null;
|
||||
```
|
||||
|
||||
یکتایی فعلی `weekly_schedules` و `date_overrides` — وابسته به ستون تولیدشده:
|
||||
|
||||
```php
|
||||
#[ORM\Table(name: 'weekly_schedules')]
|
||||
#[ORM\UniqueConstraint(name: 'idx_weekly_schedules_doctor_clinic', columns: ['doctor_id', 'clinic_key'])]
|
||||
|
||||
#[ORM\Table(name: 'date_overrides')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_clinic_date', columns: ['doctor_id', 'clinic_key', 'date'])]
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. ساخت trait مشترک برای جفت tenant
|
||||
|
||||
سه entity همان دو ستون را میگیرند. بهجای کپی، یک trait در `src/Shared/Tenant/` بساز.
|
||||
|
||||
```php
|
||||
// src/Shared/Tenant/TenantOwnedTrait.php
|
||||
namespace App\Shared\Tenant;
|
||||
|
||||
use App\Shared\Context\EntityContext;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
/**
|
||||
* جفت مالکیتِ محیط — همان قراردادی که ServiceSection و PatientRecord از قبل دارند.
|
||||
* مقدارها فقط از EntityContext::toEntityPair() میآیند تا با فاز ۱ یک منبع بمانند.
|
||||
*/
|
||||
trait TenantOwnedTrait
|
||||
{
|
||||
#[ORM\Column(name: 'entity_type', type: 'string', length: 10)]
|
||||
private string $entityType;
|
||||
|
||||
#[ORM\Column(name: 'entity_id', type: 'integer')]
|
||||
private int $entityId;
|
||||
|
||||
public function getEntityType(): string { return $this->entityType; }
|
||||
public function getEntityId(): int { return $this->entityId; }
|
||||
|
||||
public function assignTenant(EntityContext $context): void
|
||||
{
|
||||
if (!$context->isResolved()) {
|
||||
throw new \InvalidArgumentException('Cannot assign an unresolved tenant context.');
|
||||
}
|
||||
|
||||
[$this->entityType, $this->entityId] = $context->toEntityPair();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
طول `length: 10` عمدی است — با `service_sections` و `clinic_staff` یکی است تا کوئریهای JOIN بین جدولها collation mismatch ندهند. (`tenant_tags` طول ۲۰ دارد؛ آن ناهماهنگی در فاز ۳ رسیدگی میشود، اینجا نه.)
|
||||
|
||||
**نحوه تست:** `ddev exec php vendor/bin/phpstan analyse src/Shared/Tenant` بدون خطا؛ و تست واحد که `assignTenant(EntityContext::unknown())` استثنا پرتاب کند.
|
||||
|
||||
---
|
||||
|
||||
### ۲. افزودن جفت به `Appointment` + backfill
|
||||
|
||||
trait را به entity اضافه کن و در سازنده از `EntityContext` پرش کن. `doctor` و `clinic` **حذف نمیشوند** — جفت جدید یک denormalization عمدی است، نه جایگزین: `doctor_id` هنوز برای یکتایی اسلات و تقویم لازم است.
|
||||
|
||||
migration دو مرحلهای بنویس (schema سپس backfill):
|
||||
|
||||
```sql
|
||||
-- گام ۱: ستونها تهیپذیر اضافه شوند
|
||||
ALTER TABLE appointments
|
||||
ADD entity_type VARCHAR(10) NULL,
|
||||
ADD entity_id INT NULL;
|
||||
|
||||
-- گام ۲: backfill — کلینیک اولویت دارد، در نبودش مطب شخصی
|
||||
UPDATE appointments
|
||||
SET entity_type = IF(clinic_id IS NULL, 'doctor', 'clinic'),
|
||||
entity_id = IFNULL(clinic_id, doctor_id);
|
||||
|
||||
-- گام ۳: ردیفهای یتیم (clinic_id به کلینیک حذفشده اشاره میکند)
|
||||
UPDATE appointments a
|
||||
LEFT JOIN clinics c ON c.id = a.clinic_id
|
||||
SET a.entity_type = 'doctor', a.entity_id = a.doctor_id
|
||||
WHERE a.clinic_id IS NOT NULL AND c.id IS NULL;
|
||||
|
||||
-- گام ۴: تأیید صفر بودن باقیمانده — اگر صفر نبود migration باید fail کند
|
||||
-- SELECT COUNT(*) FROM appointments WHERE entity_type IS NULL OR entity_id IS NULL;
|
||||
|
||||
-- گام ۵: تازه حالا NOT NULL
|
||||
ALTER TABLE appointments
|
||||
MODIFY entity_type VARCHAR(10) NOT NULL,
|
||||
MODIFY entity_id INT NOT NULL;
|
||||
```
|
||||
|
||||
در متد `up()` migration، بین گام ۴ و ۵ یک `abortIf()` بگذار تا اگر ردیف پر نشدهای ماند، migration بهجای ساختن دادهٔ خراب متوقف شود:
|
||||
|
||||
```php
|
||||
$remaining = (int) $this->connection->fetchOne(
|
||||
'SELECT COUNT(*) FROM appointments WHERE entity_type IS NULL OR entity_id IS NULL'
|
||||
);
|
||||
$this->abortIf($remaining > 0, "Backfill left {$remaining} appointments without a tenant.");
|
||||
```
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:diff --no-interaction
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
# صحت backfill — هر سه کوئری باید 0 برگردانند
|
||||
ddev exec php bin/console dbal:run-sql "SELECT COUNT(*) FROM appointments WHERE entity_type IS NULL"
|
||||
ddev exec php bin/console dbal:run-sql "SELECT COUNT(*) FROM appointments WHERE clinic_id IS NULL AND (entity_type<>'doctor' OR entity_id<>doctor_id)"
|
||||
ddev exec php bin/console dbal:run-sql "SELECT COUNT(*) FROM appointments a JOIN clinics c ON c.id=a.clinic_id WHERE a.entity_type<>'clinic' OR a.entity_id<>a.clinic_id"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ۳. `WeeklySchedule` و `DateOverride` — افزودن جفت و حذف `clinic_key`
|
||||
|
||||
اینجا جفت tenant **جایگزین** `clinic_key` میشود، ولی `doctor_id` در کلید یکتا میماند.
|
||||
|
||||
**چرا `doctor_id` باید در کلید بماند:** در یک کلینیک چند پزشک وجود دارد و هر کدام برنامهٔ هفتگی خودش را دارد. اگر کلید فقط `(entity_type, entity_id)` باشد، پزشک دوم همان کلینیک با نقض یکتایی مواجه میشود. این همان حالت مرزی معیار پذیرش است.
|
||||
|
||||
```php
|
||||
#[ORM\Table(name: 'weekly_schedules')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_weekly_schedule_doctor_tenant',
|
||||
columns: ['doctor_id', 'entity_type', 'entity_id'])]
|
||||
|
||||
#[ORM\Table(name: 'date_overrides')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_date_override_doctor_tenant_date',
|
||||
columns: ['doctor_id', 'entity_type', 'entity_id', 'date'])]
|
||||
```
|
||||
|
||||
migration:
|
||||
|
||||
```sql
|
||||
ALTER TABLE weekly_schedules ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL;
|
||||
UPDATE weekly_schedules
|
||||
SET entity_type = IF(clinic_id IS NULL, 'doctor', 'clinic'),
|
||||
entity_id = IFNULL(clinic_id, doctor_id);
|
||||
-- abortIf روی باقیماندهٔ NULL
|
||||
ALTER TABLE weekly_schedules
|
||||
MODIFY entity_type VARCHAR(10) NOT NULL,
|
||||
MODIFY entity_id INT NOT NULL;
|
||||
|
||||
DROP INDEX idx_weekly_schedules_doctor_clinic ON weekly_schedules;
|
||||
CREATE UNIQUE INDEX uniq_weekly_schedule_doctor_tenant
|
||||
ON weekly_schedules (doctor_id, entity_type, entity_id);
|
||||
ALTER TABLE weekly_schedules DROP COLUMN clinic_key;
|
||||
```
|
||||
|
||||
**ترتیب حیاتی:** `clinic_key` فقط بعد از ساخته شدن ایندکس جدید حذف شود، وگرنه بین دو دستور، جدول بدون هیچ محافظ یکتایی میماند. همین ترتیب برای `date_overrides`.
|
||||
|
||||
`clinic_id` را نگه دار — چند نقطهٔ کد (از جمله `AssignScheduleClinicCommand` و کوئریهای `SlotCalculatorService`) هنوز مستقیم به آن وابستهاند و حذفش دامنهٔ این فاز را میترکاند. حذفش کار فاز ۵ است، بعد از اینکه فاز ۴ ثابت کرد جفت tenant همهجا کافی است.
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
|
||||
ddev exec php bin/phpunit tests/Appointment/
|
||||
# حالت مرزی: دو پزشک در یک کلینیک
|
||||
ddev exec php bin/console dbal:run-sql "SELECT entity_type, entity_id, COUNT(DISTINCT doctor_id) d FROM weekly_schedules GROUP BY 1,2 HAVING d > 1"
|
||||
# ↑ باید حداقل یک ردیف برگرداند (یعنی چند پزشک در یک کلینیک) و migration نشکسته باشد
|
||||
ddev exec php bin/console dbal:run-sql "SHOW COLUMNS FROM weekly_schedules LIKE 'clinic_key'" # خالی
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ۴. بازسازی ایندکسهای `appointments` با tenant پیشرو
|
||||
|
||||
هر ایندکسی که برای کوئریهای «لیست نوبتهای این محیط» استفاده میشود باید tenant را بهعنوان **ستون اول** داشته باشد، وگرنه MariaDB نمیتواند از آن برای فیلتر tenant استفاده کند.
|
||||
|
||||
```php
|
||||
#[ORM\Table(name: 'appointments')]
|
||||
// tenant پیشرو — برای لیستهای پنل که همیشه محیطمحورند
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'slot_start'], name: 'idx_appointments_tenant_slot')]
|
||||
#[ORM\Index(columns: ['entity_type', 'entity_id', 'status'], name: 'idx_appointments_tenant_status')]
|
||||
// بدون tenant — عمدی: یکتایی و تقویم سطح پزشکاند، نه محیط
|
||||
#[ORM\Index(columns: ['doctor_id', 'slot_start'], name: 'idx_appointments_doctor_slot')]
|
||||
#[ORM\Index(columns: ['user_id', 'status'], name: 'idx_appointments_user_status')]
|
||||
#[ORM\Index(columns: ['status', 'expires_at'], name: 'idx_appointments_status_expires')]
|
||||
```
|
||||
|
||||
`idx_appointments_doctor_slot` را **حذف نکن** — `AppointmentRepository::isSlotTaken()` و `SlotCalculatorService` روی آن کوئری میزنند و آن کوئریها ذاتاً سطح پزشکاند.
|
||||
|
||||
بعد از migration، پلن کوئری لیست نوبتها را بررسی کن که واقعاً ایندکس جدید را انتخاب میکند.
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
ddev exec php bin/console dbal:run-sql "EXPLAIN SELECT * FROM appointments WHERE entity_type='clinic' AND entity_id=1 AND slot_start > UNIX_TIMESTAMP() ORDER BY slot_start LIMIT 20"
|
||||
# ستون key باید idx_appointments_tenant_slot باشد، نه NULL و نه ALL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ۵. اتصال نقطهٔ نوشتن به `EntityContext`
|
||||
|
||||
هر جایی که `Appointment`، `WeeklySchedule` یا `DateOverride` **ساخته** میشود باید `assignTenant()` را با `EntityContext` صدا بزند. نقاط ساخت را با این دستور پیدا کن:
|
||||
|
||||
```bash
|
||||
ddev exec grep -rn "new Appointment(\|new WeeklySchedule(\|new DateOverride(" src --include="*.php"
|
||||
```
|
||||
|
||||
برای مسیر رزرو عمومی (بیمار، نه کاربر پنل)، محیط از `BookingContextResolver` میآید که `?Clinic` برمیگرداند؛ تبدیلش:
|
||||
|
||||
```php
|
||||
$context = $clinic !== null
|
||||
? EntityContext::forClinic($clinic)
|
||||
: EntityContext::forDoctor($doctor);
|
||||
|
||||
$appointment->assignTenant($context);
|
||||
```
|
||||
|
||||
**هیچ مقدار پیشفرضی نگذار.** اگر محیط حل نشد، `AppException` با کد از `ErrorCodes` پرتاب شود — نه `entity_id = 0` و نه حدس زدن. این همان معیار پذیرش ❌ است.
|
||||
|
||||
**نحوه تست:** ثبت نوبت واقعی از پنل با کاربر تست (`clinicpro-qa-accounts`) در هر دو محیط، سپس:
|
||||
```bash
|
||||
ddev exec php bin/console dbal:run-sql "SELECT id, doctor_id, clinic_id, entity_type, entity_id FROM appointments ORDER BY id DESC LIMIT 5"
|
||||
```
|
||||
جفت باید با محیطی که نوبت در آن ثبت شده بخواند.
|
||||
|
||||
---
|
||||
|
||||
### ۶. یادداشت استثنا روی `Holiday`
|
||||
|
||||
`holidays` نشانهگذاری نمیشود. برای اینکه فاز ۴ (تست ساختاری روی همهٔ entityها) این را استثنای عمدی بشناسد نه فراموشی، در docblock کلاس یادداشت بگذار:
|
||||
|
||||
```php
|
||||
/**
|
||||
* ...docblock موجود...
|
||||
*
|
||||
* عمداً جفت (entity_type, entity_id) ندارد: در این جدول clinic = NULL یعنی
|
||||
* «همهٔ محیطها»، نه «مطب شخصی». جفت tenant نمیتواند «همه» را بیان کند و
|
||||
* تبدیلش، تعطیلی سراسری را به تعطیلی مطب شخصی تنزل میدهد.
|
||||
* در whitelist فاز ۴ (TenantFilter) ثبت شده است.
|
||||
*/
|
||||
```
|
||||
|
||||
**نحوه تست:** بازبینی دستی؛ فاز ۴ این یادداشت را به whitelist تبدیل میکند.
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **الگو: denormalization عمدی + trait مشترک.** جفت tenant روی `appointments` تکرار اطلاعاتی است که از `clinic_id`/`doctor_id` قابل استخراج بود. دلیل پذیرش تکرار: فیلتر خودکار فاز ۴ و ایندکس tenant-پیشرو، هر دو به یک ستون واقعی نیاز دارند و با شرط `IF(clinic_id IS NULL, ...)` قابل ساخت نیستند. trait بهجای کلاس پایه انتخاب شده چون این سه entity هیچ رفتار مشترک دیگری ندارند و ارثبری Doctrine هزینهٔ نقشهبرداری اضافه میکند.
|
||||
- **`active_slot_key` را دست نزن** (بخش «دو چیزی که عمداً تغییر نمیکنند»). اگر تستی از تو خواست آن را عوض کنی، تست غلط است نه کد.
|
||||
- **`clinic_id` و `doctor_id` حذف نمیشوند.** این فاز فقط اضافه میکند. حذف ستونهای قدیمی بعد از اثبات کفایت جفت tenant در فاز ۵ بررسی میشود.
|
||||
- **ترتیب migration:** ستون تهیپذیر → backfill → `abortIf` روی باقیماندهٔ NULL → `NOT NULL` → ایندکس جدید → حذف ایندکس/ستون قدیمی. هر جابهجایی در این ترتیب یعنی پنجرهای که جدول بدون محافظ یکتایی میماند.
|
||||
- **قبل از migrate روی دادهٔ واقعی، بکاپ بگیر:** `ddev export-db --file=/tmp/pre-tenant-phase2.sql.gz`. این migration دادهٔ موجود را مینویسد و rollback خودکار ندارد.
|
||||
- **کلاینتهای متأثر:** `entity_type`/`entity_id` نباید در پاسخ API ظاهر شوند مگر جایی که از قبل ظاهر میشدند. اگر `toArray()` نوبت را عوض کردی، `nobat724_front/services/response.js` و `clinic-pro-tauri/src/service/response.js` باید دستی بررسی شوند — build آنها خطا نمیدهد.
|
||||
- **مستندات:** اگر شکل پاسخ نوبت تغییر کرد، `docs/api/appointment.md` همان جلسه بهروز شود با JSON واقعیِ خروجی اجرا، نه دستساز (guidelines §۴).
|
||||
@@ -0,0 +1,366 @@
|
||||
# فاز ۳ — یکسانسازی نمایشهای ناهمگون 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 واقعی بهروز شود.
|
||||
@@ -0,0 +1,416 @@
|
||||
# فاز ۴ — اجباریکردن جداسازی tenant در لایهٔ Doctrine
|
||||
|
||||
> پرامپت چهارم از سری پنجتایی. **پیشنیاز: فازهای ۱ تا ۳ کامل و سبز.**
|
||||
> ۱. `tenant-01-entity-context-unify.md` → ۲. `tenant-02-mark-booking-tables.md` → ۳. `tenant-03-unify-owner-columns.md` → **۴. اجبار فیلتر (همین فایل)** → ۵. `tenant-05-audit-and-docs.md`
|
||||
|
||||
## زمینه
|
||||
|
||||
بعد از فاز ۳، هر جدولِ tenant-دار یک جفت `(entity_type, entity_id)` با املا و طول یکسان دارد. اما جداسازی هنوز **اختیاری** است: هر کوئری باید خودش `WHERE entity_type = ... AND entity_id = ...` بگذارد. یک `WHERE` فراموششده در یک repository = نشت دادهٔ پزشکی بین دو کلینیک.
|
||||
|
||||
MariaDB معادل Row Level Security پستگرس را ندارد، پس این تضمین باید در لایهٔ ORM ساخته شود. Doctrine برای همین `SQLFilter` دارد: شرطی که خودکار به هر کوئری DQL روی entityهای هدف چسبانده میشود.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
**مشکل:** جداسازی tenant به یادآوری برنامهنویس وابسته است. با ۱۱۸ entity و ۱۴۸ فایل تست، تضمین اینکه هیچکس هرگز فراموش نکند، ممکن نیست.
|
||||
|
||||
**هدف:** فیلتر خودکار Doctrine + listener تخصیص خودکار + یک تست ساختاری که جلوی رگرسیون آینده را بگیرد.
|
||||
|
||||
### آنچه فیلتر تضمین میکند و آنچه نمیکند
|
||||
|
||||
این جدول باید در docblock فیلتر هم نوشته شود — امنیت کاذب خطرناکتر از نبود امنیت است:
|
||||
|
||||
| مسیر | فیلتر اعمال میشود؟ | چه باید کرد |
|
||||
|---|---|---|
|
||||
| DQL و QueryBuilder | ✅ | — |
|
||||
| بارگذاری تنبل کالکشنها (`OneToMany`/`ManyToMany`) | ✅ | — |
|
||||
| `EntityManager::find($class, $id)` | ❌ **نه** | دریافت با DQL از repository، یا چک صریح tenant بعد از `find()` |
|
||||
| `getReference()` | ❌ نه | همان بالا |
|
||||
| entity که از قبل در identity map است | ❌ نه | آگاه باش |
|
||||
| SQL خام DBAL (۸ فایل پروژه) | ❌ نه | `WHERE` دستی — فاز ۵ |
|
||||
|
||||
پس فیلتر **تور ایمنی** است، نه جایگزین `AppointmentAccessChecker` و `PatientRecordScopeResolver`. آنها باقی میمانند.
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق: با توکن مالک کلینیک A، `GET /api/v1/patients`، `GET /api/v1/inventory/items`، `GET /api/v1/discounts` و `GET /api/v1/clinic-services/sections` هیچ رکورد متعلق به کلینیک B یا به مطب شخصی هیچ پزشکی برنمیگردانند — حتی اگر `WHERE` دستی از کوئری repository حذف شود. (این آخری معیار واقعی است: در تست، فیلتر باید بهتنهایی کافی باشد.)
|
||||
- ❌ خطا: ساخت entity از یک entityِ tenant-دار بدون محیط حلشده (مثلاً از داخل messenger consumer که کاربر ندارد) → استثنای صریح با پیام قابل فهم، نه ردیف با `entity_id = 0` و نه خطای `SQLSTATE[23000]` خام.
|
||||
- ⚠️ مرزی: endpointهای عمومی سایت (`nobat724_front`) که عمداً cross-tenant هستند — جستجوی پزشک، صفحهٔ پزشک، لیست شهرها — همچنان دادهٔ همهٔ کلینیکها را برمیگردانند و فیلتر آنها را خفه نمیکند. تست این حالت اجباری است، وگرنه سایت عمومی بیصدا خالی میشود.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Shared/Tenant/TenantFilter.php` | فیلتر SQL — جدید |
|
||||
| `src/Shared/Tenant/TenantAssignmentListener.php` | تخصیص خودکار در `prePersist` — جدید |
|
||||
| `src/Shared/Tenant/TenantFilterSubscriber.php` | فعالسازی فیلتر در `kernel.request` — جدید |
|
||||
| `src/Shared/Tenant/GlobalTables.php` | whitelist جدولهای خارج از tenant — جدید |
|
||||
| `src/Shared/Tenant/TenantOwnedTrait.php` | از فاز ۲ |
|
||||
| `src/Shared/Context/EntityContextResolver.php` | منبع محیط |
|
||||
| `config/packages/doctrine.yaml` | ثبت فیلتر |
|
||||
| `tests/Shared/TenantIsolationMatrixTest.php` | از فاز ۳ — باید سبز بماند |
|
||||
| `tests/Shared/TenantSchemaCoverageTest.php` | تست ساختاری — جدید |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
`doctrine.yaml` فعلاً هیچ فیلتری ثبت نکرده — فقط دو تابع DQL برای JSON:
|
||||
|
||||
```yaml
|
||||
# config/packages/doctrine.yaml
|
||||
doctrine:
|
||||
orm:
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||
auto_mapping: true
|
||||
mappings:
|
||||
App:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src'
|
||||
prefix: 'App'
|
||||
alias: App
|
||||
dql:
|
||||
string_functions:
|
||||
JSON_CONTAINS: App\Shared\Doctrine\JsonContains
|
||||
JSON_EXTRACT: App\Shared\Doctrine\JsonExtract
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. whitelist جدولهای سراسری
|
||||
|
||||
قبل از فیلتر، فهرست صریحِ چیزهایی که **نباید** tenant داشته باشند لازم است. بدون این فهرست، تست ساختاری وظیفهٔ ۵ قابل نوشتن نیست.
|
||||
|
||||
```php
|
||||
// src/Shared/Tenant/GlobalTables.php
|
||||
namespace App\Shared\Tenant;
|
||||
|
||||
/**
|
||||
* Entityهایی که عمداً به هیچ tenant تعلق ندارند. هر افزودهای به این فهرست
|
||||
* باید دلیل داشته باشد — این فهرست تنها راه فرار از تست پوشش tenant است.
|
||||
*/
|
||||
final class GlobalTables
|
||||
{
|
||||
/** @var array<class-string, string> کلاس => دلیل */
|
||||
public const ENTITIES = [
|
||||
// دادهٔ مرجع مشترک بین همهٔ tenantها
|
||||
\App\Category\Entity\Category::class => 'تاکسونومی سراسری (استان/شهر/تخصص/بیمه)',
|
||||
\App\Location\Entity\Province::class => 'دادهٔ جغرافیایی کشور',
|
||||
\App\Location\Entity\City::class => 'دادهٔ جغرافیایی کشور',
|
||||
\App\Specialty\Entity\Specialty::class => 'تاکسونومی سراسری تخصصها',
|
||||
\App\Insurance\Entity\Insurance::class => 'فهرست بیمههای کشور',
|
||||
\App\Tag\Entity\Tag::class => 'تاکسونومی سراسری (در برابر TenantTag که tenant دارد)',
|
||||
|
||||
// هویت — بیمار میتواند مشتری چند tenant باشد
|
||||
\App\Auth\Entity\User::class => 'هویت سراسری؛ رابطهٔ بیمار↔محیط از patient_records میآید',
|
||||
\App\UserProfile\Entity\UserProfile::class => 'پروفایل کاربر، نه دادهٔ محیط',
|
||||
|
||||
// محتوای عمومی سایت
|
||||
\App\Blog\Entity\Blog::class => 'محتوای عمومی مارکتپلیس',
|
||||
|
||||
// خودِ موجودیتهای tenant — نمیتوانند به خودشان اشاره کنند
|
||||
\App\Doctor\Entity\Doctor::class => 'خودش یک tenant است',
|
||||
\App\Clinic\Entity\Clinic::class => 'خودش یک tenant است',
|
||||
|
||||
// استثناهای مستند در فازهای قبلی
|
||||
\App\Appointment\Entity\Holiday::class => 'clinic=NULL یعنی «همهٔ محیطها»، نه مطب شخصی — فاز ۲',
|
||||
\App\Auth\Entity\MobileVerificationOtp::class => 'entity_type دارد ولی معنایش tenant نیست — فاز ۳',
|
||||
\App\Auth\Entity\UserActiveContext::class => 'خودش تعیینکنندهٔ محیط است',
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
**این فهرست را از روی این پرامپت کورکورانه کپی نکن.** هر کلاس را باز کن و تأیید کن که واقعاً بیtenant است. اگر entityای پیدا کردی که نه tenant دارد و نه در این فهرست جا دارد (مثل `payments`، `settlements`، `financial_breakdowns`، `claim_items`)، **آن را در گزارش بهعنوان کار باقیمانده اعلام کن** و در فهرست موقت با دلیل `'TODO فاز بعدی — هنوز طبقهبندی نشده'` بگذار. جدولهای مالی عمداً در دامنهٔ این سری نیستند چون مالکیتشان (پرداختکننده در برابر دریافتکننده) تحلیل جداگانه میخواهد.
|
||||
|
||||
**نحوه تست:** بازبینی دستی + خروجی وظیفهٔ ۵.
|
||||
|
||||
---
|
||||
|
||||
### ۲. `TenantFilter`
|
||||
|
||||
```php
|
||||
// src/Shared/Tenant/TenantFilter.php
|
||||
namespace App\Shared\Tenant;
|
||||
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
use Doctrine\ORM\Query\Filter\SQLFilter;
|
||||
|
||||
/**
|
||||
* جداسازی خودکار محیط روی هر کوئری DQL.
|
||||
*
|
||||
* تور ایمنی است، نه جایگزین authorization: روی EntityManager::find()،
|
||||
* getReference()، entityهای موجود در identity map و SQL خام DBAL اعمال نمیشود.
|
||||
* checkerهای دامنه (AppointmentAccessChecker، PatientRecordScopeResolver) سر جایشان میمانند.
|
||||
*/
|
||||
final class TenantFilter extends SQLFilter
|
||||
{
|
||||
public const NAME = 'tenant';
|
||||
|
||||
public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
|
||||
{
|
||||
if (!$targetEntity->hasField('entityType') || !$targetEntity->hasField('entityId')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return sprintf(
|
||||
'%s.entity_type = %s AND %s.entity_id = %s',
|
||||
$targetTableAlias,
|
||||
$this->getParameter('entity_type'),
|
||||
$targetTableAlias,
|
||||
$this->getParameter('entity_id'),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`getParameter()` خودش مقدار را escape میکند؛ هرگز مقدار خام را داخل رشته نگذار.
|
||||
|
||||
ثبت در پیکربندی — **پیشفرض خاموش**:
|
||||
|
||||
```yaml
|
||||
# config/packages/doctrine.yaml
|
||||
doctrine:
|
||||
orm:
|
||||
filters:
|
||||
tenant:
|
||||
class: App\Shared\Tenant\TenantFilter
|
||||
enabled: false
|
||||
```
|
||||
|
||||
`enabled: false` عمدی است: فیلتر فقط وقتی روشن میشود که محیط حل شده باشد. بدون این، مسیرهای عمومی و کامندهای کنسول میشکنند.
|
||||
|
||||
**نحوه تست:** `ddev exec php bin/console debug:container --parameter=doctrine.orm.filters` یا صرفاً بالا آمدن کانتینر بدون خطا؛ تست واقعی در وظیفهٔ ۳.
|
||||
|
||||
---
|
||||
|
||||
### ۳. فعالسازی فیلتر در ابتدای هر درخواست
|
||||
|
||||
```php
|
||||
// src/Shared/Tenant/TenantFilterSubscriber.php
|
||||
namespace App\Shared\Tenant;
|
||||
|
||||
use App\Shared\Context\EntityContextResolver;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelEvents;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* فیلتر tenant را فقط وقتی روشن میکند که کاربر احراز شده و محیطش حل شود.
|
||||
*
|
||||
* مسیرهای عمومی مارکتپلیس کاربر پنل ندارند، پس فیلتر خاموش میماند و
|
||||
* جستجوی cross-tenant سایت دستنخورده کار میکند — این رفتار عمدی است.
|
||||
*/
|
||||
final class TenantFilterSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
private readonly EntityManagerInterface $em,
|
||||
) {}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
// بعد از فایروال (که روی ۸ اجرا میشود) تا توکن در دسترس باشد
|
||||
return [KernelEvents::REQUEST => ['onRequest', 5]];
|
||||
}
|
||||
|
||||
public function onRequest(RequestEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
if (!$user instanceof \App\Auth\Entity\User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = $this->contextResolver->tryResolve($user);
|
||||
if ($context === null || !$context->isResolved()) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$type, $id] = $context->toEntityPair();
|
||||
|
||||
$this->em->getFilters()
|
||||
->enable(TenantFilter::NAME)
|
||||
->setParameter('entity_type', $type, 'string')
|
||||
->setParameter('entity_id', $id, 'integer');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`tryResolve()` (نه `resolve()`) استفاده میشود تا محیطِ حلنشده استثنا پرتاب نکند و درخواست را در همان `kernel.request` نکشد.
|
||||
|
||||
**⚠️ نکتهٔ ادمین:** کاربر `ROLE_ADMIN` نباید فیلتر بخورد — پنل ادمین ذاتاً cross-tenant است. قبل از resolve بررسی کن:
|
||||
|
||||
```php
|
||||
if ($user->hasRole('ROLE_ADMIN')) {
|
||||
return; // ادمین سراسری است
|
||||
}
|
||||
```
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
ddev exec php bin/phpunit tests/Shared/TenantIsolationMatrixTest.php # از فاز ۳ — باید سبز بماند
|
||||
```
|
||||
و تست جدید نشتی که فیلتر را بهتنهایی میسنجد:
|
||||
|
||||
```php
|
||||
// tests/Shared/TenantFilterLeakTest.php
|
||||
public function testFilterAloneBlocksCrossTenantRowsWithoutManualWhere(): void
|
||||
{
|
||||
// دو tenant با داده بساز، فیلتر را با tenant اول روشن کن،
|
||||
// یک DQL بدون هیچ WHERE tenant بزن → فقط ردیفهای tenant اول برگردند
|
||||
}
|
||||
|
||||
public function testPublicRouteWithoutAuthenticatedUserIsNotFiltered(): void
|
||||
{
|
||||
// GET یک endpoint عمومی پزشکان → دادههای چند tenant برگردد
|
||||
}
|
||||
|
||||
public function testAdminIsNotFiltered(): void;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ۴. تخصیص خودکار محیط هنگام درج
|
||||
|
||||
فراموش کردن `assignTenant()` هنگام ساخت entity، ردیفی میسازد که فیلتر بعداً آن را از دید همه پنهان میکند — بدترین نوع باگ چون خطا نمیدهد.
|
||||
|
||||
```php
|
||||
// src/Shared/Tenant/TenantAssignmentListener.php
|
||||
namespace App\Shared\Tenant;
|
||||
|
||||
use App\Shared\Context\EntityContextResolver;
|
||||
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
|
||||
use Doctrine\ORM\Event\PrePersistEventArgs;
|
||||
use Doctrine\ORM\Events;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* اگر entityِ tenant-دار بدون محیط ذخیره شود، محیط جاری را مینشاند؛
|
||||
* اگر محیطی هم نبود، صریح استثنا میدهد. ردیف بیtenant هرگز نباید ساخته شود:
|
||||
* فیلتر فاز ۴ آن را از دید همه پنهان میکند بدون اینکه خطایی دیده شود.
|
||||
*/
|
||||
#[AsDoctrineListener(event: Events::prePersist)]
|
||||
final class TenantAssignmentListener
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Security $security,
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
) {}
|
||||
|
||||
public function prePersist(PrePersistEventArgs $args): void
|
||||
{
|
||||
$entity = $args->getObject();
|
||||
if (!in_array(TenantOwnedTrait::class, class_uses($entity) ?: [], true)) {
|
||||
return;
|
||||
}
|
||||
if ($entity->getEntityId() !== 0 && $entity->getEntityType() !== '') {
|
||||
return; // خودِ سرویس مقدار داده — دست نزن
|
||||
}
|
||||
|
||||
$user = $this->security->getUser();
|
||||
$context = $user instanceof \App\Auth\Entity\User
|
||||
? $this->contextResolver->tryResolve($user)
|
||||
: null;
|
||||
|
||||
if ($context === null || !$context->isResolved()) {
|
||||
throw new \LogicException(sprintf(
|
||||
'Entity %s requires a tenant but none is set and no context could be resolved.',
|
||||
$entity::class,
|
||||
));
|
||||
}
|
||||
|
||||
$entity->assignTenant($context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`class_uses()` فقط traitهای مستقیم را برمیگرداند. اگر جایی ارثبری وارد شد، از `class_uses_recursive` معادل خودت استفاده کن — ولی الان هیچکدام از entityهای tenant-دار پدر ندارند، پس سادگی درست است.
|
||||
|
||||
**⚠️ مسیرهای بدون کاربر:** messenger consumerها و کامندهای کنسول کاربر ندارند. هر کدام که entityِ tenant-دار میسازند باید **خودشان** `assignTenant()` را صدا بزنند. آنها را پیدا کن:
|
||||
|
||||
```bash
|
||||
ddev exec grep -rln "TenantOwnedTrait" src --include="*.php" | xargs -I{} basename {} .php
|
||||
ddev exec grep -rn "new \(Appointment\|DiscountRule\|ServiceSection\|PatientRecord\|InventoryItem\)(" src/*/Command src/*/MessageHandler --include="*.php"
|
||||
```
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
ddev exec php bin/phpunit tests/
|
||||
ddev exec php bin/console messenger:consume async --limit=1 -vv # نباید LogicException بدهد
|
||||
ddev exec php bin/console app:cancel-expired-appointments --dry-run 2>/dev/null || true
|
||||
```
|
||||
بهعلاوه تست واحد: `persist` یک `DiscountRule` بدون tenant و بدون کاربر → `LogicException`.
|
||||
|
||||
---
|
||||
|
||||
### ۵. تست پوشش ساختاری — مهمترین خروجی این فاز
|
||||
|
||||
بدون این تست، فاز ۴ فقط وضعیت امروز را درست میکند؛ با آن، entity جدیدی که فردا کسی بسازد و tenant یادش برود، در CI قرمز میشود.
|
||||
|
||||
```php
|
||||
// tests/Shared/TenantSchemaCoverageTest.php
|
||||
namespace App\Tests\Shared;
|
||||
|
||||
use App\Shared\Tenant\GlobalTables;
|
||||
use App\Shared\Tenant\TenantOwnedTrait;
|
||||
|
||||
final class TenantSchemaCoverageTest extends ApiTestCase
|
||||
{
|
||||
/**
|
||||
* هر entity یا tenant دارد یا با دلیل در GlobalTables ثبت شده.
|
||||
* شکست این تست یعنی entity جدیدی اضافه شده و طبقهبندی نشده — نه اینکه تست خراب است.
|
||||
*/
|
||||
public function testEveryEntityIsEitherTenantScopedOrExplicitlyGlobal(): void
|
||||
{
|
||||
$em = static::getContainer()->get('doctrine')->getManager();
|
||||
$unclassified = [];
|
||||
|
||||
foreach ($em->getMetadataFactory()->getAllMetadata() as $meta) {
|
||||
$class = $meta->getName();
|
||||
|
||||
if (isset(GlobalTables::ENTITIES[$class])) {
|
||||
continue;
|
||||
}
|
||||
if (in_array(TenantOwnedTrait::class, class_uses($class) ?: [], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$unclassified[] = $class;
|
||||
}
|
||||
|
||||
self::assertSame([], $unclassified, sprintf(
|
||||
"این entityها نه tenant دارند نه در GlobalTables ثبت شدهاند:\n%s",
|
||||
implode("\n", $unclassified),
|
||||
));
|
||||
}
|
||||
|
||||
/** فهرست سراسری نباید بدون دلیل رشد کند. */
|
||||
public function testEveryGlobalEntityHasAReason(): void
|
||||
{
|
||||
foreach (GlobalTables::ENTITIES as $class => $reason) {
|
||||
self::assertNotSame('', trim($reason), "دلیل سراسری بودن {$class} خالی است.");
|
||||
self::assertTrue(class_exists($class), "کلاس ثبتشده وجود ندارد: {$class}");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**واقعبینی:** اولین اجرای این تست قطعاً قرمز میشود، با فهرستی از entityهای مالی و فرزندان aggregate (مثل `patient_attachments`، `session_payments`، `claim_items`). این **خروجی مطلوب** است، نه شکست: همان فهرست، ورودی برنامهریزی فاز بعدی است. آنها را با دلیل `'TODO — طبقهبندی نشده'` در `GlobalTables` ثبت کن، فهرست را در گزارش پایانی بیاور، و تست را سبز کن.
|
||||
|
||||
فرزندان aggregate (مثل `patient_attachments` که از طریق `record` به `patient_records` وصل است) گزینهٔ سومی هم دارند: بهجای ستون tenant، از ریشه به ارث میرسند. اگر این مسیر را انتخاب کردی، در `GlobalTables` با دلیل صریح `'فرزند aggregate؛ tenant از PatientRecord به ارث میرسد'` ثبتشان کن — نه بهعنوان TODO.
|
||||
|
||||
**نحوه تست:** `ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php`
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **الگو: Filter + Event Listener (هر دو مکانیزم خود Doctrine/Symfony).** دلیل انتخاب: هدف «هیچکس نتواند فراموش کند» است و این فقط با قلابزدن به چرخهٔ عمر ORM ممکن است، نه با disciplineِ کدنویسی. جایگزین بررسیشده — trait روی repositoryها که خودش `WHERE` را اضافه کند — رد شد چون هر repository جدید باید یادش باشد trait را بگیرد؛ همان مسئله، یک لایه بالاتر.
|
||||
- **فیلتر جایگزین checkerهای موجود نیست.** `AppointmentAccessChecker`، `ClinicDoctorAccessChecker`، `SecretaryAccessChecker` و `PatientRecordScopeResolver` باقی میمانند: آنها «این کاربر با این نقش چه کاری مجاز است» را جواب میدهند، فیلتر فقط «کدام ردیفها» را. حذف هیچکدام جزو این فاز نیست.
|
||||
- **جدول `SQLFilter` limitations را در docblock بنویس.** توسعهدهندهٔ بعدی که فرض کند فیلتر همهجا کار میکند، `find()` را امن میپندارد و باگ میسازد.
|
||||
- **ادمین و مسیرهای عمومی از فیلتر مستثنیاند.** این را با تست تثبیت کن، نه فقط با کامنت — بدون تست، اولین refactor آینده سایت عمومی را بیصدا خالی میکند.
|
||||
- **جدولهای مالی عمداً خارج از دامنهٔ این سریاند.** `payments`، `settlements`، `financial_breakdowns`، `wallet_transactions`، `secretary_earnings` مالکیت دوگانه دارند (پرداختکننده در برابر دریافتکننده) و طبقهبندیشان تحلیل جدا میخواهد. آنها را با TODO ثبت کن و در گزارش پایانی بهعنوان کار باقیمانده اعلام کن — بیصدا رد نشو.
|
||||
- **کارایی:** فیلتر به هر کوئری دو شرط اضافه میکند. ایندکسهای tenant-پیشرو فاز ۲ همین را جبران میکنند. بعد از فعالسازی، `EXPLAIN` سنگینترین لیست پنل (نوبتها و بیماران) را بگیر و مطمئن شو هنوز از ایندکس استفاده میشود و به `ALL` نیفتاده.
|
||||
- **مستندات:** یک بخش کوتاه «جداسازی tenant» به `docs/api/README.md` (یا سند معماری معادل) اضافه کن که بگوید فیلتر چه تضمینی میدهد و چه تضمینی نمیدهد. این تنها سندی است که توسعهدهندهٔ بعدی قبل از نوشتن repository جدید میخواند.
|
||||
@@ -0,0 +1,248 @@
|
||||
# فاز ۵ — آدیت نقاط فرار، تأیید کلاینتها و مستندسازی
|
||||
|
||||
> پرامپت پنجم و آخر سری. **پیشنیاز: فازهای ۱ تا ۴ کامل و سبز.**
|
||||
> ۱. `tenant-01-entity-context-unify.md` → ۲. `tenant-02-mark-booking-tables.md` → ۳. `tenant-03-unify-owner-columns.md` → ۴. `tenant-04-enforce-tenant-filter.md` → **۵. آدیت و مستندسازی (همین فایل)**
|
||||
|
||||
## زمینه
|
||||
|
||||
فاز ۴ فیلتر خودکار را روشن کرد، اما فیلتر Doctrine روی SQL خام DBAL اعمال نمیشود. پروژه هشت فایل دارد که مستقیم SQL میزنند:
|
||||
|
||||
```
|
||||
src/Category/Service/CategoryImporter.php
|
||||
src/Doctor/Command/PurgeDoctorsCommand.php
|
||||
src/Doctor/Command/PurgeUnclaimedDoctorsCommand.php
|
||||
src/Admin/Controller/AdminApiController.php
|
||||
src/Shared/Controller/HealthController.php
|
||||
src/Shared/Command/SeedDemoDataCommand.php
|
||||
src/Representation/Controller/RepresentationActionController.php
|
||||
src/Billing/Repository/ClaimRepository.php
|
||||
```
|
||||
|
||||
همزمان، تغییرات فاز ۲ و ۳ ممکن است شکل پاسخ برخی endpointها را عوض کرده باشند و **build هیچکدام از سه کلاینت خطا نمیدهد** — نه پنل ادمین، نه `nobat724_front`، نه `clinic-pro-tauri`.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
**مشکل:** سه دستهٔ ریسک باقیمانده که هیچکدام خودکار کشف نمیشوند:
|
||||
|
||||
1. **نقاط فرار از فیلتر** — SQL خام، `EntityManager::find()`، `getReference()`
|
||||
2. **رگرسیون خاموش کلاینتها** — تغییر قرارداد API که فقط در رانتایم میشکند
|
||||
3. **دانش از دست رفته** — بدون سند، توسعهدهندهٔ بعدی repository جدیدی مینویسد که فرض میکند فیلتر همهجا کار میکند
|
||||
|
||||
**هدف:** آدیت کامل، تأیید دستی سه کلاینت با هر پنج نقش، سند معماری، و ابزار بکاپ per-tenant که مزیت عملی جداسازی را در دسترس بگذارد.
|
||||
|
||||
## معیار پذیرش
|
||||
|
||||
- ✅ موفق: هر هشت فایل SQL خام آدیت و طبقهبندی شدهاند (نیاز به `WHERE` دارد / سراسری است / ادمین است)؛ سایت عمومی `nobat724_front` با هر سه صفحهٔ اصلی (لیست پزشکان شهر، صفحهٔ پزشک، صفحهٔ کلینیک) داده برمیگرداند؛ پنل ادمین با هر پنج نقش ماتریس بدون خطای کنسول کار میکند.
|
||||
- ❌ خطا: `ddev exec php bin/console app:tenant:dump --tenant=clinic:999` برای tenant ناموجود → پیام خطای واضح و خروج با کد غیرصفر، نه فایل خالی و نه stack trace.
|
||||
- ⚠️ مرزی: `app:tenant:dump` برای tenantی که هیچ دادهای ندارد (کلینیک تازه ثبتنامشده) → فایل معتبر با ساختار جدولها و صفر ردیف، بدون خطا.
|
||||
|
||||
## فایلهای مرتبط
|
||||
|
||||
| فایل | نقش |
|
||||
|------|-----|
|
||||
| `src/Category/Service/CategoryImporter.php` | `SET FOREIGN_KEY_CHECKS` + `DELETE FROM` روی جدولهای مرجع |
|
||||
| `src/Doctor/Command/PurgeDoctorsCommand.php` | حذف انبوه با SQL خام |
|
||||
| `src/Doctor/Command/PurgeUnclaimedDoctorsCommand.php` | `DELETE c FROM ... JOIN` |
|
||||
| `src/Admin/Controller/AdminApiController.php` | کوئریهای ادمین — عمداً cross-tenant |
|
||||
| `src/Shared/Controller/HealthController.php` | `SELECT 1` — بیخطر |
|
||||
| `src/Shared/Command/SeedDemoDataCommand.php` | `INSERT` انبوه دادهٔ نمونه |
|
||||
| `src/Representation/Controller/RepresentationActionController.php` | پنل نماینده |
|
||||
| `src/Billing/Repository/ClaimRepository.php` | گزارشهای صورتحساب |
|
||||
| `src/Shared/Tenant/GlobalTables.php` | فهرست TODOهای فاز ۴ که اینجا تعیین تکلیف میشوند |
|
||||
| `docs/api/` | مستندات endpointهای متأثر |
|
||||
| `src/Shared/Command/TenantDumpCommand.php` | کامند بکاپ per-tenant — جدید |
|
||||
|
||||
## وضعیت فعلی
|
||||
|
||||
نمونهٔ SQL خامی که فیلتر tenant نمیبیند:
|
||||
|
||||
```php
|
||||
// src/Doctor/Command/PurgeUnclaimedDoctorsCommand.php
|
||||
$this->conn->executeStatement('SET FOREIGN_KEY_CHECKS=0');
|
||||
// ...
|
||||
$this->conn->executeStatement(
|
||||
"DELETE c FROM {$t} c JOIN doctors d ON d.id = c.doctor_id WHERE {$where}",
|
||||
$params,
|
||||
);
|
||||
$deleted = $this->conn->executeStatement("DELETE d FROM doctors d WHERE {$where}", $params);
|
||||
```
|
||||
|
||||
```php
|
||||
// src/Category/Service/CategoryImporter.php
|
||||
$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 0');
|
||||
$this->db->executeStatement('DELETE FROM ' . $table);
|
||||
foreach ($rows as $row) {
|
||||
$this->db->insert($table, $row);
|
||||
}
|
||||
$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 1');
|
||||
```
|
||||
|
||||
## وظایف
|
||||
|
||||
### ۱. آدیت هشت فایل SQL خام
|
||||
|
||||
هر فایل را باز کن و هر دستور SQL را در یکی از این سه دسته بگذار، سپس **در docblock همان متد** دسته و دلیل را بنویس:
|
||||
|
||||
| دسته | معنی | اقدام |
|
||||
|---|---|---|
|
||||
| **سراسری** | فقط روی جدولهای `GlobalTables` کار میکند | یادداشت در docblock، بدون تغییر کد |
|
||||
| **ادمین** | عمداً cross-tenant است و پشت `ROLE_ADMIN` قفل است | تأیید کن `#[IsGranted('ROLE_ADMIN')]` واقعاً هست |
|
||||
| **نیازمند دامنه** | روی جدول tenant-دار کار میکند و کاربر غیرادمین به آن میرسد | `WHERE entity_type/entity_id` دستی اضافه کن |
|
||||
|
||||
خروجی این وظیفه یک جدول در گزارش پایانی است، نه فقط تغییر کد. برای هر فایل: مسیر، دسته، دلیل.
|
||||
|
||||
نکات از پیش معلوم:
|
||||
- `HealthController` فقط `SELECT 1` است → سراسری، بدون اقدام.
|
||||
- `CategoryImporter` روی `categories` کار میکند که در `GlobalTables` است → سراسری. اما تأیید کن که `$table` واقعاً از یک allowlist میآید و از ورودی کاربر نمیآید — اگر میآید، این یک مسئلهٔ امنیتی جدا از tenant است و باید گزارش شود.
|
||||
- `AdminApiController` و `SeedDemoDataCommand` و دو `Purge*Command` مسیر ادمین/کنسولاند → دستهٔ ادمین، فقط تأیید گارد.
|
||||
- `ClaimRepository` و `RepresentationActionController` را با دقت بخوان — این دو محتملترین موارد دستهٔ «نیازمند دامنه» هستند.
|
||||
|
||||
**نحوه تست:** برای هر مورد دستهٔ «نیازمند دامنه»، یک تست بنویس که با کاربر tenant A فراخوانی شود و هیچ ردیف tenant B برنگردد. برای بقیه، تست لازم نیست ولی یادداشت docblock اجباری است.
|
||||
|
||||
---
|
||||
|
||||
### ۲. آدیت `find()` و `getReference()` روی entityهای tenant-دار
|
||||
|
||||
فیلتر روی این دو اعمال نمیشود. نقاط را پیدا کن:
|
||||
|
||||
```bash
|
||||
ddev exec grep -rn "->find(\|getReference(" src --include="*.php" | grep -v "findBy\|findOneBy\|findAll"
|
||||
```
|
||||
|
||||
برای هر مورد، تعیین کن روی entityِ tenant-دار است یا نه. اگر بله، یکی از دو کار:
|
||||
|
||||
- **جایگزینی با repository + DQL** (ترجیح) — فیلتر خودکار اعمال میشود
|
||||
- **چک صریح بعد از `find()`** اگر جایگزینی ممکن نبود:
|
||||
|
||||
```php
|
||||
$item = $this->repo->find($id);
|
||||
if ($item === null || $item->getEntityId() !== $context->id || $item->getEntityType() !== $context->type) {
|
||||
throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, null, 403);
|
||||
}
|
||||
```
|
||||
|
||||
**نحوه تست:** برای هر نقطهٔ اصلاحشده، یک تست: کاربر tenant A با شناسهٔ رکورد tenant B → `403`، نه `200` و نه `404` مبهم.
|
||||
|
||||
---
|
||||
|
||||
### ۳. تعیین تکلیف TODOهای `GlobalTables`
|
||||
|
||||
فاز ۴ فهرستی از entityهای طبقهبندینشده تولید کرد (احتمالاً جدولهای مالی و فرزندان aggregate). هر کدام را در یکی از سه دسته قطعی کن:
|
||||
|
||||
| دسته | مثال | اقدام |
|
||||
|---|---|---|
|
||||
| فرزند aggregate | `patient_attachments`, `session_payments`, `claim_items`, `invoice_items` | در `GlobalTables` با دلیل «tenant از ریشه به ارث میرسد»؛ تأیید کن کوئریهایشان همیشه از ریشه JOIN میخورند |
|
||||
| نیازمند tenant | آنچه مستقیم کوئری میشود و ریشهٔ tenant-دار ندارد | ستون اضافه شود — **پرامپت فاز ۶ برایش نوشته شود، در این فاز پیاده نشود** |
|
||||
| سراسری | دادهٔ مرجع | ثبت با دلیل |
|
||||
|
||||
**دستهٔ «نیازمند tenant» را در این فاز پیاده نکن.** جدولهای مالی مالکیت دوگانه دارند (پرداختکننده در برابر دریافتکننده) و migration اشتباه روی دادههای مالی قابل برگشت نیست. خروجی این وظیفه برای آن دسته فقط یک فهرست مستند در گزارش پایانی است، بهعلاوهٔ پیشنهاد اینکه فاز ۶ لازم است یا نه.
|
||||
|
||||
**نحوه تست:** `ddev exec php bin/phpunit tests/Shared/TenantSchemaCoverageTest.php` سبز، و هیچ دلیلی در `GlobalTables` با متن `TODO` باقی نمانده باشد.
|
||||
|
||||
---
|
||||
|
||||
### ۴. کامند بکاپ per-tenant
|
||||
|
||||
مزیت عملیای که کاربر از «دیتابیس مجزا» میخواست، بدون هزینهٔ آن:
|
||||
|
||||
```php
|
||||
// src/Shared/Command/TenantDumpCommand.php
|
||||
#[AsCommand(name: 'app:tenant:dump', description: 'خروجی SQL از دادهٔ یک محیط (پزشک یا کلینیک)')]
|
||||
```
|
||||
|
||||
ورودی: `--tenant=clinic:12` یا `--tenant=doctor:5`، خروجی: `--output=/path/file.sql`.
|
||||
|
||||
پیادهسازی: فهرست جدولهای tenant-دار را از metadata بگیر (همان منطق `TenantSchemaCoverageTest`)، و برای هر کدام `mysqldump --where` بزن:
|
||||
|
||||
```bash
|
||||
mysqldump db <table> --where="entity_type='clinic' AND entity_id=12"
|
||||
```
|
||||
|
||||
**اعتبارسنجی ورودی اجباری است:** `entity_type` باید یکی از `doctor`/`clinic` باشد و `entity_id` عدد صحیح — این مقادیر مستقیم داخل رشتهٔ `--where` میروند و بدون اعتبارسنجی، تزریق شل/SQL میشود. مقدار را با `in_array()` و `(int)` قفل کن، نه با escape.
|
||||
|
||||
اگر tenant وجود نداشت (نه کلینیک نه پزشک با آن شناسه)، پیام خطای فارسی و `Command::FAILURE`.
|
||||
|
||||
**نحوه تست:**
|
||||
```bash
|
||||
ddev exec php bin/console app:tenant:dump --tenant=clinic:1 --output=/tmp/c1.sql
|
||||
ddev exec php bin/console app:tenant:dump --tenant=clinic:999999 --output=/tmp/x.sql # باید FAILURE بدهد
|
||||
ddev exec php bin/console app:tenant:dump --tenant="clinic:1 OR 1=1" --output=/tmp/y.sql # باید رد شود
|
||||
```
|
||||
و بررسی محتوای `/tmp/c1.sql`: هیچ ردیفی با `entity_id` غیر از ۱ نباشد.
|
||||
|
||||
---
|
||||
|
||||
### ۵. تأیید دستی سه کلاینت
|
||||
|
||||
هیچ تست خودکاری این را پوشش نمیدهد (guidelines §۳). هر سه را دستی بررسی کن و نتیجه را گزارش بده.
|
||||
|
||||
**الف) پنل ادمین ClinicPro** — با هر پنج نقش ماتریس وارد شو (اعتبارنامهها از `TEST_USERS.md` / حسابهای QA محلی):
|
||||
|
||||
| نقش | چه باید ببیند |
|
||||
|---|---|
|
||||
| پزشک مستقل | فقط دادهٔ مطب شخصی |
|
||||
| پزشک عضو کلینیک، محیط = کلینیک | دادهٔ کلینیک، محدود به بیماران خودش |
|
||||
| پزشک عضو کلینیک، محیط = شخصی | فقط دادهٔ مطب شخصی |
|
||||
| پزشک مالک کلینیک | همهٔ دادهٔ کلینیک، بدون محدودیت permission |
|
||||
| مدیر کلینیک | همهٔ دادهٔ کلینیک |
|
||||
|
||||
برای هر نقش، صفحات نوبتها، بیماران، خدمات، انبار و تخفیفها را باز کن و کنسول مرورگر را برای خطا چک کن. برای این کار میتوانی از skill `qa-clinicpro` استفاده کنی.
|
||||
|
||||
**ب) `nobat724_front`** — مسیرهای عمومی نباید فیلتر بخورند:
|
||||
- لیست پزشکان یک شهر
|
||||
- صفحهٔ یک پزشک (`/doctor/{uuid}`)
|
||||
- صفحهٔ یک کلینیک (`/clinic/{uuid}`)
|
||||
- گرفتن نوبت بهعنوان بیمار
|
||||
|
||||
هر کدام که خالی برگشت، یعنی فیلتر روی مسیر عمومی روشن شده — باگ فاز ۴.
|
||||
|
||||
**ج) `clinic-pro-tauri`** — `src/service/response.js` همان `/api/v1/...` را صدا میزند. اگر شکل پاسخ نوبت یا خدمات عوض شده، اینجا در رانتایم میشکند. حداقل مسیر ورود و لیست نوبتها را بررسی کن.
|
||||
|
||||
**نحوه تست:** همان بالا — گزارش با ذکر اینکه هر مورد تأیید شد یا نه. اگر موردی بررسی نشد، صریح بنویس بررسی نشد؛ ننویس «احتمالاً سالم است».
|
||||
|
||||
---
|
||||
|
||||
### ۶. مستندسازی
|
||||
|
||||
**الف) سند معماری** — فایل `docs/architecture/tenancy.md` (جدید):
|
||||
|
||||
- تعریف tenant: جفت `(entity_type, entity_id)` با مقادیر `doctor`/`clinic`
|
||||
- ماتریس پنج نقش با نحوهٔ حل محیط هر کدام
|
||||
- `EntityContextResolver` تنها نقطهٔ تصمیم
|
||||
- `TenantFilter` چه تضمین میکند و **چه تضمین نمیکند** (جدول محدودیتها از فاز ۴)
|
||||
- `GlobalTables` و قاعدهٔ افزودن به آن
|
||||
- «entity جدید میسازی؟ این چکلیست» — سه خط
|
||||
|
||||
**ب) `docs/api/`** — هر فایلی که endpointش شکل پاسخ عوض کرده، با **JSON واقعیِ خروجی اجرا** بهروز شود، نه دستساز. حداقل: `appointment.md`، `patient.md`، `discount.md`، `secretary.md`.
|
||||
|
||||
**ج) `CLAUDE.md`** — یک بند کوتاه زیر «قواعد غیرقابلمذاکره» یا بخش الگوها:
|
||||
|
||||
```markdown
|
||||
## جداسازی محیط (tenant)
|
||||
هر entity جدید یا `TenantOwnedTrait` میگیرد، یا با دلیل در `App\Shared\Tenant\GlobalTables`
|
||||
ثبت میشود. تست `TenantSchemaCoverageTest` هر دو حالت را اجبار میکند.
|
||||
محیط جاری همیشه از `EntityContextResolver` گرفته میشود، نه از نقش کاربر.
|
||||
جزئیات: `docs/architecture/tenancy.md`
|
||||
```
|
||||
|
||||
**نحوه تست:** سند را بخوان و مطمئن شو با کد واقعی میخواند — بهویژه جدول محدودیتهای فیلتر. سند ناهمخوان بدتر از نبود سند است (guidelines §۴).
|
||||
|
||||
---
|
||||
|
||||
### ۷. گزارش پایانی
|
||||
|
||||
یک خلاصه بنویس شامل:
|
||||
|
||||
1. جدول آدیت هشت فایل SQL خام (مسیر، دسته، اقدام)
|
||||
2. فهرست نقاط `find()` اصلاحشده
|
||||
3. entityهای دستهٔ «نیازمند tenant» که به فاز بعدی موکول شدند + پیشنهاد اینکه فاز ۶ لازم است یا نه
|
||||
4. نتیجهٔ تأیید دستی سه کلاینت — با ذکر صریح هر موردی که بررسی نشد
|
||||
5. هر رفتاری که عمداً عوض شد (مثل رفع باگ یکتایی `doctor_secretaries` در فاز ۳)
|
||||
|
||||
## نکات مهم
|
||||
|
||||
- **این فاز کد کمی دارد و بررسی زیاد.** وسوسهٔ رد شدن سریع از وظیفههای ۱ و ۵ همان چیزی است که نشتی را زنده نگه میدارد. اگر وقت کم آمد، وظیفهٔ ۴ (کامند بکاپ) را به تعویق بینداز، نه آدیت را.
|
||||
- **هیچ migration جدیدی در این فاز نیست.** اگر لازم شد، یعنی فازهای قبلی ناقص ماندهاند — برگرد و همانجا درست کن، اینجا وصله نزن.
|
||||
- **اعتبارسنجی ورودی `app:tenant:dump` امنیتی است، نه سلیقهای.** مقدار مستقیم داخل رشتهٔ shell و SQL میرود.
|
||||
- **`SET FOREIGN_KEY_CHECKS=0` در سه فایل**: این الگو در MariaDB سراسری است و در طول اجرا تمام محافظت ارجاعی را برای همهٔ کانکشنها خاموش میکند. جزو دامنهٔ این فاز نیست، ولی اگر روی جدول tenant-دار اجرا میشود، در گزارش بهعنوان ریسک ذکرش کن.
|
||||
- **کلاینتهای cross-repo تست خودکار ندارند** (guidelines §۳) — تأیید دستی وظیفهٔ ۵ تنها پوشش موجود است.
|
||||
@@ -8,7 +8,6 @@ use App\Appointment\Repository\AppointmentRepository;
|
||||
use App\Appointment\Repository\SlotTakenException;
|
||||
use App\Appointment\Service\SlotCalculatorService;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
@@ -35,7 +34,7 @@ class MyAppointmentsController extends BaseController
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
private readonly \App\Shared\Context\EntityContextResolver $contextResolver,
|
||||
private readonly SlotCalculatorService $slotCalculator,
|
||||
private readonly \App\Appointment\Service\BookingContextResolver $bookingContext,
|
||||
private readonly \App\ClinicService\Repository\ServiceSectionRepository $sectionRepo,
|
||||
@@ -552,21 +551,19 @@ class MyAppointmentsController extends BaseController
|
||||
|
||||
private function secretaryCanBookForDoctor(User $user, Doctor $doctor): bool
|
||||
{
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid === null) {
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
if (!$context->isResolved()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
if ($context->isClinic()) {
|
||||
// منشی فقط برای پزشکانِ تخصیصیافتهی خودش میتواند رزرو کند، نه کل کلینیک
|
||||
$rel = $this->secretaryRepo->findActiveClinicRow($user, $clinic, $doctor);
|
||||
$rel = $this->secretaryRepo->findActiveClinicRow($user, $context->clinic, $doctor);
|
||||
return $rel !== null && (bool) ($rel->getPermissions()['resources']['appointments']['create'] ?? false);
|
||||
}
|
||||
|
||||
$scopeDoctor = $this->doctorRepo->findByUuid($dbUuid);
|
||||
if ($scopeDoctor !== null && $scopeDoctor->getId() === $doctor->getId()) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $scopeDoctor);
|
||||
if ($context->id === $doctor->getId()) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
|
||||
return $rel !== null && (bool) ($rel->getPermissions()['resources']['appointments']['create'] ?? false);
|
||||
}
|
||||
|
||||
@@ -581,35 +578,28 @@ class MyAppointmentsController extends BaseController
|
||||
*/
|
||||
private function resolveSecretaryFilter(User $user): ?array
|
||||
{
|
||||
$activeCtx = $this->contextRepo->findByUser($user);
|
||||
$dbUuid = $activeCtx?->getDbUuid();
|
||||
|
||||
if ($dbUuid === null) {
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
if (!$context->isResolved()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// بررسی scope کلینیک — فقط پزشکانِ تخصیصیافته به این منشی، نه کل کلینیک
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
|
||||
if ($context->isClinic()) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $context->clinic);
|
||||
if ($rel === null) return null;
|
||||
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
|
||||
$doctorIds = array_map(
|
||||
fn(Doctor $d) => $d->getId(),
|
||||
$this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic)
|
||||
$this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $context->clinic)
|
||||
);
|
||||
return ['clinic', $doctorIds, $canView];
|
||||
}
|
||||
|
||||
// بررسی scope مطب شخصی
|
||||
$doctor = $this->doctorRepo->findByUuid($dbUuid);
|
||||
if ($doctor !== null) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
|
||||
if ($rel === null) return null;
|
||||
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
|
||||
return ['doctor', $doctor, $canView];
|
||||
}
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $context->doctor);
|
||||
if ($rel === null) return null;
|
||||
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
|
||||
|
||||
return null;
|
||||
return ['doctor', $context->doctor, $canView];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,11 @@ namespace App\Appointment\Security;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Clinic\Security\ClinicDoctorPermissionChecker;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Secretary\Security\SecretaryPermissionChecker;
|
||||
use App\Shared\Context\EntityContextResolver;
|
||||
|
||||
/**
|
||||
* تنها تصمیمگیرندهٔ دسترسی روی «یک نوبت مشخص».
|
||||
@@ -34,9 +33,8 @@ class AppointmentAccessChecker
|
||||
private readonly ClinicDoctorPermissionChecker $clinicPermissions,
|
||||
private readonly SecretaryPermissionChecker $secretaryPermissions,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
) {}
|
||||
|
||||
public function canView(Appointment $appointment, User $user): bool
|
||||
@@ -108,29 +106,23 @@ class AppointmentAccessChecker
|
||||
*/
|
||||
private function secretaryCanContext(User $user, \App\Doctor\Entity\Doctor $doctor, ?\App\Clinic\Entity\Clinic $clinic): bool
|
||||
{
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid === null) {
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
if (!$context->isResolved()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ctxClinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($ctxClinic !== null) {
|
||||
if ($clinic === null || $ctxClinic->getId() !== $clinic->getId()) {
|
||||
if ($context->isClinic()) {
|
||||
if ($clinic === null || $context->id !== $clinic->getId()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$relation = $this->secretaryRepo->findActiveClinicRow($user, $ctxClinic, $doctor);
|
||||
$relation = $this->secretaryRepo->findActiveClinicRow($user, $context->clinic, $doctor);
|
||||
|
||||
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, self::ACTION_UPDATE_STATUS);
|
||||
}
|
||||
|
||||
// محیطِ مطب شخصی: نوبت هم باید در همان مطب شخصی باشد (clinic == null).
|
||||
if ($clinic !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ctxDoctor = $this->doctorRepo->findByUuid($dbUuid);
|
||||
if ($ctxDoctor === null || $ctxDoctor->getId() !== $doctor->getId()) {
|
||||
if ($clinic !== null || $context->id !== $doctor->getId()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -145,12 +137,12 @@ class AppointmentAccessChecker
|
||||
*/
|
||||
public function viewableClinicFor(User $user, \App\Doctor\Entity\Doctor $doctor): ?\App\Clinic\Entity\Clinic
|
||||
{
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
$clinic = $dbUuid !== null ? $this->clinicRepo->findByUuid($dbUuid) : null;
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
|
||||
if ($clinic === null) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
}
|
||||
// بیرون از محیط کلینیک، کلینیکِ تحتِ مالکیت بازمیگردد: کاربری که هم پزشک
|
||||
// است و هم مالک کلینیک، بدون محیط فعال بهعنوان پزشک حل میشود ولی هنوز
|
||||
// باید لیست نوبتهای کلینیک خودش را ببیند.
|
||||
$clinic = $context->isClinic() ? $context->clinic : $this->clinicRepo->findByUser($user);
|
||||
|
||||
if ($clinic === null || !$clinic->hasDoctor($doctor)) {
|
||||
return null;
|
||||
@@ -168,24 +160,23 @@ class AppointmentAccessChecker
|
||||
*/
|
||||
private function secretaryCan(Appointment $appointment, User $user, string $action): bool
|
||||
{
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid === null) {
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
if (!$context->isResolved()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
if ($appointment->getClinic()?->getId() !== $clinic->getId()) {
|
||||
if ($context->isClinic()) {
|
||||
if ($appointment->getClinic()?->getId() !== $context->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$relation = $this->secretaryRepo->findActiveClinicRow($user, $clinic, $appointment->getDoctor());
|
||||
$relation = $this->secretaryRepo->findActiveClinicRow($user, $context->clinic, $appointment->getDoctor());
|
||||
|
||||
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, $action);
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($dbUuid);
|
||||
if ($doctor === null || $doctor->getId() !== $appointment->getDoctor()->getId()) {
|
||||
$doctor = $appointment->getDoctor();
|
||||
if ($context->id !== $doctor->getId()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@ use App\Shared\Exception\AppException;
|
||||
*
|
||||
* نبودِ clinic_uuid هرگز به معنی «هر محلی که پیدا شد» نیست. با چند برنامهٔ همزمان،
|
||||
* حدسزدن محل یعنی ثبت خاموشِ نوبت در جای اشتباه — پس یا محل صریح است، یا شخصی.
|
||||
*
|
||||
* چرا جدا از {@see \App\Shared\Context\EntityContextResolver}: ورودی آن User است و
|
||||
* محیطِ کاربرِ پنل را حل میکند؛ اینجا ورودی Doctor است چون رزرو عمومی را بیمار
|
||||
* انجام میدهد و کاربرِ درخواست هیچ محیط کاری ندارد. به همین دلیل هم null اینجا
|
||||
* معنای صریحِ «مطب شخصی» دارد، نه «نامشخص».
|
||||
*/
|
||||
class BookingContextResolver
|
||||
{
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
namespace App\Clinic\Security;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Context\EntityContextResolver;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
@@ -20,8 +19,7 @@ use App\Shared\Exception\AppException;
|
||||
class ClinicDoctorAccessChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicDoctorPermissionChecker $permissions,
|
||||
) {}
|
||||
@@ -33,22 +31,14 @@ class ClinicDoctorAccessChecker
|
||||
*/
|
||||
public function memberClinicId(User $user): ?int
|
||||
{
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic === null) {
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
if (!$context->isClinic()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor === null || !$clinic->hasDoctor($doctor)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $clinic->getId();
|
||||
return $doctor !== null && $context->clinic->hasDoctor($doctor) ? $context->id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,16 +46,13 @@ class ClinicDoctorAccessChecker
|
||||
*/
|
||||
public function canOrNonMember(User $user, string $resource, string $action): bool
|
||||
{
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid === null) {
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
if (!$context->isClinic()) {
|
||||
// محیطِ مطب شخصی یا نامشخص → این checker مالِ او نیست.
|
||||
return true;
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic === null) {
|
||||
// محیطِ مطب شخصی (db_uuid پزشک است، نه کلینیک) → محدود نمیکنیم.
|
||||
return true;
|
||||
}
|
||||
$clinic = $context->clinic;
|
||||
|
||||
// مالکِ کلینیک هرگز با مجوزهای عضویت قفل نمیشود.
|
||||
if ($clinic->getUser()->getId() === $user->getId()) {
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Dashboard\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
@@ -33,7 +32,6 @@ class DashboardController extends BaseController
|
||||
private readonly SmsWalletService $smsWalletService,
|
||||
private readonly PatientRecordRepository $patientRecordRepo,
|
||||
private readonly PatientSessionRepository $patientSessionRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
private readonly \App\Clinic\Security\ClinicDoctorPermissionChecker $permChecker,
|
||||
private readonly \App\Doctor\Repository\DoctorAddressRepository $addressRepo,
|
||||
@@ -532,29 +530,22 @@ class DashboardController extends BaseController
|
||||
#[IsGranted('ROLE_SECRETARY')]
|
||||
public function secretary(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$activeCtx = $this->contextRepo->findByUser($user);
|
||||
$dbUuid = $activeCtx?->getDbUuid();
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
|
||||
if ($dbUuid === null) {
|
||||
if (!$context->isResolved()) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403);
|
||||
}
|
||||
|
||||
// تعیین scope بر اساس db_uuid فعال
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
return $this->secretaryClinicDashboard($user, $clinic);
|
||||
if ($context->isClinic()) {
|
||||
return $this->secretaryClinicDashboard($user, $context->clinic);
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($dbUuid);
|
||||
if ($doctor !== null) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
|
||||
if ($rel === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403);
|
||||
}
|
||||
return $this->secretaryDoctorDashboard($user, $rel);
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $context->doctor);
|
||||
if ($rel === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403);
|
||||
}
|
||||
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'context نامعتبر است', 403);
|
||||
return $this->secretaryDoctorDashboard($user, $rel);
|
||||
}
|
||||
|
||||
private function secretaryDoctorDashboard(User $user, DoctorSecretary $rel): JsonResponse
|
||||
|
||||
@@ -286,7 +286,14 @@ class InventoryController extends BaseController
|
||||
return $package;
|
||||
}
|
||||
|
||||
/** @return array{0: string, 1: int|null} [entityType, entityId] */
|
||||
/**
|
||||
* عمداً از EntityContextResolver استفاده نمیکند: اینجا نقشِ پزشک بیقید اول
|
||||
* بررسی میشود، پس پزشکِ عضو حتی در محیط کلینیک هم انبارِ شخصی خودش را میبیند.
|
||||
* رزولور مشترک محیط فعال را مقدم میداند و انتقال به آن، دادهٔ نمایشدادهشده به
|
||||
* پزشکِ عضو را عوض میکند — تصمیمی محصولی که به فاز نشانهگذاری tenant موکول شد.
|
||||
*
|
||||
* @return array{0: string, 1: int|null} [entityType, entityId]
|
||||
*/
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
|
||||
@@ -3,18 +3,20 @@
|
||||
namespace App\Patient\Security;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Security\ClinicDoctorPermissionChecker;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Shared\Context\EntityContext;
|
||||
use App\Shared\Context\EntityContextResolver;
|
||||
|
||||
/**
|
||||
* «پروندههای کدام محیط را این کاربر میبیند؟»
|
||||
* «پروندههای کدام محیط را این کاربر میبیند، و درون آن محیط محدود به بیمارانِ
|
||||
* کدام پزشکان؟»
|
||||
*
|
||||
* پیش از این، نگاشت تکمقصدی بود: نقش پزشک همیشه به پروندهٔ مطب شخصی میرسید، پس
|
||||
* پزشکِ دعوتشده به کلینیک پروندههای بیمارانش در آن کلینیک را اصلاً نمیدید. محیط
|
||||
* فعال (UserActiveContext) تعیینکننده است، دقیقاً مثل EntityContextResolver.
|
||||
* پرسش اول را EntityContextResolver جواب میدهد؛ مسئولیت منحصربهفرد این کلاس
|
||||
* فقط پرسش دوم است. پروندهٔ کلینیکی per-بیمار است نه per-پزشک، پس محدودسازیِ
|
||||
* پزشکِ عضو و منشی نمیتواند روی خودِ محیط باشد.
|
||||
*
|
||||
* «پایان همکاری» منبع حقیقتِ جدا ندارد: ClinicDoctorPermission/DoctorSecretary با
|
||||
* active=false خودشان رد میکنند.
|
||||
@@ -24,79 +26,52 @@ class PatientRecordScopeResolver
|
||||
private const RESOURCE = 'patients';
|
||||
|
||||
public function __construct(
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly ClinicDoctorPermissionChecker $clinicPermissions,
|
||||
) {}
|
||||
|
||||
public function resolve(User $user): PatientRecordScope
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
return $this->forDoctorUser($user);
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
|
||||
if ($context->isClinic()) {
|
||||
return $this->restrictionInClinic($user, $context->clinic);
|
||||
}
|
||||
|
||||
if ($user->hasRole('ROLE_CLINIC')) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
|
||||
return PatientRecordScope::forClinic($clinic?->getId());
|
||||
}
|
||||
|
||||
if ($user->hasRole('ROLE_SECRETARY')) {
|
||||
return $this->forSecretary($user);
|
||||
}
|
||||
|
||||
return PatientRecordScope::unknown();
|
||||
// پزشکی که هنوز رکورد Doctor ندارد، محیطش «doctor» با شناسهٔ تهی است —
|
||||
// assertPatientGate همان را ۴۰۳ میکند.
|
||||
return $context->type === EntityContext::TYPE_DOCTOR
|
||||
? PatientRecordScope::forDoctor($context->id)
|
||||
: PatientRecordScope::unknown();
|
||||
}
|
||||
|
||||
/**
|
||||
* پزشک در محیط کلینیکِ فعالش پروندههای همان کلینیک را میبیند — محدود به
|
||||
* بیمارانِ خودش. بیرون از آن محیط، فقط پروندهٔ مطب شخصی.
|
||||
* مالک کلینیک همهٔ پروندهها را میبیند؛ پزشکِ عضو فقط بیمارانِ خودش؛ منشی
|
||||
* فقط بیمارانِ پزشکانِ تخصیصیافته به او — عضویت در کلینیک بهتنهایی یعنی
|
||||
* منشیِ یک پزشک پروندهٔ بیماران پزشک دیگر را ببیند.
|
||||
*/
|
||||
private function forDoctorUser(User $user): PatientRecordScope
|
||||
private function restrictionInClinic(User $user, Clinic $clinic): PatientRecordScope
|
||||
{
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor === null) {
|
||||
return PatientRecordScope::forDoctor(null);
|
||||
}
|
||||
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
$clinic = $dbUuid !== null ? $this->clinicRepo->findByUuid($dbUuid) : null;
|
||||
|
||||
if ($clinic === null || !$clinic->hasDoctor($doctor)) {
|
||||
return PatientRecordScope::forDoctor($doctor->getId());
|
||||
}
|
||||
|
||||
// مالک کلینیکی که خودش پزشک هم هست، محدود نمیشود.
|
||||
if ($clinic->getUser()->getId() === $user->getId()) {
|
||||
return PatientRecordScope::forClinic($clinic->getId());
|
||||
}
|
||||
|
||||
if (!$this->clinicPermissions->can($user, $clinic, self::RESOURCE, 'view')) {
|
||||
return PatientRecordScope::forDoctor($doctor->getId());
|
||||
}
|
||||
|
||||
return PatientRecordScope::forClinicRestrictedToDoctors($clinic->getId(), [$doctor->getId()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* منشی در محیط فعالش. در کلینیک، فقط بیمارانِ پزشکانِ تخصیصیافته به او —
|
||||
* عضویت در کلینیک بهتنهایی یعنی منشیِ یک پزشک پروندهٔ بیماران پزشک دیگر را ببیند.
|
||||
*/
|
||||
private function forSecretary(User $user): PatientRecordScope
|
||||
{
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid === null) {
|
||||
return PatientRecordScope::unknown();
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
if ($this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic) === null) {
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor === null) {
|
||||
return PatientRecordScope::unknown();
|
||||
}
|
||||
|
||||
if (!$this->clinicPermissions->can($user, $clinic, self::RESOURCE, 'view')) {
|
||||
return PatientRecordScope::forDoctor($doctor->getId());
|
||||
}
|
||||
|
||||
return PatientRecordScope::forClinicRestrictedToDoctors($clinic->getId(), [$doctor->getId()]);
|
||||
}
|
||||
|
||||
if ($user->hasRole('ROLE_SECRETARY')) {
|
||||
$doctorIds = array_map(
|
||||
fn($d) => $d->getId(),
|
||||
$this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic),
|
||||
@@ -105,11 +80,6 @@ class PatientRecordScopeResolver
|
||||
return PatientRecordScope::forClinicRestrictedToDoctors($clinic->getId(), $doctorIds);
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($dbUuid);
|
||||
if ($doctor !== null && $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor) !== null) {
|
||||
return PatientRecordScope::forDoctor($doctor->getId());
|
||||
}
|
||||
|
||||
return PatientRecordScope::unknown();
|
||||
return PatientRecordScope::forClinic($clinic->getId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,28 +3,23 @@
|
||||
namespace App\Secretary\Security;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Context\EntityContextResolver;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
* Single entry point that answers «آیا این منشی مجاز به resource/action هست؟».
|
||||
*
|
||||
* منبع حقیقت، ستون JSON `permission` روی ردیف فعالِ DoctorSecretary در محیطِ
|
||||
* فعال کاربر (UserActiveContext.db_uuid) است — دقیقاً مثل PatientRecordScopeResolver
|
||||
* و DashboardController::secretary. کنترلرهایی که چند نقش میگیرند فقط وقتی کاربر
|
||||
* ROLE_SECRETARY دارد این checker را صدا میزنند؛ نقشهای دیگر دستنخورده میمانند.
|
||||
* فعالِ کاربر است. محیط از EntityContextResolver میآید — همان نقطهای که همهٔ
|
||||
* نقشها از آن میگذرند؛ اینجا فقط مجوزها بررسی میشوند، نه محیط.
|
||||
*/
|
||||
class SecretaryAccessChecker
|
||||
{
|
||||
public function __construct(
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly EntityContextResolver $contextResolver,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly SecretaryPermissionChecker $permissions,
|
||||
) {}
|
||||
@@ -32,22 +27,14 @@ class SecretaryAccessChecker
|
||||
/** ردیف فعالِ منشی در محیط فعال؛ null اگر محیط تنظیم نشده یا رابطهای نیست. */
|
||||
public function activeRelation(User $user): ?DoctorSecretary
|
||||
{
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid === null) {
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
if (!$context->isResolved()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
return $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($dbUuid);
|
||||
if ($doctor !== null) {
|
||||
return $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
|
||||
}
|
||||
|
||||
return null;
|
||||
return $context->isClinic()
|
||||
? $this->secretaryRepo->findActiveBySecretaryForClinic($user, $context->clinic)
|
||||
: $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $context->doctor);
|
||||
}
|
||||
|
||||
public function can(User $user, string $resource, string $action): bool
|
||||
@@ -59,30 +46,14 @@ class SecretaryAccessChecker
|
||||
|
||||
/**
|
||||
* جفتِ [entityType, entityId] مالکِ محیطِ فعالِ منشی — کلینیک یا پزشک.
|
||||
* برای کنترلرهایی که دادهٔ tenant را با این جفت واکشی میکنند و resolverِ
|
||||
* عمومی (EntityContextResolver) منشی را نمیشناسد. مجوز جدا با
|
||||
* برای کنترلرهایی که دادهٔ tenant را با این جفت واکشی میکنند. مجوز جدا با
|
||||
* denyUnlessGranted بررسی میشود؛ این متد فقط owner را حل میکند.
|
||||
*
|
||||
* @return array{0: string, 1: int|null} ['clinic'|'doctor'|'unknown', id|null]
|
||||
*/
|
||||
public function resolveOwnerEntity(User $user): array
|
||||
{
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid === null) {
|
||||
return ['unknown', null];
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
return ['clinic', $clinic->getId()];
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($dbUuid);
|
||||
if ($doctor !== null) {
|
||||
return ['doctor', $doctor->getId()];
|
||||
}
|
||||
|
||||
return ['unknown', null];
|
||||
return $this->contextResolver->resolve($user)->toEntityPair();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,28 +68,23 @@ class SecretaryAccessChecker
|
||||
string $resource,
|
||||
string $action
|
||||
): bool {
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid === null) {
|
||||
$context = $this->contextResolver->resolve($user);
|
||||
if (!$context->isResolved()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ctxClinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($ctxClinic !== null) {
|
||||
if ($context->isClinic()) {
|
||||
// محیطِ کلینیک: تنظیم باید در همان کلینیک باشد و پزشکش جزو پزشکانِ منشی.
|
||||
if ($clinic === null || $ctxClinic->getId() !== $clinic->getId()) {
|
||||
if ($clinic === null || $context->id !== $clinic->getId()) {
|
||||
return false;
|
||||
}
|
||||
$relation = $this->secretaryRepo->findActiveClinicRow($user, $ctxClinic, $doctor);
|
||||
$relation = $this->secretaryRepo->findActiveClinicRow($user, $context->clinic, $doctor);
|
||||
|
||||
return $relation !== null && $this->permissions->can($relation, $resource, $action);
|
||||
}
|
||||
|
||||
// محیطِ مطب شخصی: تنظیم هم باید شخصی باشد (clinic == null).
|
||||
if ($clinic !== null) {
|
||||
return false;
|
||||
}
|
||||
$ctxDoctor = $this->doctorRepo->findByUuid($dbUuid);
|
||||
if ($ctxDoctor === null || $ctxDoctor->getId() !== $doctor->getId()) {
|
||||
if ($clinic !== null || $context->id !== $doctor->getId()) {
|
||||
return false;
|
||||
}
|
||||
$relation = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
|
||||
|
||||
@@ -6,7 +6,9 @@ use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
@@ -18,6 +20,12 @@ use App\Shared\Exception\AppException;
|
||||
* نقش بهتنهایی برای کاربری که هم پزشک است و هم مالک کلینیک جواب نمیدهد: چنین
|
||||
* کاربری همیشه بهعنوان پزشک حل میشد و هرگز به سرویسهای کلینیک خودش نمیرسید.
|
||||
* UserActiveContext تعیینکننده است و نقش فقط fallback آخر.
|
||||
*
|
||||
* این کلاس فقط به «کدام محیط» جواب میدهد. «چه کاری در آن محیط مجاز است» کار
|
||||
* ClinicDoctorPermissionChecker و SecretaryPermissionChecker است و اینجا بررسی نمیشود.
|
||||
*
|
||||
* برای مسیر رزرو عمومی (که کاربر پنل ندارد و ورودیاش Doctor است، نه User)
|
||||
* قرینهای جدا وجود دارد: {@see \App\Appointment\Service\BookingContextResolver}.
|
||||
*/
|
||||
class EntityContextResolver
|
||||
{
|
||||
@@ -25,6 +33,7 @@ class EntityContextResolver
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly UserActiveContextRepository $activeContextRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -64,7 +73,7 @@ class EntityContextResolver
|
||||
}
|
||||
}
|
||||
|
||||
/** مالک کلینیک، ادمین، یا پزشکِ عضو همان کلینیک. */
|
||||
/** مالک کلینیک، ادمین، پزشکِ عضو همان کلینیک، یا منشیِ دارای رابطهٔ فعال در آن. */
|
||||
public function canActInClinic(User $user, Clinic $clinic): bool
|
||||
{
|
||||
if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) {
|
||||
@@ -72,8 +81,11 @@ class EntityContextResolver
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
if ($doctor !== null && $clinic->hasDoctor($doctor)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $doctor !== null && $clinic->hasDoctor($doctor);
|
||||
return $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic) !== null;
|
||||
}
|
||||
|
||||
public function assertCanActInClinic(User $user, Clinic $clinic): void
|
||||
@@ -100,11 +112,24 @@ class EntityContextResolver
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($active->getDbUuid());
|
||||
if ($doctor !== null && $doctor->getUser()->getId() === $user->getId()) {
|
||||
return EntityContext::forDoctor($doctor);
|
||||
|
||||
return $doctor !== null && $this->canActForDoctor($user, $doctor)
|
||||
? EntityContext::forDoctor($doctor)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* محیطِ مطب شخصی: خودِ پزشک، یا منشیِ دارای رابطهٔ فعال با او.
|
||||
* قرینهٔ canActInClinic برای شاخهٔ غیرکلینیکی؛ ادمین عمداً اینجا نیست چون
|
||||
* مسیرهای ادمین سراسریاند و از محیط فعالِ یک پزشک عبور نمیکنند.
|
||||
*/
|
||||
private function canActForDoctor(User $user, Doctor $doctor): bool
|
||||
{
|
||||
if ($doctor->getUser()->getId() === $user->getId()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return null;
|
||||
return $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor) !== null;
|
||||
}
|
||||
|
||||
private function fromRole(User $user): EntityContext
|
||||
@@ -119,6 +144,9 @@ class EntityContextResolver
|
||||
return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown();
|
||||
}
|
||||
|
||||
// منشی fallback نقشی ندارد: محیطش فقط از UserActiveContext میآید، چون یک
|
||||
// منشی میتواند همزمان به چند پزشک و کلینیک وصل باشد و نقش تنها، انتخاب
|
||||
// بین آنها را تعیین نمیکند.
|
||||
return EntityContext::unknown();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,6 +257,13 @@ class SubscriptionController extends BaseController
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* عمداً از EntityContextResolver استفاده نمیکند: اشتراک به مالکِ حقوقی تعلق
|
||||
* دارد، نه به محیطِ کاری لحظهای — پزشکِ عضو در محیط کلینیک همچنان اشتراک
|
||||
* خودش را میبیند، نه اشتراک کلینیک. انتقال به رزولور این را عوض میکند.
|
||||
*
|
||||
* @return array{0: string, 1: int|null} [entityType, entityId]
|
||||
*/
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
|
||||
@@ -154,7 +154,14 @@ class TenantTagController extends BaseController
|
||||
return $tag;
|
||||
}
|
||||
|
||||
/** @return array{0: string, 1: int|null} [entityType, entityId] */
|
||||
/**
|
||||
* عمداً از EntityContextResolver استفاده نمیکند: اینجا نقشِ پزشک بیقید اول
|
||||
* بررسی میشود، پس پزشکِ عضو حتی در محیط کلینیک هم برچسبهای شخصی خودش را
|
||||
* میبیند. رزولور مشترک محیط فعال را مقدم میداند و انتقال به آن، دادهٔ
|
||||
* نمایشدادهشده را عوض میکند — به فاز نشانهگذاری tenant موکول شد.
|
||||
*
|
||||
* @return array{0: string, 1: int|null} [entityType, entityId]
|
||||
*/
|
||||
private function resolveEntity(User $user): array
|
||||
{
|
||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Subscription\Entity\SubscriptionPlan;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
@@ -24,6 +25,31 @@ abstract class ApiTestCase extends WebTestCase
|
||||
{
|
||||
$this->client = static::createClient();
|
||||
$this->em = static::getContainer()->get(EntityManagerInterface::class);
|
||||
$this->ensureFreePlan();
|
||||
}
|
||||
|
||||
/**
|
||||
* The «free» plan is SubscriptionService::getEffectivePlan's fallback for any
|
||||
* tenant without a paid subscription. Without it every hasFeature() is false
|
||||
* and the patient / service / insurance endpoints answer 403 instead of doing
|
||||
* their job. db_test is never reset, so the insert is idempotent.
|
||||
*
|
||||
* Mirrors the row shipped in the dev database.
|
||||
*/
|
||||
private function ensureFreePlan(): void
|
||||
{
|
||||
$repo = $this->em->getRepository(SubscriptionPlan::class);
|
||||
if ($repo->findOneBy(['name' => 'free']) !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->em->persist(new SubscriptionPlan('free', 0, 1, [
|
||||
'patient_records' => true,
|
||||
'services' => true,
|
||||
'sms_panel' => true,
|
||||
'insurance' => true,
|
||||
]));
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Shared;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Entity\UserActiveContext;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Entity\ClinicDoctorPermission;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Shared\Context\EntityContext;
|
||||
use App\Shared\Context\EntityContextResolver;
|
||||
use App\Shared\Exception\AppException;
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* ماتریس نقشها: «این کاربر در کدام محیط کار میکند؟»
|
||||
*
|
||||
* قرارداد اجرایی فازهای بعدیِ نشانهگذاری tenant است — هر assert روی
|
||||
* toEntityPair() است، نه روی جزئیات داخلی رزولور.
|
||||
*
|
||||
* رزولور فقط محیط را تعیین میکند؛ مجوزها (ClinicDoctorPermission /
|
||||
* DoctorSecretary.permission) لایهٔ جداگانهاند و اینجا سنجیده نمیشوند.
|
||||
*/
|
||||
class EntityContextResolverTest extends ApiTestCase
|
||||
{
|
||||
private function resolver(): EntityContextResolver
|
||||
{
|
||||
return static::getContainer()->get(EntityContextResolver::class);
|
||||
}
|
||||
|
||||
private function makeDoctor(?User $user = null): Doctor
|
||||
{
|
||||
$doctor = new Doctor($user ?? $this->createUser(['ROLE_DOCTOR']), 'دکتر تست');
|
||||
$this->em->persist($doctor);
|
||||
$this->em->flush();
|
||||
|
||||
return $doctor;
|
||||
}
|
||||
|
||||
private function makeClinic(?User $owner = null): Clinic
|
||||
{
|
||||
$clinic = new Clinic($owner ?? $this->createUser(['ROLE_CLINIC']));
|
||||
$clinic->setName('کلینیک تست');
|
||||
$this->em->persist($clinic);
|
||||
$this->em->flush();
|
||||
|
||||
return $clinic;
|
||||
}
|
||||
|
||||
private function joinClinic(Clinic $clinic, Doctor $doctor): void
|
||||
{
|
||||
$clinic->getDoctors()->add($doctor);
|
||||
$this->em->persist(new ClinicDoctorPermission($clinic, $doctor));
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
private function setActiveContext(User $user, string $dbUuid): void
|
||||
{
|
||||
$this->em->persist(new UserActiveContext($user, $dbUuid));
|
||||
$this->em->flush();
|
||||
}
|
||||
|
||||
// ── سناریو ۱: پزشک مستقل ────────────────────────────────────────────────
|
||||
|
||||
public function testIndependentDoctorAlwaysResolvesToOwnPractice(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
|
||||
$context = $this->resolver()->resolve($doctor->getUser());
|
||||
|
||||
self::assertSame(['doctor', $doctor->getId()], $context->toEntityPair());
|
||||
}
|
||||
|
||||
// ── سناریو ۲: پزشکِ مستقلِ عضو کلینیک ───────────────────────────────────
|
||||
|
||||
public function testClinicMemberDoctorResolvesToClinicWhenActiveContextIsClinic(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$clinic = $this->makeClinic();
|
||||
$this->joinClinic($clinic, $doctor);
|
||||
$this->setActiveContext($doctor->getUser(), $clinic->getUuid());
|
||||
|
||||
$context = $this->resolver()->resolve($doctor->getUser());
|
||||
|
||||
self::assertSame(['clinic', $clinic->getId()], $context->toEntityPair());
|
||||
}
|
||||
|
||||
public function testClinicMemberDoctorFallsBackToOwnPracticeWhenActiveContextIsSelf(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$clinic = $this->makeClinic();
|
||||
$this->joinClinic($clinic, $doctor);
|
||||
$this->setActiveContext($doctor->getUser(), $doctor->getUuid());
|
||||
|
||||
$context = $this->resolver()->resolve($doctor->getUser());
|
||||
|
||||
self::assertSame(['doctor', $doctor->getId()], $context->toEntityPair());
|
||||
}
|
||||
|
||||
/**
|
||||
* مجوزِ غیرفعال محیط را عوض نمیکند — عضویت در clinic_doctors تعیینکنندهٔ
|
||||
* «کجا»ست و ClinicDoctorPermission تعیینکنندهٔ «چه کاری». محدودسازی در
|
||||
* PatientRecordScopeResolver اتفاق میافتد، نه اینجا.
|
||||
*/
|
||||
public function testInactivePermissionStillResolvesToClinicEnvironment(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$clinic = $this->makeClinic();
|
||||
$this->joinClinic($clinic, $doctor);
|
||||
|
||||
$permission = $this->em->getRepository(ClinicDoctorPermission::class)
|
||||
->findOneBy(['clinic' => $clinic, 'doctor' => $doctor]);
|
||||
$permission->setActive(false);
|
||||
$this->em->flush();
|
||||
|
||||
$this->setActiveContext($doctor->getUser(), $clinic->getUuid());
|
||||
|
||||
$context = $this->resolver()->resolve($doctor->getUser());
|
||||
|
||||
self::assertSame(['clinic', $clinic->getId()], $context->toEntityPair());
|
||||
}
|
||||
|
||||
// ── سناریو ۳: پزشکی که هم عضو و هم مالک کلینیک است ──────────────────────
|
||||
|
||||
public function testDoctorWhoOwnsClinicResolvesToClinicWithoutMembershipRow(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']);
|
||||
$doctor = $this->makeDoctor($user);
|
||||
$clinic = $this->makeClinic($user);
|
||||
$this->setActiveContext($user, $clinic->getUuid());
|
||||
|
||||
$context = $this->resolver()->resolve($user);
|
||||
|
||||
self::assertSame(['clinic', $clinic->getId()], $context->toEntityPair());
|
||||
self::assertTrue($this->resolver()->canActInClinic($user, $clinic));
|
||||
}
|
||||
|
||||
public function testDoctorWhoOwnsClinicResolvesToOwnPracticeWhenActiveContextIsSelf(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']);
|
||||
$doctor = $this->makeDoctor($user);
|
||||
$this->makeClinic($user);
|
||||
$this->setActiveContext($user, $doctor->getUuid());
|
||||
|
||||
$context = $this->resolver()->resolve($user);
|
||||
|
||||
self::assertSame(['doctor', $doctor->getId()], $context->toEntityPair());
|
||||
}
|
||||
|
||||
// ── سناریو ۴: مدیر/مالک کلینیک (غیرپزشک) ────────────────────────────────
|
||||
|
||||
public function testClinicManagerAlwaysResolvesToClinic(): void
|
||||
{
|
||||
$clinic = $this->makeClinic();
|
||||
|
||||
$context = $this->resolver()->resolve($clinic->getUser());
|
||||
|
||||
self::assertSame(['clinic', $clinic->getId()], $context->toEntityPair());
|
||||
}
|
||||
|
||||
// ── سناریو ۵: منشی ──────────────────────────────────────────────────────
|
||||
|
||||
public function testSecretaryWithoutActiveContextResolvesToUnknown(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_SECRETARY']);
|
||||
|
||||
$context = $this->resolver()->resolve($user);
|
||||
|
||||
self::assertSame(EntityContext::TYPE_UNKNOWN, $context->type);
|
||||
self::assertFalse($context->isResolved());
|
||||
}
|
||||
|
||||
public function testSecretaryWithActiveClinicRelationResolvesToThatClinic(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$clinic = $this->makeClinic();
|
||||
$this->joinClinic($clinic, $doctor);
|
||||
$secretary = $this->createUser(['ROLE_SECRETARY']);
|
||||
|
||||
$this->em->persist(new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic));
|
||||
$this->em->flush();
|
||||
$this->setActiveContext($secretary, $clinic->getUuid());
|
||||
|
||||
$context = $this->resolver()->resolve($secretary);
|
||||
|
||||
self::assertSame(['clinic', $clinic->getId()], $context->toEntityPair());
|
||||
}
|
||||
|
||||
public function testSecretaryWithActiveDoctorRelationResolvesToThatPractice(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$secretary = $this->createUser(['ROLE_SECRETARY']);
|
||||
|
||||
$this->em->persist(new DoctorSecretary($doctor, $secretary));
|
||||
$this->em->flush();
|
||||
$this->setActiveContext($secretary, $doctor->getUuid());
|
||||
|
||||
$context = $this->resolver()->resolve($secretary);
|
||||
|
||||
self::assertSame(['doctor', $doctor->getId()], $context->toEntityPair());
|
||||
}
|
||||
|
||||
/** رابطهٔ غیرفعال = پایان همکاری؛ محیط دیگر حل نمیشود. */
|
||||
public function testSecretaryWithInactiveRelationResolvesToUnknown(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$clinic = $this->makeClinic();
|
||||
$this->joinClinic($clinic, $doctor);
|
||||
$secretary = $this->createUser(['ROLE_SECRETARY']);
|
||||
|
||||
$relation = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic);
|
||||
$relation->setActive(false);
|
||||
$this->em->persist($relation);
|
||||
$this->em->flush();
|
||||
$this->setActiveContext($secretary, $clinic->getUuid());
|
||||
|
||||
$context = $this->resolver()->resolve($secretary);
|
||||
|
||||
self::assertFalse($context->isResolved());
|
||||
}
|
||||
|
||||
// ── مسیر clinic_uuid صریح ───────────────────────────────────────────────
|
||||
|
||||
public function testExplicitClinicUuidForNonMemberThrowsAccessDenied(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$clinic = $this->makeClinic();
|
||||
|
||||
$this->expectException(AppException::class);
|
||||
$this->resolver()->resolve($doctor->getUser(), $clinic->getUuid());
|
||||
}
|
||||
|
||||
public function testTryResolveReturnsNullInsteadOfThrowingForNonMember(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$clinic = $this->makeClinic();
|
||||
|
||||
self::assertNull($this->resolver()->tryResolve($doctor->getUser(), $clinic->getUuid()));
|
||||
}
|
||||
|
||||
public function testExplicitClinicUuidWinsOverStoredActiveContext(): void
|
||||
{
|
||||
$doctor = $this->makeDoctor();
|
||||
$clinicA = $this->makeClinic();
|
||||
$clinicB = $this->makeClinic();
|
||||
$this->joinClinic($clinicA, $doctor);
|
||||
$this->joinClinic($clinicB, $doctor);
|
||||
$this->setActiveContext($doctor->getUser(), $clinicA->getUuid());
|
||||
|
||||
$context = $this->resolver()->resolve($doctor->getUser(), $clinicB->getUuid());
|
||||
|
||||
self::assertSame(['clinic', $clinicB->getId()], $context->toEntityPair());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user