refactor(tenant): make EntityContextResolver the single context resolver

Phase 1 of the tenant-marking series. The "which environment is this user
working in?" decision was reimplemented in six places, each reading
UserActiveContext.db_uuid and then guessing whether the uuid belongs to a
clinic or a doctor. Every copy was a place the roles could silently diverge.

EntityContextResolver already encoded the right precedence (explicit
clinic_uuid > stored active context > role) but only five files used it, and
it did not recognise secretaries at all: canActInClinic accepted admins,
clinic owners and member doctors, so a secretary's active clinic context
always collapsed to unknown. That gap is why SecretaryAccessChecker carried
its own copy of the logic.

- canActInClinic now also accepts an active DoctorSecretary relation, and a
  matching canActForDoctor covers the personal-practice branch.
- AppointmentAccessChecker, ClinicDoctorAccessChecker, SecretaryAccessChecker,
  PatientRecordScopeResolver, MyAppointmentsController and the secretary
  dashboard all resolve through it now.
- PatientRecordScopeResolver keeps only its real responsibility: which
  doctors' patients are visible inside the resolved environment.
- The resolver answers "where"; ClinicDoctorPermissionChecker and
  SecretaryPermissionChecker still answer "what may you do".

Left deliberately untouched, with the reason recorded at each site:
SubscriptionController, InventoryController and TenantTagController check
ROLE_DOCTOR unconditionally and ignore the active context, so a member doctor
sees personal inventory/tags/subscription even inside a clinic. Switching them
changes what users see, which is a product decision, not a refactor.
AuthController keeps its repository because it writes the active context.

tests/ApiTestCase now seeds the "free" subscription plan. db_test had no such
row, so getEffectivePlan returned null, every hasFeature() was false and 83
tests across Patient, ClinicService, Insurance and Appointment failed with 403.

No schema, route, request, response or error code changed.

