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], 'دسترسی رد شود، نه خطای سرور'); + } +}