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>
188 lines
8.0 KiB
PHP
188 lines
8.0 KiB
PHP
<?php
|
|
|
|
namespace App\Appointment\Security;
|
|
|
|
use App\Appointment\Entity\Appointment;
|
|
use App\Auth\Entity\User;
|
|
use App\Clinic\Repository\ClinicRepository;
|
|
use App\Clinic\Security\ClinicDoctorPermissionChecker;
|
|
use App\Secretary\Repository\DoctorSecretaryRepository;
|
|
use App\Secretary\Security\SecretaryPermissionChecker;
|
|
use App\Shared\Context\EntityContextResolver;
|
|
|
|
/**
|
|
* تنها تصمیمگیرندهٔ دسترسی روی «یک نوبت مشخص».
|
|
*
|
|
* پیش از این، مسیرهای تکنوبت فقط بیمار، پزشکِ مالک و ادمین را میشناختند؛ نوبتی که
|
|
* کاربر کلینیک از مسیر /my/appointment میساخت، روی مشاهده و ویرایش ۴۰۳ میگرفت.
|
|
* محیط نوبت با appointment.clinic بیان میشود (NULL یعنی مطب شخصی) و همان مبنای
|
|
* تصمیم است — نه نقش کاربر.
|
|
*
|
|
* اکشنها از همان واژگان ClinicDoctorPermission/DoctorSecretary گرفته شدهاند تا
|
|
* «پایان همکاری» فقط یک منبع حقیقت داشته باشد: active=false در همان رکوردها.
|
|
*/
|
|
class AppointmentAccessChecker
|
|
{
|
|
public const ACTION_VIEW = 'view';
|
|
public const ACTION_UPDATE_STATUS = 'update_status';
|
|
public const ACTION_CANCEL = 'cancel';
|
|
|
|
private const RESOURCE = 'appointments';
|
|
|
|
public function __construct(
|
|
private readonly ClinicDoctorPermissionChecker $clinicPermissions,
|
|
private readonly SecretaryPermissionChecker $secretaryPermissions,
|
|
private readonly DoctorSecretaryRepository $secretaryRepo,
|
|
private readonly EntityContextResolver $contextResolver,
|
|
private readonly ClinicRepository $clinicRepo,
|
|
) {}
|
|
|
|
public function canView(Appointment $appointment, User $user): bool
|
|
{
|
|
return $this->can($appointment, $user, self::ACTION_VIEW);
|
|
}
|
|
|
|
/** مجوز تغییر نوبت: ویرایش، جابهجایی، رزرو، جایگزینی و تغییر وضعیت. */
|
|
public function canManage(Appointment $appointment, User $user): bool
|
|
{
|
|
return $this->can($appointment, $user, self::ACTION_UPDATE_STATUS);
|
|
}
|
|
|
|
public function canCancel(Appointment $appointment, User $user): bool
|
|
{
|
|
return $this->can($appointment, $user, self::ACTION_CANCEL);
|
|
}
|
|
|
|
public function can(Appointment $appointment, User $user, string $action): bool
|
|
{
|
|
if ($user->hasRole('ROLE_ADMIN')) {
|
|
return true;
|
|
}
|
|
|
|
if ($appointment->getDoctor()->getUser()->getId() === $user->getId()) {
|
|
return true;
|
|
}
|
|
|
|
// بیمار نوبت خودش را میبیند و لغو میکند، ولی جابهجا/ویرایش نمیکند.
|
|
if ($appointment->getUser()->getId() === $user->getId()) {
|
|
return $action === self::ACTION_VIEW || $action === self::ACTION_CANCEL;
|
|
}
|
|
|
|
$clinic = $appointment->getClinic();
|
|
if ($clinic !== null && $this->clinicPermissions->can($user, $clinic, self::RESOURCE, $action)) {
|
|
return true;
|
|
}
|
|
|
|
return $this->secretaryCan($appointment, $user, $action);
|
|
}
|
|
|
|
/**
|
|
* آیا این کاربر میتواند در محیطِ (پزشک + کلینیک) نوبت مدیریت/ثبت کند — بدون آنکه
|
|
* هنوز نوبتی وجود داشته باشد. برای اندپوینتهای اسلات که عمومیاند ولی وقتی از پنل
|
|
* (با management=1) صدا زده میشوند باید توگلِ نوبتدهی آنلاین را دور بزنند.
|
|
*
|
|
* منطق همان can() است اما روی محیط، نه روی یک Appointment مشخص.
|
|
*/
|
|
public function canManageContext(User $user, \App\Doctor\Entity\Doctor $doctor, ?\App\Clinic\Entity\Clinic $clinic): bool
|
|
{
|
|
if ($user->hasRole('ROLE_ADMIN')) {
|
|
return true;
|
|
}
|
|
|
|
if ($doctor->getUser()->getId() === $user->getId()) {
|
|
return true;
|
|
}
|
|
|
|
if ($clinic !== null && $this->clinicPermissions->can($user, $clinic, self::RESOURCE, self::ACTION_UPDATE_STATUS)) {
|
|
return true;
|
|
}
|
|
|
|
return $this->secretaryCanContext($user, $doctor, $clinic);
|
|
}
|
|
|
|
/**
|
|
* منشی در محیطِ فعالِ خودش، اما روی محیط (پزشک/کلینیک) نه یک نوبت مشخص.
|
|
* قرینهٔ secretaryCan() است.
|
|
*/
|
|
private function secretaryCanContext(User $user, \App\Doctor\Entity\Doctor $doctor, ?\App\Clinic\Entity\Clinic $clinic): bool
|
|
{
|
|
$context = $this->contextResolver->resolve($user);
|
|
if (!$context->isResolved()) {
|
|
return false;
|
|
}
|
|
|
|
if ($context->isClinic()) {
|
|
if ($clinic === null || $context->id !== $clinic->getId()) {
|
|
return false;
|
|
}
|
|
|
|
$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 || $context->id !== $doctor->getId()) {
|
|
return false;
|
|
}
|
|
|
|
$relation = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
|
|
|
|
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, self::ACTION_UPDATE_STATUS);
|
|
}
|
|
|
|
/**
|
|
* کلینیکی که این کاربر در آن اجازهٔ دیدن نوبتهای این پزشک را دارد، یا null.
|
|
* برای لیستهایی که باید به یک محیط محدود شوند (نه تکنوبت).
|
|
*/
|
|
public function viewableClinicFor(User $user, \App\Doctor\Entity\Doctor $doctor): ?\App\Clinic\Entity\Clinic
|
|
{
|
|
$context = $this->contextResolver->resolve($user);
|
|
|
|
// بیرون از محیط کلینیک، کلینیکِ تحتِ مالکیت بازمیگردد: کاربری که هم پزشک
|
|
// است و هم مالک کلینیک، بدون محیط فعال بهعنوان پزشک حل میشود ولی هنوز
|
|
// باید لیست نوبتهای کلینیک خودش را ببیند.
|
|
$clinic = $context->isClinic() ? $context->clinic : $this->clinicRepo->findByUser($user);
|
|
|
|
if ($clinic === null || !$clinic->hasDoctor($doctor)) {
|
|
return null;
|
|
}
|
|
|
|
return $this->clinicPermissions->can($user, $clinic, self::RESOURCE, self::ACTION_VIEW)
|
|
? $clinic
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* منشی در محیط فعالِ خودش. در محیط کلینیک، نوبت باید هم متعلق به همان کلینیک
|
|
* باشد و هم پزشکش جزو پزشکان تخصیصیافته به این منشی — عضویت در کلینیک بهتنهایی
|
|
* یعنی منشیِ یک پزشک بتواند نوبت پزشک دیگری را دستکاری کند.
|
|
*/
|
|
private function secretaryCan(Appointment $appointment, User $user, string $action): bool
|
|
{
|
|
$context = $this->contextResolver->resolve($user);
|
|
if (!$context->isResolved()) {
|
|
return false;
|
|
}
|
|
|
|
if ($context->isClinic()) {
|
|
if ($appointment->getClinic()?->getId() !== $context->id) {
|
|
return false;
|
|
}
|
|
|
|
$relation = $this->secretaryRepo->findActiveClinicRow($user, $context->clinic, $appointment->getDoctor());
|
|
|
|
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, $action);
|
|
}
|
|
|
|
$doctor = $appointment->getDoctor();
|
|
if ($context->id !== $doctor->getId()) {
|
|
return false;
|
|
}
|
|
|
|
$relation = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
|
|
|
|
return $relation !== null && $this->secretaryPermissions->can($relation, self::RESOURCE, $action);
|
|
}
|
|
}
|