Tests: 813 passing (was 730 passing / 83 failing). PHPStan clean on all
changed files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamed
2026-07-28 10:59:22 +03:30
co-authored by Claude Opus 5
parent 748dc81ea9
commit 1a7bf53577
18 changed files with 2185 additions and 216 deletions
@@ -8,7 +8,6 @@ use App\Appointment\Repository\AppointmentRepository;
use App\Appointment\Repository\SlotTakenException;
use App\Appointment\Service\SlotCalculatorService;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Doctor\Entity\Doctor;
use App\Doctor\Repository\DoctorRepository;
@@ -35,7 +34,7 @@ class MyAppointmentsController extends BaseController
private readonly DoctorRepository $doctorRepo,
private readonly ClinicRepository $clinicRepo,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly \App\Shared\Context\EntityContextResolver $contextResolver,
private readonly SlotCalculatorService $slotCalculator,
private readonly \App\Appointment\Service\BookingContextResolver $bookingContext,
private readonly \App\ClinicService\Repository\ServiceSectionRepository $sectionRepo,
@@ -552,21 +551,19 @@ class MyAppointmentsController extends BaseController
private function secretaryCanBookForDoctor(User $user, Doctor $doctor): bool
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
$context = $this->contextResolver->resolve($user);
if (!$context->isResolved()) {
return false;
}
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
if ($context->isClinic()) {
// منشی فقط برای پزشکانِ تخصیص‌یافته‌ی خودش می‌تواند رزرو کند، نه کل کلینیک
$rel = $this->secretaryRepo->findActiveClinicRow($user, $clinic, $doctor);
$rel = $this->secretaryRepo->findActiveClinicRow($user, $context->clinic, $doctor);
return $rel !== null && (bool) ($rel->getPermissions()['resources']['appointments']['create'] ?? false);
}
$scopeDoctor = $this->doctorRepo->findByUuid($dbUuid);
if ($scopeDoctor !== null && $scopeDoctor->getId() === $doctor->getId()) {
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $scopeDoctor);
if ($context->id === $doctor->getId()) {
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
return $rel !== null && (bool) ($rel->getPermissions()['resources']['appointments']['create'] ?? false);
}
@@ -581,35 +578,28 @@ class MyAppointmentsController extends BaseController
*/
private function resolveSecretaryFilter(User $user): ?array
{
$activeCtx = $this->contextRepo->findByUser($user);
$dbUuid = $activeCtx?->getDbUuid();
if ($dbUuid === null) {
$context = $this->contextResolver->resolve($user);
if (!$context->isResolved()) {
return null;
}
// بررسی scope کلینیک — فقط پزشکانِ تخصیص‌یافته به این منشی، نه کل کلینیک
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
if ($context->isClinic()) {
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $context->clinic);
if ($rel === null) return null;
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
$doctorIds = array_map(
fn(Doctor $d) => $d->getId(),
$this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic)
$this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $context->clinic)
);
return ['clinic', $doctorIds, $canView];
}
// بررسی scope مطب شخصی
$doctor = $this->doctorRepo->findByUuid($dbUuid);
if ($doctor !== null) {
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
if ($rel === null) return null;
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
return ['doctor', $doctor, $canView];
}
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $context->doctor);
if ($rel === null) return null;
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
return null;
return ['doctor', $context->doctor, $canView];
}
}
@@ -4,12 +4,11 @@ namespace App\Appointment\Security;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\User;
use App\Auth\Repository\UserActiveContextRepository;
use App\Clinic\Repository\ClinicRepository;
use App\Clinic\Security\ClinicDoctorPermissionChecker;
use App\Doctor\Repository\DoctorRepository;
use App\Secretary\Repository\DoctorSecretaryRepository;
use App\Secretary\Security\SecretaryPermissionChecker;
use App\Shared\Context\EntityContextResolver;
/**
* تنها تصمیم‌گیرندهٔ دسترسی روی «یک نوبت مشخص».
@@ -34,9 +33,8 @@ class AppointmentAccessChecker
private readonly ClinicDoctorPermissionChecker $clinicPermissions,
private readonly SecretaryPermissionChecker $secretaryPermissions,
private readonly DoctorSecretaryRepository $secretaryRepo,
private readonly UserActiveContextRepository $contextRepo,
private readonly EntityContextResolver $contextResolver,
private readonly ClinicRepository $clinicRepo,
private readonly DoctorRepository $doctorRepo,
) {}
public function canView(Appointment $appointment, User $user): bool
@@ -108,29 +106,23 @@ class AppointmentAccessChecker
*/
private function secretaryCanContext(User $user, \App\Doctor\Entity\Doctor $doctor, ?\App\Clinic\Entity\Clinic $clinic): bool
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
$context = $this->contextResolver->resolve($user);
if (!$context->isResolved()) {
return false;
}
$ctxClinic = $this->clinicRepo->findByUuid($dbUuid);
if ($ctxClinic !== null) {
if ($clinic === null || $ctxClinic->getId() !== $clinic->getId()) {
if ($context->isClinic()) {
if ($clinic === null || $context->id !== $clinic->getId()) {
return false;
}
$relation = $this->secretaryRepo->findActiveClinicRow($user, $ctxClinic, $doctor);
$relation = $this->secretaryRepo->findActiveClinicRow($user, $context->clinic, $doctor);
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, self::ACTION_UPDATE_STATUS);
}
// محیطِ مطب شخصی: نوبت هم باید در همان مطب شخصی باشد (clinic == null).
if ($clinic !== null) {
return false;
}
$ctxDoctor = $this->doctorRepo->findByUuid($dbUuid);
if ($ctxDoctor === null || $ctxDoctor->getId() !== $doctor->getId()) {
if ($clinic !== null || $context->id !== $doctor->getId()) {
return false;
}
@@ -145,12 +137,12 @@ class AppointmentAccessChecker
*/
public function viewableClinicFor(User $user, \App\Doctor\Entity\Doctor $doctor): ?\App\Clinic\Entity\Clinic
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
$clinic = $dbUuid !== null ? $this->clinicRepo->findByUuid($dbUuid) : null;
$context = $this->contextResolver->resolve($user);
if ($clinic === null) {
$clinic = $this->clinicRepo->findByUser($user);
}
// بیرون از محیط کلینیک، کلینیکِ تحتِ مالکیت بازمی‌گردد: کاربری که هم پزشک
// است و هم مالک کلینیک، بدون محیط فعال به‌عنوان پزشک حل می‌شود ولی هنوز
// باید لیست نوبت‌های کلینیک خودش را ببیند.
$clinic = $context->isClinic() ? $context->clinic : $this->clinicRepo->findByUser($user);
if ($clinic === null || !$clinic->hasDoctor($doctor)) {
return null;
@@ -168,24 +160,23 @@ class AppointmentAccessChecker
*/
private function secretaryCan(Appointment $appointment, User $user, string $action): bool
{
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
if ($dbUuid === null) {
$context = $this->contextResolver->resolve($user);
if (!$context->isResolved()) {
return false;
}
$clinic = $this->clinicRepo->findByUuid($dbUuid);
if ($clinic !== null) {
if ($appointment->getClinic()?->getId() !== $clinic->getId()) {
if ($context->isClinic()) {
if ($appointment->getClinic()?->getId() !== $context->id) {
return false;
}
$relation = $this->secretaryRepo->findActiveClinicRow($user, $clinic, $appointment->getDoctor());
$relation = $this->secretaryRepo->findActiveClinicRow($user, $context->clinic, $appointment->getDoctor());
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, $action);
}
$doctor = $this->doctorRepo->findByUuid($dbUuid);
if ($doctor === null || $doctor->getId() !== $appointment->getDoctor()->getId()) {
$doctor = $appointment->getDoctor();
if ($context->id !== $doctor->getId()) {
return false;
}
@@ -13,6 +13,11 @@ use App\Shared\Exception\AppException;
*
* نبودِ clinic_uuid هرگز به معنی «هر محلی که پیدا شد» نیست. با چند برنامهٔ هم‌زمان،
* حدس‌زدن محل یعنی ثبت خاموشِ نوبت در جای اشتباه — پس یا محل صریح است، یا شخصی.
*
* چرا جدا از {@see \App\Shared\Context\EntityContextResolver}: ورودی آن User است و
* محیطِ کاربرِ پنل را حل می‌کند؛ اینجا ورودی Doctor است چون رزرو عمومی را بیمار
* انجام می‌دهد و کاربرِ درخواست هیچ محیط کاری ندارد. به همین دلیل هم null اینجا
* معنای صریحِ «مطب شخصی» دارد، نه «نامشخص».
*/
class BookingContextResolver
{