From 32044c8fa95839fe2199166e9b7a23201975d295 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Wed, 19 Aug 2026 22:07:56 +0330 Subject: [PATCH] fix(subscription): read the subscription of the environment the user owns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user who is both a doctor and a clinic owner always resolved to the doctor: SubscriptionController had its own role-first resolveEntity, and ownedEntity() returned the doctor whenever one existed. So a subscription granted to that user's clinic was stored correctly but never surfaced — /subscription/my kept reporting the free plan and the panel kept the feature-gated menu items locked. ownedEntity() now disambiguates with the active context when the user owns both environments, and the controller delegates to it instead of re-deriving the pair from roles. Payment already used ownedEntity(), so display, purchase and admin grant now agree on one environment. Co-Authored-By: Claude Opus 5 (1M context) --- docs/api/subscription.md | 8 +++- src/Shared/Context/EntityContextResolver.php | 23 +++++++++- .../Controller/SubscriptionController.php | 41 +++++++---------- tests/Shared/EntityContextResolverTest.php | 45 +++++++++++++++++++ 4 files changed, 89 insertions(+), 28 deletions(-) diff --git a/docs/api/subscription.md b/docs/api/subscription.md index 26f916f8..41e36352 100644 --- a/docs/api/subscription.md +++ b/docs/api/subscription.md @@ -82,7 +82,13 @@ **Permission:** `IS_AUTHENTICATED_FULLY` -**نکته:** از نسخه فعلی، این endpoint برای `ROLE_SECRETARY` نیز کار می‌کند. منشی از طریق `UserActiveContextRepository` به `db_uuid` entity مربوطه (doctor یا clinic) دسترسی پیدا می‌کند و اشتراک همان entity برگردانده می‌شود. +**محیط اشتراک:** همان محیطی که کاربر **صاحبش** است، از `EntityContextResolver::ownedEntity()` — همان مرجعی که خرید اشتراک هم استفاده می‌کند، تا نمایش و پرداخت و اعطای ادمین روی یک محیط بنشینند. + +کاربری که هم پزشک است و هم مالک کلینیک، دو محیط صاحب‌شده دارد. آنجا محیط فعال +(`UserActiveContext`) تعیین می‌کند اشتراک کدام‌یک خوانده شود. بدون محیط فعال، مطب +شخصی پیش‌فرض است. + +**نکته:** این endpoint برای `ROLE_SECRETARY` هم کار می‌کند. منشی محیط صاحب‌شده ندارد، پس محیط فعالش خوانده می‌شود و اشتراک همان entity برمی‌گردد. **Response 200:** ```json diff --git a/src/Shared/Context/EntityContextResolver.php b/src/Shared/Context/EntityContextResolver.php index 993bdc6f..7c8a2e4a 100644 --- a/src/Shared/Context/EntityContextResolver.php +++ b/src/Shared/Context/EntityContextResolver.php @@ -81,16 +81,35 @@ class EntityContextResolver * برای خریدهایی است که به حساب خودِ صاحب می‌نشیند (اشتراک): آنجا «کجا ایستاده‌ام» * مهم نیست، «چه چیزی دارم» مهم است. تنها مرجعِ این پرسش همین متد است تا پرداختِ * اشتراک و خودِ اشتراک هرگز روی دو محیط متفاوت ننشینند. + * + * استثنا: کاربری که هر دو محیط را دارد. آنجا محیط فعال تعیین می‌کند کدام‌یک، + * وگرنه یکی از دو محیطِ صاحب‌شده هرگز اشتراک نمی‌گیرد. */ public function ownedEntity(User $user): EntityContext { $doctor = $this->doctorRepo->findByUser($user); + $clinic = $this->clinicRepo->findByUser($user); + + // کاربری که هم پزشک است و هم مالک کلینیک، دو محیطِ صاحب‌شده دارد و «چه چیزی + // دارم» به‌تنهایی جواب یکتا ندارد. محیط فعال گره را باز می‌کند: اشتراک همان + // جایی می‌نشیند که کاربر ایستاده و آن را می‌بیند. بدون این، اعطای اشتراک به + // کلینیک برای چنین کاربری بی‌اثر می‌ماند، چون پنل همیشه پزشک را می‌خواند. + if ($doctor !== null && $clinic !== null) { + $active = $this->fromActiveContext($user); + if ($active !== null) { + if ($active->isClinic() && $active->toEntityPair()[1] === $clinic->getId()) { + return EntityContext::forClinic($clinic); + } + if (!$active->isClinic() && $active->toEntityPair()[1] === $doctor->getId()) { + return EntityContext::forDoctor($doctor); + } + } + } + if ($doctor !== null) { return EntityContext::forDoctor($doctor); } - $clinic = $this->clinicRepo->findByUser($user); - return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown(); } diff --git a/src/Subscription/Controller/SubscriptionController.php b/src/Subscription/Controller/SubscriptionController.php index e63874e5..94800644 100644 --- a/src/Subscription/Controller/SubscriptionController.php +++ b/src/Subscription/Controller/SubscriptionController.php @@ -3,10 +3,10 @@ namespace App\Subscription\Controller; use App\Auth\Entity\User; -use App\Auth\Repository\UserActiveContextRepository; use App\Clinic\Repository\ClinicRepository; use App\Doctor\Repository\DoctorRepository; use App\Shared\Constant\ErrorCodes; +use App\Shared\Context\EntityContextResolver; use App\Shared\Controller\BaseController; use App\Shared\Exception\AppException; use App\Subscription\Entity\SubscriptionPeriod; @@ -34,7 +34,7 @@ class SubscriptionController extends BaseController private readonly ClinicSubscriptionRepository $subscriptionRepo, private readonly DoctorRepository $doctorRepo, private readonly ClinicRepository $clinicRepo, - private readonly UserActiveContextRepository $contextRepo, + private readonly EntityContextResolver $contextResolver, private readonly EntityManagerInterface $em, private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess, private readonly SubscriptionTaxCalculator $tax, @@ -430,32 +430,23 @@ class SubscriptionController extends BaseController * * @return array{0: string, 1: int|null} [entityType, entityId] */ + /** + * محیطی که اشتراکِ این کاربر روی آن می‌نشیند. + * + * همان مرجعی که خریدِ اشتراک استفاده می‌کند (`PaymentController::subscriptionInit`)، + * تا نمایش پنل و پرداخت و اعطای ادمین هر سه یک محیط را ببینند. نقش‌محورِ محلی + * بود و برای کاربری که هم پزشک است و هم مالک کلینیک، همیشه پزشک را برمی‌گرداند: + * اشتراکِ کلینیک چنین کاربری در پنل «اعمال‌نشده» دیده می‌شد. + * + * منشی و پرسنل صاحب محیطی نیستند، پس برایشان محیط فعال خوانده می‌شود. + */ private function resolveEntity(User $user): array { - if ($user->hasRole('ROLE_DOCTOR')) { - $doctor = $this->doctorRepo->findByUser($user); - return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null]; + $owned = $this->contextResolver->ownedEntity($user); + if ($owned->isResolved()) { + return $owned->toEntityPair(); } - if ($user->hasRole('ROLE_CLINIC')) { - $clinic = $this->clinicRepo->findByUser($user); - return $clinic !== null ? ['clinic', $clinic->getId()] : ['clinic', null]; - } - - if ($user->hasRole('ROLE_SECRETARY')) { - $dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid(); - if ($dbUuid !== null) { - $clinic = $this->clinicRepo->findByUuid($dbUuid); - if ($clinic !== null) { - return ['clinic', $clinic->getId()]; - } - $doctor = $this->doctorRepo->findByUuid($dbUuid); - if ($doctor !== null) { - return ['doctor', $doctor->getId()]; - } - } - } - - return ['unknown', null]; + return $this->contextResolver->tryResolve($user)?->toEntityPair() ?? ['unknown', null]; } } diff --git a/tests/Shared/EntityContextResolverTest.php b/tests/Shared/EntityContextResolverTest.php index e8a7e657..3e58133f 100644 --- a/tests/Shared/EntityContextResolverTest.php +++ b/tests/Shared/EntityContextResolverTest.php @@ -158,6 +158,51 @@ class EntityContextResolverTest extends ApiTestCase self::assertSame(['doctor', $doctor->getId()], $context->toEntityPair()); } + // ── محیطِ صاحب‌شده (اشتراک) ────────────────────────────────────────────── + + /** + * اشتراک روی محیطی می‌نشیند که کاربر صاحبش است. کاربری که هم پزشک است و هم + * مالک کلینیک، دو محیط صاحب‌شده دارد و محیط فعال تعیین می‌کند کدام‌یک؛ وگرنه + * اشتراکِ اعطاشده به کلینیک هرگز در پنل دیده نمی‌شود. + */ + public function testOwnedEntityFollowsActiveContextWhenUserOwnsBoth(): void + { + $user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']); + $this->makeDoctor($user); + $clinic = $this->makeClinic($user); + $this->setClinicContext($user, $clinic); + + self::assertSame(['clinic', $clinic->getId()], $this->resolver()->ownedEntity($user)->toEntityPair()); + } + + public function testOwnedEntityIsOwnPracticeWhenActiveContextIsSelf(): void + { + $user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']); + $doctor = $this->makeDoctor($user); + $this->makeClinic($user); + $this->setDoctorContext($user, $doctor); + + self::assertSame(['doctor', $doctor->getId()], $this->resolver()->ownedEntity($user)->toEntityPair()); + } + + /** بدون محیط فعال، مطب شخصی همان پیش‌فرض قبلی می‌ماند. */ + public function testOwnedEntityFallsBackToOwnPracticeWithoutActiveContext(): void + { + $user = $this->createUser(['ROLE_DOCTOR', 'ROLE_CLINIC']); + $doctor = $this->makeDoctor($user); + $this->makeClinic($user); + + self::assertSame(['doctor', $doctor->getId()], $this->resolver()->ownedEntity($user)->toEntityPair()); + } + + /** مالک کلینیکِ غیرپزشک تنها یک محیط دارد و محیط فعال چیزی را عوض نمی‌کند. */ + public function testOwnedEntityIsClinicForNonDoctorOwner(): void + { + $clinic = $this->makeClinic(); + + self::assertSame(['clinic', $clinic->getId()], $this->resolver()->ownedEntity($clinic->getUser())->toEntityPair()); + } + // ── سناریو ۴: مدیر/مالک کلینیک (غیرپزشک) ──────────────────────────────── public function testClinicManagerAlwaysResolvesToClinic(): void