From 2e0888e0ef71f80716c206811066db9da8b31a62 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 28 Jul 2026 11:56:57 +0330 Subject: [PATCH] refactor(tenant): give every table one spelling of the tenant pair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the tenant-marking series. The same concept was written four ways, and the Doctrine filter arriving in phase 4 keys on the field name — so the tables using a different spelling would have been skipped silently, which is exactly the leak this work exists to prevent. - discount_rules: owner_type/owner_id renamed to entity_type/entity_id. Pure rename, no data moves. - doctor_secretaries: owner_type plus a nullable clinic_id replaced by the shared pair. The environment now comes from the clinic argument alone, so the inconsistent combination (owner_type='clinic', clinic_id=NULL) can no longer be constructed, and the redundant constructor parameter is gone. - user_active_context: added db_type, so resolving an environment is one lookup instead of "try clinics, then try doctors". Filled from the type already present in available_contexts. - entity_type is VARCHAR(10) in all twenty tenant tables; four of them were 20. Behaviour change, the only one in this series: the doctor_secretaries unique key went from (doctor_id, secretary_id, owner_type) to (doctor_id, secretary_id, entity_type, entity_id). With clinic_id outside the key, one secretary could not be assigned to the same doctor in two clinics — the second row collided on owner_type='clinic'. The duplicate check in SecretaryController had the same blind spot and would have rejected the request before the database saw it; both are fixed together. Correcting an assumption from the phase-3 plan: mobile_verification_otp.entity_type really is a tenant pair. NotificationMobileController validates the target against ['doctor','clinic'] and stores that entity's id, so the column was normalised with the rest rather than treated as unrelated. TenantOwnedTrait gained assignTenantPair() for callers that resolved the pair as scalars and hold no entity — building an EntityContext from scalars would produce one where isClinic() is true but ->clinic is null, breaking consumers silently. tests/ApiTestCase::createUser now retries on a duplicate mobile. db_test is never reset and already holds ~38k users, so the 9-digit random draw collided often enough to fail unrelated tests a few percent of runs. Tests: 830 passing. PHPStan reports no new errors on the changed files. Co-Authored-By: Claude Opus 5 (1M context) --- docs/api/auth.md | 2 + docs/api/secretary.md | 5 +- migrations/Version20260728080123.php | 38 ++++ migrations/Version20260728080516.php | 82 ++++++++ migrations/Version20260728080955.php | 64 ++++++ migrations/Version20260728081650.php | 41 ++++ src/Auth/Controller/AuthController.php | 8 +- src/Auth/Entity/MobileVerificationOtp.php | 2 +- src/Auth/Entity/UserActiveContext.php | 29 ++- .../UserActiveContextRepository.php | 6 +- src/Discount/Entity/DiscountRule.php | 20 +- .../Repository/DiscountRuleRepository.php | 14 +- src/Inventory/Entity/InventoryItem.php | 2 +- src/Inventory/Entity/InventoryPackage.php | 2 +- .../Controller/SecretaryController.php | 17 +- src/Secretary/Entity/DoctorSecretary.php | 28 ++- .../Repository/DoctorSecretaryRepository.php | 18 +- src/Secretary/Service/SecretaryService.php | 6 +- src/Shared/Context/EntityContextResolver.php | 14 +- src/Shared/Tenant/TenantOwnedTrait.php | 23 +++ src/Tag/Entity/TenantTag.php | 2 +- tests/ApiTestCase.php | 29 ++- .../ClinicAppointmentAccessTest.php | 6 +- .../ClinicDoctorPermissionEnforcementTest.php | 2 +- tests/Patient/ClinicRecordAccessTest.php | 21 +- tests/PaymentMethod/PaymentMethodTest.php | 10 +- .../SecretaryAppointmentScopeTest.php | 6 +- tests/Secretary/SecretaryListNPlusOneTest.php | 2 +- .../SecretaryMultiClinicScopeTest.php | 107 ++++++++++ tests/Secretary/SecretaryOnlineShareTest.php | 7 +- .../SecretaryResourceEnforcementTest.php | 12 +- tests/Shared/EntityContextResolverTest.php | 36 ++-- tests/Shared/TenantIsolationMatrixTest.php | 187 ++++++++++++++++++ 33 files changed, 728 insertions(+), 120 deletions(-) create mode 100644 migrations/Version20260728080123.php create mode 100644 migrations/Version20260728080516.php create mode 100644 migrations/Version20260728080955.php create mode 100644 migrations/Version20260728081650.php create mode 100644 tests/Secretary/SecretaryMultiClinicScopeTest.php create mode 100644 tests/Shared/TenantIsolationMatrixTest.php diff --git a/docs/api/auth.md b/docs/api/auth.md index 9d95926a..bdd416e5 100644 --- a/docs/api/auth.md +++ b/docs/api/auth.md @@ -349,6 +349,8 @@ Authorization: Bearer - اگر چند context وجود دارد و کاربر هنوز انتخاب نکرده: `null` — frontend باید صفحه انتخاب نشان دهد - پس از `POST /api/v1/auth/switch-context`: برابر context انتخاب‌شده +> **سمت سرور:** جدول `user_active_context` علاوه بر `db_uuid` ستون `db_type` (`doctor` یا `clinic`) هم دارد که از `available_contexts[].type` پر می‌شود. بدون آن، هر بار حل‌کردن محیط دو کوئری می‌خواست: اول کلینیک با آن uuid، بعد پزشک. این ستون در پاسخ API ظاهر نمی‌شود و قرارداد frontend را عوض نمی‌کند. + ### Errors | Code | HTTP | Description | |------|------|-------------| diff --git a/docs/api/secretary.md b/docs/api/secretary.md index 3b422d84..bf9a9824 100644 --- a/docs/api/secretary.md +++ b/docs/api/secretary.md @@ -13,7 +13,8 @@ - یک منشی می‌تواند هم در مطب شخصی یک دکتر و هم در کلینیک همان دکتر فعال باشد (دو ردیف مجزا) - منشی کلینیک می‌تواند به چند دکتر در همان کلینیک متصل باشد -- scope فعال در runtime از جدول `user_active_context` (db_uuid) خوانده می‌شود +- **یک منشی می‌تواند به همان دکتر در چند کلینیک متفاوت تخصیص یابد** (یک ردیف به ازای هر کلینیک). تا پیش از این، کلید یکتا فقط `(doctor_id, secretary_id, owner_type)` بود و کلینیکِ دوم را تکراری می‌شمرد؛ حالا خودِ محیط هم بخشی از هویت رابطه است +- scope فعال در runtime از جدول `user_active_context` خوانده می‌شود — `db_uuid` به‌همراه `db_type` که می‌گوید uuid مالِ پزشک است یا کلینیک - **محدودسازی به پزشکانِ تخصیص‌یافته:** منشیِ کلینیک فقط نوبت‌های پزشکانی را می‌بیند/رزرو می‌کند که واقعاً به او تخصیص داده شده‌اند — نه همه‌ی پزشکان کلینیک. لیست نوبت (`GET /api/v1/my/appointments`) با `a.doctor IN (پزشکانِ تخصیص‌یافته)` فیلتر می‌شود و گیت رزرو (`POST /api/v1/my/appointment`) رابطه‌ی فعالِ همان (منشی، کلینیک، پزشک) را چک می‌کند. permission رزرو از همان ردیفِ پزشک خوانده می‌شود Secretaries are linked to a doctor and have granular permissions controlling what they can do on behalf of the doctor. @@ -244,7 +245,7 @@ Create a secretary for a doctor. | `ERR_AUTH_001` | 401 | Missing token | | `ERR_AUTH_006` | 403 | Not the doctor owner / clinic owner / admin | | `ERR_NOT_FOUND_001` | 404 | Doctor not found | -| `ERR_CONFLICT_001` | 409 | Secretary already added for this doctor | +| `ERR_CONFLICT_001` | 409 | Secretary already added for this doctor **in this same environment** — همان منشی برای همان پزشک در کلینیکِ دیگر ۴۰۹ نمی‌گیرد | | `ERR_SECRETARY_001` | 422 | Plan limit for secretaries reached | --- diff --git a/migrations/Version20260728080123.php b/migrations/Version20260728080123.php new file mode 100644 index 00000000..f4285b62 --- /dev/null +++ b/migrations/Version20260728080123.php @@ -0,0 +1,38 @@ +addSql('DROP INDEX idx_discount_rules_owner ON discount_rules'); + $this->addSql('ALTER TABLE discount_rules CHANGE owner_type entity_type VARCHAR(10) NOT NULL, CHANGE owner_id entity_id INT NOT NULL'); + $this->addSql('CREATE INDEX idx_discount_rules_tenant ON discount_rules (entity_type, entity_id, active)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX idx_discount_rules_tenant ON discount_rules'); + $this->addSql('ALTER TABLE discount_rules CHANGE entity_type owner_type VARCHAR(10) NOT NULL, CHANGE entity_id owner_id INT NOT NULL'); + $this->addSql('CREATE INDEX idx_discount_rules_owner ON discount_rules (owner_type, owner_id, active)'); + } +} diff --git a/migrations/Version20260728080516.php b/migrations/Version20260728080516.php new file mode 100644 index 00000000..aa227293 --- /dev/null +++ b/migrations/Version20260728080516.php @@ -0,0 +1,82 @@ +connection so the guards can sit between the + * backfill and the NOT NULL change; addSql() would defer them to the end. + */ +final class Version20260728080516 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Replace doctor_secretaries.owner_type with the shared tenant pair and widen its unique key'; + } + + public function up(Schema $schema): void + { + $this->connection->executeStatement( + 'ALTER TABLE doctor_secretaries ADD entity_type VARCHAR(10) NULL, ADD entity_id INT NULL' + ); + + // A row claiming the clinic environment without a clinic is data we cannot + // interpret; stop and let a human decide rather than guessing an owner. + $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 (owner_type=clinic, clinic_id NULL): ' . implode(',', $broken) + ); + + $this->connection->executeStatement( + "UPDATE doctor_secretaries + SET entity_type = owner_type, + entity_id = IF(owner_type = 'clinic', clinic_id, doctor_id)" + ); + + $remaining = (int) $this->connection->fetchOne( + 'SELECT COUNT(*) FROM doctor_secretaries WHERE entity_type IS NULL OR entity_id IS NULL' + ); + $this->abortIf($remaining > 0, "Backfill left {$remaining} doctor_secretaries rows without a tenant."); + + $this->connection->executeStatement( + 'ALTER TABLE doctor_secretaries MODIFY entity_type VARCHAR(10) NOT NULL, MODIFY entity_id INT NOT NULL' + ); + + // Replacement key first, so the table is never without uniqueness cover. + $this->connection->executeStatement( + 'CREATE UNIQUE INDEX uniq_doctor_secretary_scope + ON doctor_secretaries (doctor_id, secretary_id, entity_type, entity_id)' + ); + $this->connection->executeStatement('DROP INDEX idx_doctor_secretary_scope ON doctor_secretaries'); + $this->connection->executeStatement('ALTER TABLE doctor_secretaries DROP owner_type'); + } + + public function down(Schema $schema): void + { + $this->addSql("ALTER TABLE doctor_secretaries ADD owner_type VARCHAR(10) DEFAULT 'doctor' NOT NULL"); + $this->addSql('UPDATE doctor_secretaries SET owner_type = entity_type'); + $this->addSql('CREATE UNIQUE INDEX idx_doctor_secretary_scope ON doctor_secretaries (doctor_id, secretary_id, owner_type)'); + $this->addSql('DROP INDEX uniq_doctor_secretary_scope ON doctor_secretaries'); + $this->addSql('ALTER TABLE doctor_secretaries DROP entity_type, DROP entity_id'); + } + + /** DDL on MariaDB commits implicitly; wrapping up() in a transaction would be a lie. */ + public function isTransactional(): bool + { + return false; + } +} diff --git a/migrations/Version20260728080955.php b/migrations/Version20260728080955.php new file mode 100644 index 00000000..0b81459d --- /dev/null +++ b/migrations/Version20260728080955.php @@ -0,0 +1,64 @@ +connection->executeStatement( + 'ALTER TABLE user_active_context ADD db_type VARCHAR(10) NULL' + ); + + $this->connection->executeStatement( + "UPDATE user_active_context uac JOIN clinics c ON c.uuid = uac.db_uuid SET uac.db_type = 'clinic'" + ); + $this->connection->executeStatement( + "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" + ); + + $orphans = (int) $this->connection->executeStatement( + 'DELETE FROM user_active_context WHERE db_type IS NULL' + ); + if ($orphans > 0) { + $this->write("Dropped {$orphans} active-context rows whose db_uuid no longer resolves."); + } + + $this->connection->executeStatement( + 'ALTER TABLE user_active_context MODIFY db_type VARCHAR(10) NOT NULL' + ); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE user_active_context DROP db_type'); + } + + /** DDL on MariaDB commits implicitly; wrapping up() in a transaction would be a lie. */ + public function isTransactional(): bool + { + return false; + } +} diff --git a/migrations/Version20260728081650.php b/migrations/Version20260728081650.php new file mode 100644 index 00000000..2fd53f94 --- /dev/null +++ b/migrations/Version20260728081650.php @@ -0,0 +1,41 @@ +addSql('ALTER TABLE inventory_items CHANGE entity_type entity_type VARCHAR(10) NOT NULL'); + $this->addSql('ALTER TABLE inventory_packages CHANGE entity_type entity_type VARCHAR(10) NOT NULL'); + $this->addSql('ALTER TABLE mobile_verification_otp CHANGE entity_type entity_type VARCHAR(10) NOT NULL'); + $this->addSql('ALTER TABLE tenant_tags CHANGE entity_type entity_type VARCHAR(10) NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE inventory_items CHANGE entity_type entity_type VARCHAR(20) NOT NULL'); + $this->addSql('ALTER TABLE inventory_packages CHANGE entity_type entity_type VARCHAR(20) NOT NULL'); + $this->addSql('ALTER TABLE mobile_verification_otp CHANGE entity_type entity_type VARCHAR(20) NOT NULL'); + $this->addSql('ALTER TABLE tenant_tags CHANGE entity_type entity_type VARCHAR(20) NOT NULL'); + } +} diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index eef14c96..e2badc66 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -597,7 +597,11 @@ class AuthController extends BaseController // اگر یک context داری، خودکار فعال کن $activeCtx = $this->contextRepo->findByUser($user); if ($activeCtx === null && count($availableContexts) === 1) { - $activeCtx = $this->contextRepo->upsert($user, $availableContexts[0]['db_uuid']); + $activeCtx = $this->contextRepo->upsert( + $user, + $availableContexts[0]['db_uuid'], + $availableContexts[0]['type'], + ); } $dbUuid = $activeCtx?->getDbUuid(); @@ -663,7 +667,7 @@ class AuthController extends BaseController return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی به این محیط کاری مجاز نیست', 403); } - $this->contextRepo->upsert($user, $dbUuid); + $this->contextRepo->upsert($user, $dbUuid, $matched['type']); return $this->success([ 'db_uuid' => $dbUuid, diff --git a/src/Auth/Entity/MobileVerificationOtp.php b/src/Auth/Entity/MobileVerificationOtp.php index c7266e78..cec75046 100644 --- a/src/Auth/Entity/MobileVerificationOtp.php +++ b/src/Auth/Entity/MobileVerificationOtp.php @@ -14,7 +14,7 @@ class MobileVerificationOtp #[ORM\Column(type: 'integer')] private ?int $id = null; - #[ORM\Column(name: 'entity_type', type: 'string', length: 20)] + #[ORM\Column(name: 'entity_type', type: 'string', length: 10)] private string $entityType; #[ORM\Column(name: 'entity_id', type: 'integer')] diff --git a/src/Auth/Entity/UserActiveContext.php b/src/Auth/Entity/UserActiveContext.php index d6317c5b..ec96e20e 100644 --- a/src/Auth/Entity/UserActiveContext.php +++ b/src/Auth/Entity/UserActiveContext.php @@ -2,9 +2,16 @@ namespace App\Auth\Entity; +use App\Shared\Context\EntityContext; use Doctrine\ORM\Mapping as ORM; use App\Auth\Repository\UserActiveContextRepository; + +/** + * آخرین محیط کاری انتخاب‌شدهٔ کاربر. db_uuid به‌تنهایی نمی‌گوید مالِ کلینیک است یا + * پزشک، پس db_type هم ذخیره می‌شود تا EntityContextResolver با یک lookup به نتیجه + * برسد به‌جای اینکه اول کلینیک را امتحان کند و بعد پزشک را. + */ #[ORM\Entity(repositoryClass: UserActiveContextRepository::class)] #[ORM\Table(name: 'user_active_context')] class UserActiveContext @@ -17,24 +24,38 @@ class UserActiveContext #[ORM\Column(name: 'db_uuid', type: 'string', length: 36)] private string $dbUuid; + /** 'doctor' یا 'clinic' — کدام موجودیت را db_uuid آدرس می‌دهد. */ + #[ORM\Column(name: 'db_type', type: 'string', length: 10)] + private string $dbType; + #[ORM\Column(name: 'updated_at', type: 'integer')] private int $updatedAt; - public function __construct(User $user, string $dbUuid) + public function __construct(User $user, string $dbUuid, string $dbType) { $this->user = $user; - $this->dbUuid = $dbUuid; $this->updatedAt = time(); + $this->setContext($dbUuid, $dbType); } - public function getUser(): User { return $this->user; } + public function getUser(): User { return $this->user; } public function getDbUuid(): string { return $this->dbUuid; } + public function getDbType(): string { return $this->dbType; } public function getUpdatedAt(): int { return $this->updatedAt; } - public function setDbUuid(string $dbUuid): self + public function isClinic(): bool { return $this->dbType === EntityContext::TYPE_CLINIC; } + + /** @throws \InvalidArgumentException اگر نوع محیط یکی از doctor/clinic نباشد */ + public function setContext(string $dbUuid, string $dbType): self { + if (!in_array($dbType, [EntityContext::TYPE_DOCTOR, EntityContext::TYPE_CLINIC], true)) { + throw new \InvalidArgumentException("Unknown active-context type: {$dbType}"); + } + $this->dbUuid = $dbUuid; + $this->dbType = $dbType; $this->updatedAt = time(); + return $this; } } diff --git a/src/Auth/Repository/UserActiveContextRepository.php b/src/Auth/Repository/UserActiveContextRepository.php index a5d1b1d0..7d38afb5 100644 --- a/src/Auth/Repository/UserActiveContextRepository.php +++ b/src/Auth/Repository/UserActiveContextRepository.php @@ -19,14 +19,14 @@ class UserActiveContextRepository extends ServiceEntityRepository return $this->findOneBy(['user' => $user]); } - public function upsert(User $user, string $dbUuid): UserActiveContext + public function upsert(User $user, string $dbUuid, string $dbType): UserActiveContext { $ctx = $this->findByUser($user); if ($ctx === null) { - $ctx = new UserActiveContext($user, $dbUuid); + $ctx = new UserActiveContext($user, $dbUuid, $dbType); $this->getEntityManager()->persist($ctx); } else { - $ctx->setDbUuid($dbUuid); + $ctx->setContext($dbUuid, $dbType); } $this->getEntityManager()->flush(); return $ctx; diff --git a/src/Discount/Entity/DiscountRule.php b/src/Discount/Entity/DiscountRule.php index 509c10ad..6879b783 100644 --- a/src/Discount/Entity/DiscountRule.php +++ b/src/Discount/Entity/DiscountRule.php @@ -3,18 +3,21 @@ namespace App\Discount\Entity; use App\Discount\Repository\DiscountRuleRepository; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; /** - * قانون تخفیف عمومی، per-tenant (owner = doctor|clinic). موتور تخفیف + * قانون تخفیف عمومی، per-tenant (پزشک یا کلینیک). موتور تخفیف * (DiscountEngine) این قوانین را برای یک پرونده ارزیابی می‌کند. */ #[ORM\Entity(repositoryClass: DiscountRuleRepository::class)] #[ORM\Table(name: 'discount_rules')] -#[ORM\Index(columns: ['owner_type', 'owner_id', 'active'], name: 'idx_discount_rules_owner')] +#[ORM\Index(columns: ['entity_type', 'entity_id', 'active'], name: 'idx_discount_rules_tenant')] class DiscountRule { + use TenantOwnedTrait; + public const TYPE_PATIENT_TAG = 'patient_tag'; public const TYPE_INVOICE_AMOUNT = 'invoice_amount'; public const TYPE_SPECIFIC_PATIENT = 'specific_patient'; @@ -45,12 +48,6 @@ class DiscountRule #[ORM\Column(type: 'string', length: 36, unique: true)] private string $uuid; - #[ORM\Column(name: 'owner_type', type: 'string', length: 10)] - private string $ownerType; - - #[ORM\Column(name: 'owner_id', type: 'integer')] - private int $ownerId; - #[ORM\Column(type: 'string', length: 120)] private string $name; @@ -104,21 +101,18 @@ class DiscountRule #[ORM\Column(name: 'updated_at', type: 'integer')] private int $updatedAt; - public function __construct(string $ownerType, int $ownerId, string $name, string $type) + public function __construct(string $entityType, int $entityId, string $name, string $type) { $this->uuid = Uuid::v4()->toRfc4122(); - $this->ownerType = $ownerType; - $this->ownerId = $ownerId; $this->name = $name; $this->type = $type; $this->createdAt = time(); $this->updatedAt = time(); + $this->assignTenantPair($entityType, $entityId); } public function getId(): ?int { return $this->id; } public function getUuid(): string { return $this->uuid; } - public function getOwnerType(): string { return $this->ownerType; } - public function getOwnerId(): int { return $this->ownerId; } public function getName(): string { return $this->name; } public function getType(): string { return $this->type; } public function getDiscountType(): string { return $this->discountType; } diff --git a/src/Discount/Repository/DiscountRuleRepository.php b/src/Discount/Repository/DiscountRuleRepository.php index d54a312e..1798ac1a 100644 --- a/src/Discount/Repository/DiscountRuleRepository.php +++ b/src/Discount/Repository/DiscountRuleRepository.php @@ -26,15 +26,15 @@ class DiscountRuleRepository extends ServiceEntityRepository } } - public function findByUuidForOwner(string $uuid, string $ownerType, int $ownerId): ?DiscountRule + public function findByUuidForOwner(string $uuid, string $entityType, int $entityId): ?DiscountRule { - return $this->findOneBy(['uuid' => $uuid, 'ownerType' => $ownerType, 'ownerId' => $ownerId]); + return $this->findOneBy(['uuid' => $uuid, 'entityType' => $entityType, 'entityId' => $entityId]); } /** @return DiscountRule[] قوانین فعالِ یک owner (برای موتور). */ - public function findActiveForOwner(string $ownerType, int $ownerId): array + public function findActiveForOwner(string $entityType, int $entityId): array { - return $this->findBy(['ownerType' => $ownerType, 'ownerId' => $ownerId, 'active' => true], ['priority' => 'DESC']); + return $this->findBy(['entityType' => $entityType, 'entityId' => $entityId, 'active' => true], ['priority' => 'DESC']); } /** @@ -42,11 +42,11 @@ class DiscountRuleRepository extends ServiceEntityRepository * موجودیت‌ها برمی‌گردند تا کنترلر با toArray() قرارداد snake_case فرانت را بدهد. * @return DiscountRule[] */ - public function findAllForOwner(string $ownerType, int $ownerId): array + public function findAllForOwner(string $entityType, int $entityId): array { return $this->createQueryBuilder('r') - ->where('r.ownerType = :t')->setParameter('t', $ownerType) - ->andWhere('r.ownerId = :i')->setParameter('i', $ownerId) + ->where('r.entityType = :t')->setParameter('t', $entityType) + ->andWhere('r.entityId = :i')->setParameter('i', $entityId) ->orderBy('r.priority', 'DESC')->addOrderBy('r.id', 'DESC') ->getQuery()->getResult(); } diff --git a/src/Inventory/Entity/InventoryItem.php b/src/Inventory/Entity/InventoryItem.php index 8a8d023a..37904d3f 100644 --- a/src/Inventory/Entity/InventoryItem.php +++ b/src/Inventory/Entity/InventoryItem.php @@ -64,7 +64,7 @@ class InventoryItem #[ORM\Column(type: 'string', length: 36, unique: true)] private string $uuid; - #[ORM\Column(name: 'entity_type', type: 'string', length: 20)] + #[ORM\Column(name: 'entity_type', type: 'string', length: 10)] private string $entityType; #[ORM\Column(name: 'entity_id', type: 'integer')] diff --git a/src/Inventory/Entity/InventoryPackage.php b/src/Inventory/Entity/InventoryPackage.php index ee12e953..1628d2c4 100644 --- a/src/Inventory/Entity/InventoryPackage.php +++ b/src/Inventory/Entity/InventoryPackage.php @@ -26,7 +26,7 @@ class InventoryPackage #[ORM\Column(type: 'string', length: 36, unique: true)] private string $uuid; - #[ORM\Column(name: 'entity_type', type: 'string', length: 20)] + #[ORM\Column(name: 'entity_type', type: 'string', length: 10)] private string $entityType; #[ORM\Column(name: 'entity_id', type: 'integer')] diff --git a/src/Secretary/Controller/SecretaryController.php b/src/Secretary/Controller/SecretaryController.php index 25d69f62..d7b0a35e 100644 --- a/src/Secretary/Controller/SecretaryController.php +++ b/src/Secretary/Controller/SecretaryController.php @@ -7,6 +7,7 @@ use App\Auth\Repository\UserRepository; use App\Clinic\Repository\ClinicRepository; use App\Doctor\Repository\DoctorRepository; use App\Secretary\Entity\DoctorSecretary; +use App\Shared\Context\EntityContext; use App\Secretary\Repository\DoctorSecretaryRepository; use App\Secretary\Service\SecretaryService; use App\Shared\Constant\ErrorCodes; @@ -240,8 +241,6 @@ class SecretaryController extends BaseController } } - $ownerType = $ownerClinic !== null ? DoctorSecretary::OWNER_CLINIC : DoctorSecretary::OWNER_DOCTOR; - // Check plan limit $limit = $this->subscriptionService->getSecretaryLimit('doctor', $doctor->getId()); $activeCount = $this->secretaryRepo->countActiveByDoctor($doctor); @@ -270,17 +269,21 @@ class SecretaryController extends BaseController } $this->userRepo->save($secretaryUser); - // Check duplicate within same scope + // تکراری فقط در همان محیط. entityId هم لازم است، وگرنه تخصیص همان منشی به + // همان پزشک در کلینیک دوم — که کلید یکتا اجازه‌اش را می‌دهد — اینجا رد می‌شد. + [$ownerEntityType, $ownerEntityId] = EntityContext::forBooking($doctor, $ownerClinic)->toEntityPair(); + $existing = $this->secretaryRepo->findOneBy([ - 'doctor' => $doctor, - 'secretary' => $secretaryUser, - 'ownerType' => $ownerType, + 'doctor' => $doctor, + 'secretary' => $secretaryUser, + 'entityType' => $ownerEntityType, + 'entityId' => $ownerEntityId, ]); if ($existing !== null) { return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این منشی قبلاً اضافه شده است', 409); } - $secretary = new DoctorSecretary($doctor, $secretaryUser, $ownerType, $ownerClinic); + $secretary = new DoctorSecretary($doctor, $secretaryUser, $ownerClinic); if (array_key_exists('national_code', $data)) { $secretary->setNationalCode($data['national_code'] !== null ? trim((string) $data['national_code']) : null); diff --git a/src/Secretary/Entity/DoctorSecretary.php b/src/Secretary/Entity/DoctorSecretary.php index 2c6e1f35..91bad9c4 100644 --- a/src/Secretary/Entity/DoctorSecretary.php +++ b/src/Secretary/Entity/DoctorSecretary.php @@ -6,16 +6,22 @@ use App\Auth\Entity\User; use App\Clinic\Entity\Clinic; use App\Doctor\Entity\Doctor; use App\Secretary\Repository\DoctorSecretaryRepository; +use App\Shared\Context\EntityContext; +use App\Shared\Tenant\TenantOwnedTrait; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; #[ORM\Entity(repositoryClass: DoctorSecretaryRepository::class)] #[ORM\Table(name: 'doctor_secretaries')] -#[ORM\UniqueConstraint(name: 'idx_doctor_secretary_scope', columns: ['doctor_id', 'secretary_id', 'owner_type'])] +// entity_id در کلید هست تا یک منشی بتواند به همان پزشک در چند کلینیک تخصیص یابد؛ +// کلید قبلی فقط owner_type را داشت و ردیف دومِ 'clinic' را تکراری می‌شمرد. +#[ORM\UniqueConstraint(name: 'uniq_doctor_secretary_scope', columns: ['doctor_id', 'secretary_id', 'entity_type', 'entity_id'])] class DoctorSecretary { - public const OWNER_DOCTOR = 'doctor'; - public const OWNER_CLINIC = 'clinic'; + use TenantOwnedTrait; + + public const OWNER_DOCTOR = EntityContext::TYPE_DOCTOR; + public const OWNER_CLINIC = EntityContext::TYPE_CLINIC; public const DEFAULT_PERMISSIONS = [ 'version' => 1, @@ -54,9 +60,6 @@ class DoctorSecretary #[ORM\JoinColumn(name: 'secretary_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] private User $secretary; - #[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; @@ -90,23 +93,27 @@ class DoctorSecretary #[ORM\Column(name: 'updated_at', type: 'integer')] private int $updatedAt; - public function __construct(Doctor $doctor, User $secretary, string $ownerType = self::OWNER_DOCTOR, ?Clinic $clinic = null) + /** محیط از $clinic می‌آید: null یعنی مطب شخصی همان پزشک. */ + public function __construct(Doctor $doctor, User $secretary, ?Clinic $clinic = null) { $this->uuid = Uuid::v4()->toRfc4122(); $this->doctor = $doctor; $this->secretary = $secretary; - $this->ownerType = $ownerType; $this->clinic = $clinic; $this->permissions = self::DEFAULT_PERMISSIONS; $this->createdAt = time(); $this->updatedAt = time(); + + // clinic تعیین‌کننده است، نه $ownerType: ترکیب ناسازگارِ ('clinic', clinic=null) + // در گذشته ممکن بود ساخته شود و همین ردیف‌ها بودند که یکتایی را می‌شکستند. + $this->assignTenant(EntityContext::forBooking($doctor, $clinic)); } public function getId(): ?int { return $this->id; } public function getUuid(): string { return $this->uuid; } public function getDoctor(): Doctor { return $this->doctor; } public function getSecretary(): User { return $this->secretary; } - public function getOwnerType(): string { return $this->ownerType; } + public function getOwnerType(): string { return $this->entityType; } public function getClinic(): ?Clinic { return $this->clinic; } public function getPermissions(): array { return $this->permissions ?? self::DEFAULT_PERMISSIONS; } public function getNationalCode(): ?string { return $this->nationalCode; } @@ -161,7 +168,8 @@ class DoctorSecretary 'mobile_number' => $this->secretary->getMobileNumber(), 'doctor_name' => $this->doctor->getName(), 'doctor_uuid' => $this->doctor->getUuid(), - 'owner_type' => $this->ownerType, + // کلید پاسخ عمداً owner_type مانده تا قرارداد کلاینت‌ها نشکند. + 'owner_type' => $this->entityType, 'clinic_uuid' => $this->clinic?->getUuid(), 'is_active' => $this->active, 'online_share_enabled' => $this->onlineShareEnabled, diff --git a/src/Secretary/Repository/DoctorSecretaryRepository.php b/src/Secretary/Repository/DoctorSecretaryRepository.php index b8753e5a..d963a901 100644 --- a/src/Secretary/Repository/DoctorSecretaryRepository.php +++ b/src/Secretary/Repository/DoctorSecretaryRepository.php @@ -54,7 +54,7 @@ class DoctorSecretaryRepository extends ServiceEntityRepository { return $this->listWithRelations() ->where('s.doctor = :doctor') - ->andWhere('s.ownerType = :type') + ->andWhere('s.entityType = :type') ->setParameter('doctor', $doctor) ->setParameter('type', DoctorSecretary::OWNER_DOCTOR) ->getQuery() @@ -67,7 +67,7 @@ class DoctorSecretaryRepository extends ServiceEntityRepository return $this->findOneBy([ 'secretary' => $user, 'doctor' => $doctor, - 'ownerType' => DoctorSecretary::OWNER_DOCTOR, + 'entityType' => DoctorSecretary::OWNER_DOCTOR, 'active' => true, ]); } @@ -78,7 +78,7 @@ class DoctorSecretaryRepository extends ServiceEntityRepository return $this->createQueryBuilder('s') ->where('s.secretary = :user') ->andWhere('s.clinic = :clinic') - ->andWhere('s.ownerType = :type') + ->andWhere('s.entityType = :type') ->andWhere('s.active = true') ->setParameter('user', $user) ->setParameter('clinic', $clinic) @@ -95,7 +95,7 @@ class DoctorSecretaryRepository extends ServiceEntityRepository 'secretary' => $user, 'clinic' => $clinic, 'doctor' => $doctor, - 'ownerType' => DoctorSecretary::OWNER_CLINIC, + 'entityType' => DoctorSecretary::OWNER_CLINIC, 'active' => true, ]); } @@ -109,7 +109,7 @@ class DoctorSecretaryRepository extends ServiceEntityRepository ->join(DoctorSecretary::class, 's', 'WITH', 's.doctor = d') ->where('s.secretary = :user') ->andWhere('s.clinic = :clinic') - ->andWhere('s.ownerType = :type') + ->andWhere('s.entityType = :type') ->andWhere('s.active = true') ->setParameter('user', $user) ->setParameter('clinic', $clinic) @@ -143,7 +143,7 @@ class DoctorSecretaryRepository extends ServiceEntityRepository ->join('s.doctor', 'doc') ->where('s.clinic = :clinic') ->andWhere('s.secretary = :secretary') - ->andWhere('s.ownerType = :type') + ->andWhere('s.entityType = :type') ->setParameter('clinic', $clinic) ->setParameter('secretary', $secretary) ->setParameter('type', DoctorSecretary::OWNER_CLINIC) @@ -156,7 +156,7 @@ class DoctorSecretaryRepository extends ServiceEntityRepository { return $this->listWithRelations() ->where('s.clinic = :clinic') - ->andWhere('s.ownerType = :type') + ->andWhere('s.entityType = :type') ->setParameter('clinic', $clinic) ->setParameter('type', DoctorSecretary::OWNER_CLINIC) ->getQuery() @@ -180,12 +180,12 @@ class DoctorSecretaryRepository extends ServiceEntityRepository if ($clinic !== null) { $qb->andWhere('s.clinic = :clinic') - ->andWhere('s.ownerType = :type') + ->andWhere('s.entityType = :type') ->setParameter('clinic', $clinic) ->setParameter('type', DoctorSecretary::OWNER_CLINIC); } else { $qb->andWhere('s.doctor = :doctor') - ->andWhere('s.ownerType = :type') + ->andWhere('s.entityType = :type') ->setParameter('doctor', $doctor) ->setParameter('type', DoctorSecretary::OWNER_DOCTOR); } diff --git a/src/Secretary/Service/SecretaryService.php b/src/Secretary/Service/SecretaryService.php index 14919c6f..7fe31fc9 100644 --- a/src/Secretary/Service/SecretaryService.php +++ b/src/Secretary/Service/SecretaryService.php @@ -87,7 +87,7 @@ class SecretaryService $existing = $this->secretaryRepo->findOneBy([ 'doctor' => $doctor, 'secretary' => $secretary, - 'ownerType' => DoctorSecretary::OWNER_CLINIC, + 'entityType' => DoctorSecretary::OWNER_CLINIC, ]); if ($existing !== null) { if ($existing->isActive()) { @@ -104,7 +104,7 @@ class SecretaryService $skippedLimit[] = $doctorUuid; continue; } - $row = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic); + $row = new DoctorSecretary($doctor, $secretary, $clinic); $this->applyMeta($row, $meta); $this->secretaryRepo->save($row, false); $created[] = $row; @@ -164,7 +164,7 @@ class SecretaryService $skippedLimit[] = $doctorUuid; continue; } - $row = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic); + $row = new DoctorSecretary($doctor, $secretary, $clinic); if ($template !== null) { $row->setNationalCode($template->getNationalCode()) ->setAddress($template->getAddress()) diff --git a/src/Shared/Context/EntityContextResolver.php b/src/Shared/Context/EntityContextResolver.php index 174fe140..708b5b5c 100644 --- a/src/Shared/Context/EntityContextResolver.php +++ b/src/Shared/Context/EntityContextResolver.php @@ -95,10 +95,7 @@ class EntityContextResolver } } - /** - * محیط فعالِ ذخیره‌شده. db_uuid یا uuid کلینیک است یا uuid پزشک؛ کلینیک اول - * بررسی می‌شود چون پزشکِ دعوت‌شده هم db_uuid کلینیک را ذخیره می‌کند. - */ + /** محیط فعالِ ذخیره‌شده؛ db_type می‌گوید db_uuid کدام موجودیت را آدرس می‌دهد. */ private function fromActiveContext(User $user): ?EntityContext { $active = $this->activeContextRepo->findByUser($user); @@ -106,9 +103,12 @@ class EntityContextResolver return null; } - $clinic = $this->clinicRepo->findByUuid($active->getDbUuid()); - if ($clinic !== null) { - return $this->canActInClinic($user, $clinic) ? EntityContext::forClinic($clinic) : null; + if ($active->isClinic()) { + $clinic = $this->clinicRepo->findByUuid($active->getDbUuid()); + + return $clinic !== null && $this->canActInClinic($user, $clinic) + ? EntityContext::forClinic($clinic) + : null; } $doctor = $this->doctorRepo->findByUuid($active->getDbUuid()); diff --git a/src/Shared/Tenant/TenantOwnedTrait.php b/src/Shared/Tenant/TenantOwnedTrait.php index f28632c0..e2107250 100644 --- a/src/Shared/Tenant/TenantOwnedTrait.php +++ b/src/Shared/Tenant/TenantOwnedTrait.php @@ -43,4 +43,27 @@ trait TenantOwnedTrait [$this->entityType, $this->entityId] = $context->toEntityPair(); } + + /** + * برای فراخوانی‌هایی که جفت را از قبل حل کرده‌اند و موجودیت را در دست ندارند — + * مثل DiscountController که owner را به‌صورت اسکالر از resolveOwner می‌گیرد. + * ساختن EntityContext از روی اسکالر ممکن نیست: چنین contextی isClinic() درست + * می‌دهد ولی ->clinic تهی دارد و مصرف‌کننده را بی‌صدا می‌شکند. + * + * @throws \InvalidArgumentException اگر جفت معتبر نباشد + */ + public function assignTenantPair(string $entityType, int $entityId): void + { + if (!in_array($entityType, [EntityContext::TYPE_DOCTOR, EntityContext::TYPE_CLINIC], true) || $entityId <= 0) { + throw new \InvalidArgumentException(sprintf( + 'Invalid tenant pair (%s, %d) for %s.', + $entityType, + $entityId, + static::class, + )); + } + + $this->entityType = $entityType; + $this->entityId = $entityId; + } } diff --git a/src/Tag/Entity/TenantTag.php b/src/Tag/Entity/TenantTag.php index 0d26255c..e218be44 100644 --- a/src/Tag/Entity/TenantTag.php +++ b/src/Tag/Entity/TenantTag.php @@ -24,7 +24,7 @@ class TenantTag #[ORM\Column(type: 'string', length: 36, unique: true)] private string $uuid; - #[ORM\Column(name: 'entity_type', type: 'string', length: 20)] + #[ORM\Column(name: 'entity_type', type: 'string', length: 10)] private string $entityType; #[ORM\Column(name: 'entity_id', type: 'integer')] diff --git a/tests/ApiTestCase.php b/tests/ApiTestCase.php index d26aa30b..e7aaee5a 100644 --- a/tests/ApiTestCase.php +++ b/tests/ApiTestCase.php @@ -10,6 +10,7 @@ use App\Clinic\Entity\Clinic; use App\Doctor\Entity\Doctor; use App\Shared\Context\EntityContext; use App\Subscription\Entity\SubscriptionPlan; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; use Symfony\Bundle\FrameworkBundle\KernelBrowser; @@ -64,9 +65,31 @@ abstract class ApiTestCase extends WebTestCase */ protected function createUser(array $roles = ['ROLE_USER'], ?string $mobile = null): User { - // 9 random digits after 09 (full ^09\d{9}$ space) — db_test is never reset, - // so a narrower space eventually collides on the unique mobile. - $mobile ??= '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT); + if ($mobile !== null) { + return $this->persistUser($mobile, $roles); + } + + // 9 random digits after 09 (full ^09\d{9}$ space). db_test is never reset and + // already holds tens of thousands of users, so a draw does collide now and + // then; retry rather than fail an unrelated test on a birthday collision. + for ($attempt = 0; ; $attempt++) { + try { + return $this->persistUser( + '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT), + $roles, + ); + } catch (UniqueConstraintViolationException $e) { + if ($attempt >= 4) { + throw $e; + } + // The failed INSERT closed the EntityManager; reopen before retrying. + $this->em = static::getContainer()->get(EntityManagerInterface::class); + } + } + } + + private function persistUser(string $mobile, array $roles): User + { $user = new User($mobile); $user->setRoles($roles); $user->setStatus(1); diff --git a/tests/Appointment/ClinicAppointmentAccessTest.php b/tests/Appointment/ClinicAppointmentAccessTest.php index 52d8790b..537d5a13 100644 --- a/tests/Appointment/ClinicAppointmentAccessTest.php +++ b/tests/Appointment/ClinicAppointmentAccessTest.php @@ -9,6 +9,7 @@ use App\Clinic\Entity\Clinic; use App\Clinic\Repository\ClinicDoctorPermissionRepository; use App\Doctor\Entity\Doctor; use App\Secretary\Entity\DoctorSecretary; +use App\Shared\Context\EntityContext; use App\Tests\ApiTestCase; /** @@ -59,14 +60,15 @@ class ClinicAppointmentAccessTest extends ApiTestCase private function makeClinicSecretary(Clinic $clinic, Doctor $doctor, array $permissionPatch = []): User { $user = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']); - $secretary = new DoctorSecretary($doctor, $user, DoctorSecretary::OWNER_CLINIC, $clinic); + $secretary = new DoctorSecretary($doctor, $user, $clinic); if ($permissionPatch !== []) { $secretary->mergePermissions(['resources' => ['appointments' => $permissionPatch]]); } $this->em->persist($secretary); $this->em->flush(); - static::getContainer()->get(UserActiveContextRepository::class)->upsert($user, $clinic->getUuid()); + static::getContainer()->get(UserActiveContextRepository::class) + ->upsert($user, $clinic->getUuid(), EntityContext::TYPE_CLINIC); return $user; } diff --git a/tests/Clinic/ClinicDoctorPermissionEnforcementTest.php b/tests/Clinic/ClinicDoctorPermissionEnforcementTest.php index 0baf6ea7..17b85ba0 100644 --- a/tests/Clinic/ClinicDoctorPermissionEnforcementTest.php +++ b/tests/Clinic/ClinicDoctorPermissionEnforcementTest.php @@ -30,7 +30,7 @@ class ClinicDoctorPermissionEnforcementTest extends ApiTestCase $perm = new ClinicDoctorPermission($clinic, $doctor); $this->em->persist($perm); // محیطِ فعالِ پزشک = کلینیک، تا memberClinicId او را به کلینیک ببرد. - $this->em->persist(new UserActiveContext($doctorUser, $clinic->getUuid())); + $this->em->persist(new UserActiveContext($doctorUser, $clinic->getUuid(), 'clinic')); return [$doctorUser, $perm]; } diff --git a/tests/Patient/ClinicRecordAccessTest.php b/tests/Patient/ClinicRecordAccessTest.php index 879c6e39..ebbc5e88 100644 --- a/tests/Patient/ClinicRecordAccessTest.php +++ b/tests/Patient/ClinicRecordAccessTest.php @@ -10,6 +10,7 @@ use App\Clinic\Repository\ClinicDoctorPermissionRepository; use App\Doctor\Entity\Doctor; use App\Patient\Entity\PatientRecord; use App\Secretary\Entity\DoctorSecretary; +use App\Shared\Context\EntityContext; use App\Tests\ApiTestCase; /** @@ -47,9 +48,9 @@ class ClinicRecordAccessTest extends ApiTestCase return [$owner, $clinic]; } - private function activeContext(User $user, string $dbUuid): void + private function activeContext(User $user, string $dbUuid, string $dbType): void { - static::getContainer()->get(UserActiveContextRepository::class)->upsert($user, $dbUuid); + static::getContainer()->get(UserActiveContextRepository::class)->upsert($user, $dbUuid, $dbType); } /** پروندهٔ کلینیکی بیمار + نوبتی که او را به این پزشک وصل می‌کند. */ @@ -82,7 +83,7 @@ class ClinicRecordAccessTest extends ApiTestCase [, $clinic] = $this->makeClinicWith($doctor); $record = $this->makeClinicRecordFor($clinic, $doctor); - $this->activeContext($doctor->getUser(), $clinic->getUuid()); + $this->activeContext($doctor->getUser(), $clinic->getUuid(), EntityContext::TYPE_CLINIC); $res = $this->authJson('GET', '/api/v1/patients?limit=50', $doctor->getUser()); @@ -97,7 +98,7 @@ class ClinicRecordAccessTest extends ApiTestCase [, $clinic] = $this->makeClinicWith($mine, $theirs); $foreign = $this->makeClinicRecordFor($clinic, $theirs); - $this->activeContext($mine->getUser(), $clinic->getUuid()); + $this->activeContext($mine->getUser(), $clinic->getUuid(), EntityContext::TYPE_CLINIC); $res = $this->authJson('GET', '/api/v1/patients?limit=50', $mine->getUser()); self::assertNotContains($foreign->getUuid(), $this->uuidsFromList($res)); @@ -112,7 +113,7 @@ class ClinicRecordAccessTest extends ApiTestCase [, $clinic] = $this->makeClinicWith($doctor); $record = $this->makeClinicRecordFor($clinic, $doctor); - $this->activeContext($doctor->getUser(), $clinic->getUuid()); + $this->activeContext($doctor->getUser(), $clinic->getUuid(), EntityContext::TYPE_CLINIC); $this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $doctor->getUser()); self::assertSame(200, $this->responseCode()); @@ -130,7 +131,7 @@ class ClinicRecordAccessTest extends ApiTestCase $clinicRecord = $this->makeClinicRecordFor($clinic, $doctor); // بدون محیط فعالِ کلینیک ⇒ مطب شخصی. - $this->activeContext($doctor->getUser(), $doctor->getUuid()); + $this->activeContext($doctor->getUser(), $doctor->getUuid(), EntityContext::TYPE_DOCTOR); $res = $this->authJson('GET', '/api/v1/patients?limit=50', $doctor->getUser()); @@ -146,7 +147,7 @@ class ClinicRecordAccessTest extends ApiTestCase [$owner, $clinic] = $this->makeClinicWith($doctor); $record = $this->makeClinicRecordFor($clinic, $doctor); - $this->activeContext($doctor->getUser(), $clinic->getUuid()); + $this->activeContext($doctor->getUser(), $clinic->getUuid(), EntityContext::TYPE_CLINIC); $this->authJson('GET', "/api/v1/patient/{$record->getUuid()}", $doctor->getUser()); self::assertSame(200, $this->responseCode(), 'پزشک فعال دسترسی دارد'); @@ -197,11 +198,11 @@ class ClinicRecordAccessTest extends ApiTestCase $foreign = $this->makeClinicRecordFor($clinic, $theirs); $secretaryUser = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']); - $relation = new DoctorSecretary($mine, $secretaryUser, DoctorSecretary::OWNER_CLINIC, $clinic); + $relation = new DoctorSecretary($mine, $secretaryUser, $clinic); $this->em->persist($relation); $this->em->flush(); - $this->activeContext($secretaryUser, $clinic->getUuid()); + $this->activeContext($secretaryUser, $clinic->getUuid(), EntityContext::TYPE_CLINIC); $res = $this->authJson('GET', '/api/v1/patients?limit=50', $secretaryUser); $uuids = $this->uuidsFromList($res); @@ -226,7 +227,7 @@ class ClinicRecordAccessTest extends ApiTestCase $this->em->flush(); // محیط فعال را قبل از confirm ست کن: آن درخواست EntityManager را پاک می‌کند. - $this->activeContext($doctor->getUser(), $clinic->getUuid()); + $this->activeContext($doctor->getUser(), $clinic->getUuid(), EntityContext::TYPE_CLINIC); $this->authJson('POST', "/api/v1/appointment/{$appointment->getUuid()}/confirm", $owner, [ 'version' => $appointment->getVersion(), diff --git a/tests/PaymentMethod/PaymentMethodTest.php b/tests/PaymentMethod/PaymentMethodTest.php index 4c5c7514..08f56cac 100644 --- a/tests/PaymentMethod/PaymentMethodTest.php +++ b/tests/PaymentMethod/PaymentMethodTest.php @@ -200,12 +200,14 @@ class PaymentMethodTest extends ApiTestCase $clinic->getDoctors()->add($doctor); $secretary = $this->createUser(['ROLE_SECRETARY']); - $rel = new \App\Secretary\Entity\DoctorSecretary( - $doctor, $secretary, \App\Secretary\Entity\DoctorSecretary::OWNER_CLINIC, $clinic - ); + $rel = new \App\Secretary\Entity\DoctorSecretary($doctor, $secretary, $clinic); $rel->mergePermissions(['resources' => ['payments' => $payments]]); $this->em->persist($rel); - $this->em->persist(new \App\Auth\Entity\UserActiveContext($secretary, $clinic->getUuid())); + $this->em->persist(new \App\Auth\Entity\UserActiveContext( + $secretary, + $clinic->getUuid(), + \App\Shared\Context\EntityContext::TYPE_CLINIC, + )); $this->em->flush(); return $secretary; diff --git a/tests/Secretary/SecretaryAppointmentScopeTest.php b/tests/Secretary/SecretaryAppointmentScopeTest.php index 76aa1347..4266758d 100644 --- a/tests/Secretary/SecretaryAppointmentScopeTest.php +++ b/tests/Secretary/SecretaryAppointmentScopeTest.php @@ -30,11 +30,11 @@ class SecretaryAppointmentScopeTest extends ApiTestCase // منشی فقط به دکتر A تخصیص داده شده $secretaryUser = $this->createUser(['ROLE_SECRETARY']); - $rel = new DoctorSecretary($doctorA, $secretaryUser, DoctorSecretary::OWNER_CLINIC, $clinic); + $rel = new DoctorSecretary($doctorA, $secretaryUser, $clinic); $this->em->persist($rel); // scope فعالِ منشی = این کلینیک - $this->em->persist(new UserActiveContext($secretaryUser, $clinic->getUuid())); + $this->em->persist(new UserActiveContext($secretaryUser, $clinic->getUuid(), 'clinic')); // یک نوبت برای هر پزشک $patient = $this->createUser(['ROLE_USER']); @@ -62,7 +62,7 @@ class SecretaryAppointmentScopeTest extends ApiTestCase // منشی context کلینیک دارد ولی رابطه‌ی فعال ندارد $secretaryUser = $this->createUser(['ROLE_SECRETARY']); - $this->em->persist(new UserActiveContext($secretaryUser, $clinic->getUuid())); + $this->em->persist(new UserActiveContext($secretaryUser, $clinic->getUuid(), 'clinic')); $patient = $this->createUser(['ROLE_USER']); $start = time() + 3600; diff --git a/tests/Secretary/SecretaryListNPlusOneTest.php b/tests/Secretary/SecretaryListNPlusOneTest.php index d858ea35..8f970bba 100644 --- a/tests/Secretary/SecretaryListNPlusOneTest.php +++ b/tests/Secretary/SecretaryListNPlusOneTest.php @@ -22,7 +22,7 @@ class SecretaryListNPlusOneTest extends ApiTestCase for ($i = 0; $i < $count; $i++) { $secUser = $this->createUser(['ROLE_SECRETARY']); - $this->em->persist(new DoctorSecretary($doctor, $secUser, DoctorSecretary::OWNER_DOCTOR)); + $this->em->persist(new DoctorSecretary($doctor, $secUser)); } $this->em->flush(); diff --git a/tests/Secretary/SecretaryMultiClinicScopeTest.php b/tests/Secretary/SecretaryMultiClinicScopeTest.php new file mode 100644 index 00000000..fb26ae69 --- /dev/null +++ b/tests/Secretary/SecretaryMultiClinicScopeTest.php @@ -0,0 +1,107 @@ +createUser(['ROLE_DOCTOR']), 'دکتر چند-کلینیک'); + $this->em->persist($doctor); + $this->em->flush(); + + return $doctor; + } + + private function makeClinic(Doctor $doctor): Clinic + { + $clinic = new Clinic($this->createUser(['ROLE_CLINIC'])); + $clinic->setName('کلینیک تست منشی'); + $clinic->getDoctors()->add($doctor); + $this->em->persist($clinic); + $this->em->flush(); + + return $clinic; + } + + public function testSameSecretaryCanServeSameDoctorInTwoDifferentClinics(): void + { + $doctor = $this->makeDoctor(); + $clinicA = $this->makeClinic($doctor); + $clinicB = $this->makeClinic($doctor); + $secretary = $this->createUser(['ROLE_SECRETARY']); + + $this->em->persist(new DoctorSecretary($doctor, $secretary, $clinicA)); + $this->em->persist(new DoctorSecretary($doctor, $secretary, $clinicB)); + $this->em->flush(); + + /** @var DoctorSecretaryRepository $repo */ + $repo = $this->em->getRepository(DoctorSecretary::class); + + $inA = $repo->findActiveClinicRow($secretary, $clinicA, $doctor); + $inB = $repo->findActiveClinicRow($secretary, $clinicB, $doctor); + + self::assertNotNull($inA, 'رابطهٔ کلینیک اول باید پیدا شود'); + self::assertNotNull($inB, 'رابطهٔ کلینیک دوم باید پیدا شود'); + self::assertNotSame($inA->getId(), $inB->getId(), 'دو ردیف مستقل، نه یک ردیف مشترک'); + self::assertSame($clinicA->getId(), $inA->getEntityId()); + self::assertSame($clinicB->getId(), $inB->getEntityId()); + } + + /** همان پزشک، همان منشی، همان کلینیک — هنوز تکراری است. */ + public function testDuplicateAssignmentInTheSameClinicIsStillRejected(): void + { + $doctor = $this->makeDoctor(); + $clinic = $this->makeClinic($doctor); + $secretary = $this->createUser(['ROLE_SECRETARY']); + + $this->em->persist(new DoctorSecretary($doctor, $secretary, $clinic)); + $this->em->flush(); + + $this->em->persist(new DoctorSecretary($doctor, $secretary, $clinic)); + $this->expectException(UniqueConstraintViolationException::class); + $this->em->flush(); + } + + /** محیط شخصی و محیط کلینیک دو مالک متفاوت‌اند، پس هر دو کنار هم می‌نشینند. */ + public function testPersonalAndClinicAssignmentsCoexist(): void + { + $doctor = $this->makeDoctor(); + $clinic = $this->makeClinic($doctor); + $secretary = $this->createUser(['ROLE_SECRETARY']); + + $personal = new DoctorSecretary($doctor, $secretary); + $inClinic = new DoctorSecretary($doctor, $secretary, $clinic); + $this->em->persist($personal); + $this->em->persist($inClinic); + $this->em->flush(); + + self::assertSame(['doctor', $doctor->getId()], [$personal->getEntityType(), $personal->getEntityId()]); + self::assertSame(['clinic', $clinic->getId()], [$inClinic->getEntityType(), $inClinic->getEntityId()]); + } + + /** بدون کلینیک، محیط همان مطب شخصی است — ترکیب ناسازگار اصلاً بیان‌شدنی نیست. */ + public function testAssignmentWithoutAClinicBelongsToThePersonalPractice(): void + { + $doctor = $this->makeDoctor(); + $secretary = $this->createUser(['ROLE_SECRETARY']); + + $relation = new DoctorSecretary($doctor, $secretary, null); + + self::assertSame('doctor', $relation->getEntityType()); + self::assertSame($doctor->getId(), $relation->getEntityId()); + } +} diff --git a/tests/Secretary/SecretaryOnlineShareTest.php b/tests/Secretary/SecretaryOnlineShareTest.php index 364b7bd2..5086ac62 100644 --- a/tests/Secretary/SecretaryOnlineShareTest.php +++ b/tests/Secretary/SecretaryOnlineShareTest.php @@ -46,12 +46,7 @@ class SecretaryOnlineShareTest extends ApiTestCase private function makeSecretary(Doctor $doctor, float $percent, bool $enabled = true, ?Clinic $clinic = null): DoctorSecretary { $user = $this->createUser(['ROLE_USER', 'ROLE_SECRETARY']); - $relation = new DoctorSecretary( - $doctor, - $user, - $clinic !== null ? DoctorSecretary::OWNER_CLINIC : DoctorSecretary::OWNER_DOCTOR, - $clinic, - ); + $relation = new DoctorSecretary($doctor, $user, $clinic); $relation->setOnlineShareEnabled($enabled)->setOnlineSharePercent($percent); $this->em->persist($relation); $this->em->flush(); diff --git a/tests/Secretary/SecretaryResourceEnforcementTest.php b/tests/Secretary/SecretaryResourceEnforcementTest.php index 5a55bbc1..6971d7d9 100644 --- a/tests/Secretary/SecretaryResourceEnforcementTest.php +++ b/tests/Secretary/SecretaryResourceEnforcementTest.php @@ -26,9 +26,9 @@ class SecretaryResourceEnforcementTest extends ApiTestCase $clinic->getDoctors()->add($doctor); $secretary = $this->createUser(['ROLE_SECRETARY']); - $rel = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic); + $rel = new DoctorSecretary($doctor, $secretary, $clinic); $this->em->persist($rel); - $this->em->persist(new UserActiveContext($secretary, $clinic->getUuid())); + $this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), 'clinic')); return [$secretary, $rel]; } @@ -283,8 +283,8 @@ class SecretaryResourceEnforcementTest extends ApiTestCase $clinic->getDoctors()->add($unassigned); $secretary = $this->createUser(['ROLE_SECRETARY']); - $this->em->persist(new DoctorSecretary($assigned, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic)); - $this->em->persist(new UserActiveContext($secretary, $clinic->getUuid())); + $this->em->persist(new DoctorSecretary($assigned, $secretary, $clinic)); + $this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), 'clinic')); $this->em->flush(); // اندپوینتِ احرازشدهٔ پنل (نه /clinic/doctor-list که عمومی است). @@ -308,9 +308,9 @@ class SecretaryResourceEnforcementTest extends ApiTestCase $clinic->getDoctors()->add($doctor); $secretary = $this->createUser(['ROLE_SECRETARY']); - $rel = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic); + $rel = new DoctorSecretary($doctor, $secretary, $clinic); $this->em->persist($rel); - $this->em->persist(new UserActiveContext($secretary, $clinic->getUuid())); + $this->em->persist(new UserActiveContext($secretary, $clinic->getUuid(), 'clinic')); return [$secretary, $rel, $clinic, $doctor]; } diff --git a/tests/Shared/EntityContextResolverTest.php b/tests/Shared/EntityContextResolverTest.php index 44a8ce71..e8a7e657 100644 --- a/tests/Shared/EntityContextResolverTest.php +++ b/tests/Shared/EntityContextResolverTest.php @@ -55,12 +55,22 @@ class EntityContextResolverTest extends ApiTestCase $this->em->flush(); } - private function setActiveContext(User $user, string $dbUuid): void + private function setActiveContext(User $user, string $dbUuid, string $dbType): void { - $this->em->persist(new UserActiveContext($user, $dbUuid)); + $this->em->persist(new UserActiveContext($user, $dbUuid, $dbType)); $this->em->flush(); } + private function setClinicContext(User $user, Clinic $clinic): void + { + $this->setActiveContext($user, $clinic->getUuid(), EntityContext::TYPE_CLINIC); + } + + private function setDoctorContext(User $user, Doctor $doctor): void + { + $this->setActiveContext($user, $doctor->getUuid(), EntityContext::TYPE_DOCTOR); + } + // ── سناریو ۱: پزشک مستقل ──────────────────────────────────────────────── public function testIndependentDoctorAlwaysResolvesToOwnPractice(): void @@ -79,7 +89,7 @@ class EntityContextResolverTest extends ApiTestCase $doctor = $this->makeDoctor(); $clinic = $this->makeClinic(); $this->joinClinic($clinic, $doctor); - $this->setActiveContext($doctor->getUser(), $clinic->getUuid()); + $this->setClinicContext($doctor->getUser(), $clinic); $context = $this->resolver()->resolve($doctor->getUser()); @@ -91,7 +101,7 @@ class EntityContextResolverTest extends ApiTestCase $doctor = $this->makeDoctor(); $clinic = $this->makeClinic(); $this->joinClinic($clinic, $doctor); - $this->setActiveContext($doctor->getUser(), $doctor->getUuid()); + $this->setDoctorContext($doctor->getUser(), $doctor); $context = $this->resolver()->resolve($doctor->getUser()); @@ -114,7 +124,7 @@ class EntityContextResolverTest extends ApiTestCase $permission->setActive(false); $this->em->flush(); - $this->setActiveContext($doctor->getUser(), $clinic->getUuid()); + $this->setClinicContext($doctor->getUser(), $clinic); $context = $this->resolver()->resolve($doctor->getUser()); @@ -128,7 +138,7 @@ class EntityContextResolverTest extends ApiTestCase $user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']); $doctor = $this->makeDoctor($user); $clinic = $this->makeClinic($user); - $this->setActiveContext($user, $clinic->getUuid()); + $this->setClinicContext($user, $clinic); $context = $this->resolver()->resolve($user); @@ -141,7 +151,7 @@ class EntityContextResolverTest extends ApiTestCase $user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']); $doctor = $this->makeDoctor($user); $this->makeClinic($user); - $this->setActiveContext($user, $doctor->getUuid()); + $this->setDoctorContext($user, $doctor); $context = $this->resolver()->resolve($user); @@ -178,9 +188,9 @@ class EntityContextResolverTest extends ApiTestCase $this->joinClinic($clinic, $doctor); $secretary = $this->createUser(['ROLE_SECRETARY']); - $this->em->persist(new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic)); + $this->em->persist(new DoctorSecretary($doctor, $secretary, $clinic)); $this->em->flush(); - $this->setActiveContext($secretary, $clinic->getUuid()); + $this->setClinicContext($secretary, $clinic); $context = $this->resolver()->resolve($secretary); @@ -194,7 +204,7 @@ class EntityContextResolverTest extends ApiTestCase $this->em->persist(new DoctorSecretary($doctor, $secretary)); $this->em->flush(); - $this->setActiveContext($secretary, $doctor->getUuid()); + $this->setDoctorContext($secretary, $doctor); $context = $this->resolver()->resolve($secretary); @@ -209,11 +219,11 @@ class EntityContextResolverTest extends ApiTestCase $this->joinClinic($clinic, $doctor); $secretary = $this->createUser(['ROLE_SECRETARY']); - $relation = new DoctorSecretary($doctor, $secretary, DoctorSecretary::OWNER_CLINIC, $clinic); + $relation = new DoctorSecretary($doctor, $secretary, $clinic); $relation->setActive(false); $this->em->persist($relation); $this->em->flush(); - $this->setActiveContext($secretary, $clinic->getUuid()); + $this->setClinicContext($secretary, $clinic); $context = $this->resolver()->resolve($secretary); @@ -246,7 +256,7 @@ class EntityContextResolverTest extends ApiTestCase $clinicB = $this->makeClinic(); $this->joinClinic($clinicA, $doctor); $this->joinClinic($clinicB, $doctor); - $this->setActiveContext($doctor->getUser(), $clinicA->getUuid()); + $this->setClinicContext($doctor->getUser(), $clinicA); $context = $this->resolver()->resolve($doctor->getUser(), $clinicB->getUuid()); diff --git a/tests/Shared/TenantIsolationMatrixTest.php b/tests/Shared/TenantIsolationMatrixTest.php new file mode 100644 index 00000000..6f88d0e2 --- /dev/null +++ b/tests/Shared/TenantIsolationMatrixTest.php @@ -0,0 +1,187 @@ +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; + } + + /** عضویت به‌علاوهٔ مجوز discounts.view که در DEFAULT_PERMISSIONS خاموش است. */ + private function joinClinic(Clinic $clinic, Doctor $doctor): void + { + $clinic->getDoctors()->add($doctor); + + $permission = new ClinicDoctorPermission($clinic, $doctor); + $permission->mergePermissions(['resources' => ['discounts' => ['view' => true]]]); + $this->em->persist($permission); + $this->em->flush(); + } + + private function setClinicContext(User $user, Clinic $clinic): void + { + $this->em->persist(new UserActiveContext($user, $clinic->getUuid(), EntityContext::TYPE_CLINIC)); + $this->em->flush(); + } + + private function rule(string $entityType, int $entityId, string $name): DiscountRule + { + $rule = new DiscountRule($entityType, $entityId, $name, DiscountRule::TYPE_INVOICE_AMOUNT); + $this->em->persist($rule); + $this->em->flush(); + + return $rule; + } + + /** + * پاسخ این endpoint با success(['data' => …]) ساخته می‌شود، پس یک لایه + * تودرتوی اضافه دارد — همان دام double-nesting که در CLAUDE.md ثبت شده. + * + * @return string[] + */ + private function listedNames(User $user): array + { + $res = $this->authJson('GET', self::LIST_URL, $user); + self::assertSame(200, $this->responseCode()); + + return array_column($res['data']['data'] ?? [], 'name'); + } + + public function testIndependentDoctorSeesOnlyItsOwnRules(): void + { + $mine = $this->makeDoctor(); + $other = $this->makeDoctor(); + $clinic = $this->makeClinic(); + + $this->rule('doctor', $mine->getId(), 'مالِ من'); + $this->rule('doctor', $other->getId(), 'مالِ پزشک دیگر'); + $this->rule('clinic', $clinic->getId(), 'مالِ کلینیک'); + + $names = $this->listedNames($mine->getUser()); + + self::assertContains('مالِ من', $names); + self::assertNotContains('مالِ پزشک دیگر', $names); + self::assertNotContains('مالِ کلینیک', $names); + } + + public function testClinicManagerSeesOnlyItsClinicRules(): void + { + $clinic = $this->makeClinic(); + $otherClinic = $this->makeClinic(); + $doctor = $this->makeDoctor(); + + $this->rule('clinic', $clinic->getId(), 'کلینیک خودم'); + $this->rule('clinic', $otherClinic->getId(), 'کلینیک دیگر'); + $this->rule('doctor', $doctor->getId(), 'مطب یک پزشک'); + + $names = $this->listedNames($clinic->getUser()); + + self::assertContains('کلینیک خودم', $names); + self::assertNotContains('کلینیک دیگر', $names); + self::assertNotContains('مطب یک پزشک', $names); + } + + /** پزشکِ عضو در محیط کلینیک، قوانین کلینیک را می‌بیند نه مطب شخصی‌اش. */ + public function testMemberDoctorInClinicContextSeesClinicRules(): void + { + $doctor = $this->makeDoctor(); + $clinic = $this->makeClinic(); + $this->joinClinic($clinic, $doctor); + $this->setClinicContext($doctor->getUser(), $clinic); + + $this->rule('clinic', $clinic->getId(), 'قانون کلینیک'); + $this->rule('doctor', $doctor->getId(), 'قانون مطب شخصی'); + + $names = $this->listedNames($doctor->getUser()); + + self::assertContains('قانون کلینیک', $names); + self::assertNotContains('قانون مطب شخصی', $names); + } + + /** همان پزشک بیرون از محیط کلینیک، فقط مطب شخصی. */ + public function testMemberDoctorOutsideClinicContextSeesOwnPracticeRules(): void + { + $doctor = $this->makeDoctor(); + $clinic = $this->makeClinic(); + $this->joinClinic($clinic, $doctor); + + $this->rule('clinic', $clinic->getId(), 'قانون کلینیک'); + $this->rule('doctor', $doctor->getId(), 'قانون مطب شخصی'); + + $names = $this->listedNames($doctor->getUser()); + + self::assertContains('قانون مطب شخصی', $names); + self::assertNotContains('قانون کلینیک', $names); + } + + /** پزشکی که مالک کلینیک هم هست: محیط فعال تعیین‌کننده است، نه نقش. */ + public function testDoctorWhoOwnsClinicSeesClinicRulesInClinicContext(): void + { + $user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']); + $doctor = $this->makeDoctor($user); + $clinic = $this->makeClinic($user); + $this->joinClinic($clinic, $doctor); + $this->setClinicContext($user, $clinic); + + $this->rule('clinic', $clinic->getId(), 'قانون کلینیکِ خودش'); + $this->rule('doctor', $doctor->getId(), 'قانون مطبِ خودش'); + + $names = $this->listedNames($user); + + self::assertContains('قانون کلینیکِ خودش', $names); + self::assertNotContains('قانون مطبِ خودش', $names); + } + + /** ❌ بدون توکن، لیست اصلاً باز نمی‌شود. */ + public function testAnonymousCannotListRules(): void + { + $this->client->request('GET', self::LIST_URL); + + self::assertSame(401, $this->client->getResponse()->getStatusCode()); + } + + /** ⚠️ محیط حل‌نشده: کاربر بدون نقش پنل، نه خطای ۵۰۰ می‌گیرد نه دادهٔ کسی را. */ + public function testUserWithoutAPanelRoleGetsNoRules(): void + { + $doctor = $this->makeDoctor(); + $this->rule('doctor', $doctor->getId(), 'قانون یک پزشک'); + + $stranger = $this->createUser(['ROLE_USER']); + $this->authJson('GET', self::LIST_URL, $stranger); + + self::assertContains($this->responseCode(), [403, 404], 'دسترسی رد شود، نه خطای سرور'); + } +}