fix(subscription): read the subscription of the environment the user owns
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) <noreply@anthropic.com>
This commit is contained in:
@@ -82,7 +82,13 @@
|
|||||||
|
|
||||||
**Permission:** `IS_AUTHENTICATED_FULLY`
|
**Permission:** `IS_AUTHENTICATED_FULLY`
|
||||||
|
|
||||||
**نکته:** از نسخه فعلی، این endpoint برای `ROLE_SECRETARY` نیز کار میکند. منشی از طریق `UserActiveContextRepository` به `db_uuid` entity مربوطه (doctor یا clinic) دسترسی پیدا میکند و اشتراک همان entity برگردانده میشود.
|
**محیط اشتراک:** همان محیطی که کاربر **صاحبش** است، از `EntityContextResolver::ownedEntity()` — همان مرجعی که خرید اشتراک هم استفاده میکند، تا نمایش و پرداخت و اعطای ادمین روی یک محیط بنشینند.
|
||||||
|
|
||||||
|
کاربری که هم پزشک است و هم مالک کلینیک، دو محیط صاحبشده دارد. آنجا محیط فعال
|
||||||
|
(`UserActiveContext`) تعیین میکند اشتراک کدامیک خوانده شود. بدون محیط فعال، مطب
|
||||||
|
شخصی پیشفرض است.
|
||||||
|
|
||||||
|
**نکته:** این endpoint برای `ROLE_SECRETARY` هم کار میکند. منشی محیط صاحبشده ندارد، پس محیط فعالش خوانده میشود و اشتراک همان entity برمیگردد.
|
||||||
|
|
||||||
**Response 200:**
|
**Response 200:**
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -81,16 +81,35 @@ class EntityContextResolver
|
|||||||
* برای خریدهایی است که به حساب خودِ صاحب مینشیند (اشتراک): آنجا «کجا ایستادهام»
|
* برای خریدهایی است که به حساب خودِ صاحب مینشیند (اشتراک): آنجا «کجا ایستادهام»
|
||||||
* مهم نیست، «چه چیزی دارم» مهم است. تنها مرجعِ این پرسش همین متد است تا پرداختِ
|
* مهم نیست، «چه چیزی دارم» مهم است. تنها مرجعِ این پرسش همین متد است تا پرداختِ
|
||||||
* اشتراک و خودِ اشتراک هرگز روی دو محیط متفاوت ننشینند.
|
* اشتراک و خودِ اشتراک هرگز روی دو محیط متفاوت ننشینند.
|
||||||
|
*
|
||||||
|
* استثنا: کاربری که هر دو محیط را دارد. آنجا محیط فعال تعیین میکند کدامیک،
|
||||||
|
* وگرنه یکی از دو محیطِ صاحبشده هرگز اشتراک نمیگیرد.
|
||||||
*/
|
*/
|
||||||
public function ownedEntity(User $user): EntityContext
|
public function ownedEntity(User $user): EntityContext
|
||||||
{
|
{
|
||||||
$doctor = $this->doctorRepo->findByUser($user);
|
$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) {
|
if ($doctor !== null) {
|
||||||
return EntityContext::forDoctor($doctor);
|
return EntityContext::forDoctor($doctor);
|
||||||
}
|
}
|
||||||
|
|
||||||
$clinic = $this->clinicRepo->findByUser($user);
|
|
||||||
|
|
||||||
return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown();
|
return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
namespace App\Subscription\Controller;
|
namespace App\Subscription\Controller;
|
||||||
|
|
||||||
use App\Auth\Entity\User;
|
use App\Auth\Entity\User;
|
||||||
use App\Auth\Repository\UserActiveContextRepository;
|
|
||||||
use App\Clinic\Repository\ClinicRepository;
|
use App\Clinic\Repository\ClinicRepository;
|
||||||
use App\Doctor\Repository\DoctorRepository;
|
use App\Doctor\Repository\DoctorRepository;
|
||||||
use App\Shared\Constant\ErrorCodes;
|
use App\Shared\Constant\ErrorCodes;
|
||||||
|
use App\Shared\Context\EntityContextResolver;
|
||||||
use App\Shared\Controller\BaseController;
|
use App\Shared\Controller\BaseController;
|
||||||
use App\Shared\Exception\AppException;
|
use App\Shared\Exception\AppException;
|
||||||
use App\Subscription\Entity\SubscriptionPeriod;
|
use App\Subscription\Entity\SubscriptionPeriod;
|
||||||
@@ -34,7 +34,7 @@ class SubscriptionController extends BaseController
|
|||||||
private readonly ClinicSubscriptionRepository $subscriptionRepo,
|
private readonly ClinicSubscriptionRepository $subscriptionRepo,
|
||||||
private readonly DoctorRepository $doctorRepo,
|
private readonly DoctorRepository $doctorRepo,
|
||||||
private readonly ClinicRepository $clinicRepo,
|
private readonly ClinicRepository $clinicRepo,
|
||||||
private readonly UserActiveContextRepository $contextRepo,
|
private readonly EntityContextResolver $contextResolver,
|
||||||
private readonly EntityManagerInterface $em,
|
private readonly EntityManagerInterface $em,
|
||||||
private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess,
|
private readonly \App\Secretary\Security\SecretaryAccessChecker $secretaryAccess,
|
||||||
private readonly SubscriptionTaxCalculator $tax,
|
private readonly SubscriptionTaxCalculator $tax,
|
||||||
@@ -430,32 +430,23 @@ class SubscriptionController extends BaseController
|
|||||||
*
|
*
|
||||||
* @return array{0: string, 1: int|null} [entityType, entityId]
|
* @return array{0: string, 1: int|null} [entityType, entityId]
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* محیطی که اشتراکِ این کاربر روی آن مینشیند.
|
||||||
|
*
|
||||||
|
* همان مرجعی که خریدِ اشتراک استفاده میکند (`PaymentController::subscriptionInit`)،
|
||||||
|
* تا نمایش پنل و پرداخت و اعطای ادمین هر سه یک محیط را ببینند. نقشمحورِ محلی
|
||||||
|
* بود و برای کاربری که هم پزشک است و هم مالک کلینیک، همیشه پزشک را برمیگرداند:
|
||||||
|
* اشتراکِ کلینیک چنین کاربری در پنل «اعمالنشده» دیده میشد.
|
||||||
|
*
|
||||||
|
* منشی و پرسنل صاحب محیطی نیستند، پس برایشان محیط فعال خوانده میشود.
|
||||||
|
*/
|
||||||
private function resolveEntity(User $user): array
|
private function resolveEntity(User $user): array
|
||||||
{
|
{
|
||||||
if ($user->hasRole('ROLE_DOCTOR')) {
|
$owned = $this->contextResolver->ownedEntity($user);
|
||||||
$doctor = $this->doctorRepo->findByUser($user);
|
if ($owned->isResolved()) {
|
||||||
return $doctor !== null ? ['doctor', $doctor->getId()] : ['doctor', null];
|
return $owned->toEntityPair();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user->hasRole('ROLE_CLINIC')) {
|
return $this->contextResolver->tryResolve($user)?->toEntityPair() ?? ['unknown', null];
|
||||||
$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];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,51 @@ class EntityContextResolverTest extends ApiTestCase
|
|||||||
self::assertSame(['doctor', $doctor->getId()], $context->toEntityPair());
|
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
|
public function testClinicManagerAlwaysResolvesToClinic(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user