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>
225 lines
10 KiB
PHP
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();
|
|
}
|
|
}
|