feat(migrations): add clinic_id context to weekly_schedules, date_overrides, and holidays
- Introduced clinic_id to weekly_schedules, date_overrides, and holidays to differentiate between personal and clinic schedules. - Updated unique constraints and indexes to accommodate the new clinic context. feat(command): create AssignScheduleClinicCommand to move schedules - Added a command to move a doctor's personal weekly schedule into a clinic context. - Implemented checks to ensure sessions align with the target clinic. feat(context): implement EntityContext and EntityContextResolver - Created EntityContext to represent the effective working environment of a request (doctor or clinic). - Developed EntityContextResolver to determine the execution context based on user roles and active contexts. test: add ServiceModeContextTest for appointment scheduling - Implemented tests to ensure service booking respects clinic and personal contexts. - Verified that financial data is omitted in clinic contexts in InvitedDoctorDashboardScopeTest.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Context;
|
||||
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
|
||||
/**
|
||||
* محیط کاری مؤثر یک درخواست: یا مطب شخصی یک پزشک، یا یک کلینیک.
|
||||
*
|
||||
* سرویسها، آدرسها و برنامهٔ نوبتدهی همگی به یکی از این دو تعلق دارند و هرگز بین
|
||||
* آنها مشترک نمیشوند. type/id دقیقاً همان جفتی است که ServiceSection با
|
||||
* entity_type/entity_id ذخیره میکند.
|
||||
*/
|
||||
final class EntityContext
|
||||
{
|
||||
public const TYPE_DOCTOR = 'doctor';
|
||||
public const TYPE_CLINIC = 'clinic';
|
||||
public const TYPE_UNKNOWN = 'unknown';
|
||||
|
||||
private function __construct(
|
||||
public readonly string $type,
|
||||
public readonly ?int $id,
|
||||
public readonly ?Clinic $clinic = null,
|
||||
public readonly ?Doctor $doctor = null,
|
||||
) {}
|
||||
|
||||
public static function forDoctor(?Doctor $doctor): self
|
||||
{
|
||||
return new self(self::TYPE_DOCTOR, $doctor?->getId(), null, $doctor);
|
||||
}
|
||||
|
||||
public static function forClinic(Clinic $clinic): self
|
||||
{
|
||||
return new self(self::TYPE_CLINIC, $clinic->getId(), $clinic);
|
||||
}
|
||||
|
||||
public static function unknown(): self
|
||||
{
|
||||
return new self(self::TYPE_UNKNOWN, null);
|
||||
}
|
||||
|
||||
public function isClinic(): bool { return $this->type === self::TYPE_CLINIC; }
|
||||
|
||||
public function isResolved(): bool { return $this->id !== null; }
|
||||
|
||||
/** @return array{0: string, 1: ?int} جفت (entity_type, entity_id) برای ServiceSection */
|
||||
public function toEntityPair(): array
|
||||
{
|
||||
return [$this->type, $this->id];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?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\Repository\DoctorRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Exception\AppException;
|
||||
|
||||
/**
|
||||
* تنها نقطهٔ تصمیمگیری دربارهٔ «این درخواست در کدام محیط اجرا میشود؟».
|
||||
*
|
||||
* اولویت: clinic_uuid صریحِ درخواست > محیط فعالِ ذخیرهشدهٔ کاربر > نقش کاربر.
|
||||
*
|
||||
* نقش بهتنهایی برای کاربری که هم پزشک است و هم مالک کلینیک جواب نمیدهد: چنین
|
||||
* کاربری همیشه بهعنوان پزشک حل میشد و هرگز به سرویسهای کلینیک خودش نمیرسید.
|
||||
* UserActiveContext تعیینکننده است و نقش فقط fallback آخر.
|
||||
*/
|
||||
class EntityContextResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly UserActiveContextRepository $activeContextRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
|
||||
$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 canActInClinic(User $user, Clinic $clinic): bool
|
||||
{
|
||||
if ($user->hasRole('ROLE_ADMIN') || $clinic->getUser()->getId() === $user->getId()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUser($user);
|
||||
|
||||
return $doctor !== null && $clinic->hasDoctor($doctor);
|
||||
}
|
||||
|
||||
public function assertCanActInClinic(User $user, Clinic $clinic): void
|
||||
{
|
||||
if (!$this->canActInClinic($user, $clinic)) {
|
||||
throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, 'به این کلینیک دسترسی ندارید', 403);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* محیط فعالِ ذخیرهشده. db_uuid یا uuid کلینیک است یا uuid پزشک؛ کلینیک اول
|
||||
* بررسی میشود چون پزشکِ دعوتشده هم db_uuid کلینیک را ذخیره میکند.
|
||||
*/
|
||||
private function fromActiveContext(User $user): ?EntityContext
|
||||
{
|
||||
$active = $this->activeContextRepo->findByUser($user);
|
||||
if ($active === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$clinic = $this->clinicRepo->findByUuid($active->getDbUuid());
|
||||
if ($clinic !== null) {
|
||||
return $this->canActInClinic($user, $clinic) ? EntityContext::forClinic($clinic) : null;
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($active->getDbUuid());
|
||||
if ($doctor !== null && $doctor->getUser()->getId() === $user->getId()) {
|
||||
return EntityContext::forDoctor($doctor);
|
||||
}
|
||||
|
||||
return 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();
|
||||
}
|
||||
|
||||
return EntityContext::unknown();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user