Files
clinicpro/src/Shared/Context/EntityContextResolver.php
T
hamedandClaude Opus 5 32044c8fa9 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>
2026-08-19 22:07:56 +03:30

225 lines
10 KiB
PHP

<?php
namespace App\Shared\Context;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Entity\Clinic;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Entity\Doctor;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Shared\Constant\ErrorCodes;
use App\Shared\Exception\AppException;
use App\Staff\Repository\ClinicStaffRepository;
/**
* تنها نقطهٔ تصمیم‌گیری دربارهٔ «این درخواست در کدام محیط اجرا می‌شود؟».
*
* اولویت: clinic_uuid صریحِ درخواست > محیط فعالِ ذخیره‌شدهٔ کاربر > نقش کاربر.
*
* نقش به‌تنهایی برای کاربری که هم پزشک است و هم مالک کلینیک جواب نمی‌دهد: چنین
* کاربری همیشه به‌عنوان پزشک حل می‌شد و هرگز به سرویس‌های کلینیک خودش نمی‌رسید.
* UserActiveContext تعیین‌کننده است و نقش فقط fallback آخر.
*
* این کلاس فقط به «کدام محیط» جواب می‌دهد. «چه کاری در آن محیط مجاز است» کار
* ClinicDoctorPermissionChecker و SecretaryPermissionChecker است و اینجا بررسی نمی‌شود.
*
* برای مسیر رزرو عمومی (که کاربر پنل ندارد و ورودی‌اش Doctor است، نه User)
* قرینه‌ای جدا وجود دارد: {@see \App\Appointment\Service\BookingContextResolver}.
*/
class EntityContextResolver
{
public function __construct(
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly UserActiveContextRepository $activeContextRepo,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly ClinicStaffRepository $staffRepo,
) {}
/**
* @param string|null $clinicUuid اگر داده شود، محیط کلینیک اجباری می‌شود و در
* صورت نداشتن دسترسی، خطای ۴۰۳ پرتاب می‌شود.
*/
public function resolve(User $user, ?string $clinicUuid = null): EntityContext
{
if ($clinicUuid !== null && $clinicUuid !== '') {
$clinic = $this->clinicRepo->findByUuid($clinicUuid);
if ($clinic === null) {
throw new AppException(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404);
}
$this->assertCanActInClinic($user, $clinic);
return EntityContext::forClinic($clinic)->asChosen();
}
$fromActive = $this->fromActiveContext($user);
if ($fromActive !== null) {
return $fromActive;
}
return $this->fromRole($user);
}
/**
* محیط را بدون پرتاب خطا حل می‌کند؛ اگر کاربر به کلینیکِ خواسته‌شده دسترسی
* نداشته باشد null برمی‌گرداند. برای مسیرهایی که خودشان authorization جدا دارند.
*/
public function tryResolve(User $user, ?string $clinicUuid = null): ?EntityContext
{
try {
return $this->resolve($user, $clinicUuid);
} catch (AppException) {
return null;
}
}
/**
* محیطی که کاربر **صاحبش** است، مستقل از محیط فعال و از نقش‌هایش.
*
* برای خریدهایی است که به حساب خودِ صاحب می‌نشیند (اشتراک): آنجا «کجا ایستاده‌ام»
* مهم نیست، «چه چیزی دارم» مهم است. تنها مرجعِ این پرسش همین متد است تا پرداختِ
* اشتراک و خودِ اشتراک هرگز روی دو محیط متفاوت ننشینند.
*
* استثنا: کاربری که هر دو محیط را دارد. آنجا محیط فعال تعیین می‌کند کدام‌یک،
* وگرنه یکی از دو محیطِ صاحب‌شده هرگز اشتراک نمی‌گیرد.
*/
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);
}
return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown();
}
/**
* آیا این کاربر **صاحبِ** همین محیط است؟ (ادمین همیشه بله.)
*
* «صاحب» با «می‌تواند در آن بایستد» فرق دارد: پزشکِ مهمانِ یک کلینیک در محیط آن
* می‌ایستد ولی صاحبش نیست. تصمیم‌هایی که قراردادِ کلِ مجموعه را عوض می‌کنند —
* مثل الگوی شمارهٔ پرونده — به این پرسش وصل‌اند، نه به مجوزهای per-resource.
*/
public function owns(User $user, string $entityType, ?int $entityId): bool
{
if ($user->hasRole('ROLE_ADMIN')) {
return true;
}
[$ownedType, $ownedId] = $this->ownedEntity($user)->toEntityPair();
return $ownedId !== null && $ownedType === $entityType && $ownedId === $entityId;
}
/**
* مالک کلینیک، ادمین، پزشکِ عضو همان کلینیک، منشیِ دارای رابطهٔ فعال در آن، یا
* پرسنلِ فعالِ همان کلینیک.
*
* «می‌تواند در این محیط بایستد» یعنی محیطش حل می‌شود — نه اینکه هر کاری در آن
* مجاز است؛ محدودهٔ پرسنل را StaffRouteGuardSubscriber تعیین می‌کند.
*/
public function canActInClinic(User $user, Clinic $clinic): bool
{
if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) {
return true;
}
$doctor = $this->doctorRepo->findByUser($user);
if ($doctor !== null && $clinic->hasDoctor($doctor)) {
return true;
}
if ($this->staffRepo->findActiveByUserAndEntity($user, EntityContext::TYPE_CLINIC, $clinic->getId()) !== null) {
return true;
}
return $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic) !== null;
}
public function assertCanActInClinic(User $user, Clinic $clinic): void
{
if (!$this->canActInClinic($user, $clinic)) {
throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, 'به این کلینیک دسترسی ندارید', 403);
}
}
/** محیط فعالِ ذخیره‌شده؛ db_type می‌گوید db_uuid کدام موجودیت را آدرس می‌دهد. */
private function fromActiveContext(User $user): ?EntityContext
{
$active = $this->activeContextRepo->findByUser($user);
if ($active === null) {
return null;
}
if ($active->isClinic()) {
$clinic = $this->clinicRepo->findByUuid($active->getDbUuid());
return $clinic !== null && $this->canActInClinic($user, $clinic)
? EntityContext::forClinic($clinic)->asChosen()
: null;
}
$doctor = $this->doctorRepo->findByUuid($active->getDbUuid());
return $doctor !== null && $this->canActForDoctor($user, $doctor)
? EntityContext::forDoctor($doctor)->asChosen()
: null;
}
/**
* محیطِ مطب شخصی: خودِ پزشک، یا منشیِ دارای رابطهٔ فعال با او.
* قرینهٔ canActInClinic برای شاخهٔ غیرکلینیکی؛ ادمین عمداً اینجا نیست چون
* مسیرهای ادمین سراسری‌اند و از محیط فعالِ یک پزشک عبور نمی‌کنند.
*/
private function canActForDoctor(User $user, Doctor $doctor): bool
{
if ($doctor->getUser()->getId() === $user->getId()) {
return true;
}
if ($this->staffRepo->findActiveByUserAndEntity($user, EntityContext::TYPE_DOCTOR, $doctor->getId()) !== null) {
return true;
}
return $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor) !== null;
}
private function fromRole(User $user): EntityContext
{
if ($user->hasRole('ROLE_DOCTOR')) {
return EntityContext::forDoctor($this->doctorRepo->findByUser($user));
}
if ($user->hasRole('ROLE_CLINIC')) {
$clinic = $this->clinicRepo->findByUser($user);
return $clinic !== null ? EntityContext::forClinic($clinic) : EntityContext::unknown();
}
// منشی و پرسنل fallback نقشی ندارند: محیطشان فقط از UserActiveContext می‌آید،
// چون هر دو می‌توانند هم‌زمان به چند پزشک و کلینیک وصل باشند و نقش تنها،
// انتخاب بین آن‌ها را تعیین نمی‌کند.
return EntityContext::unknown();
}
}