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:
hamed
2026-07-18 13:32:56 +03:30
parent 2553b45990
commit f1258d206d
28 changed files with 2126 additions and 276 deletions
@@ -10,9 +10,9 @@ use App\Doctor\Entity\DoctorAddress;
use App\Tests\ApiTestCase;
/**
* Appointment settings are reachable by the doctor, an admin, and the owner of a
* clinic the doctor belongs to. A member doctor's own access is governed by the
* clinic's appointment_settings permission. Edits never leak across doctors.
* تنظیمات نوبت‌دهی per-context است: مطب شخصی پزشک (بدون clinic_uuid) فقط برای خود
* پزشک و ادمین باز است، و کلینیک با clinic_uuid فقط برنامهٔ همان کلینیک را
* می‌بیند/می‌نویسد. این دو برنامهٔ جدا هستند و روی هم اثر نمی‌گذارند.
*/
class ClinicOwnerScheduleAccessTest extends ApiTestCase
{
@@ -50,32 +50,43 @@ class ClinicOwnerScheduleAccessTest extends ApiTestCase
return $address;
}
private function schedulePayload(Doctor $doctor, int $locationId, string $start): array
private function clinicAddress(Clinic $clinic): DoctorAddress
{
return [
$address = DoctorAddress::forClinic($clinic->getId());
$this->em->persist($address);
$this->em->flush();
return $address;
}
private function schedulePayload(Doctor $doctor, int $locationId, string $start, ?Clinic $clinic = null): array
{
return array_filter([
'doctor_uuid' => $doctor->getUuid(),
'clinic_uuid' => $clinic?->getUuid(),
'schedule' => [
['day' => 'saturday', 'sessions' => [
['active' => true, 'location_id' => $locationId, 'start' => $start, 'end' => '12:00'],
]],
],
];
], fn($v) => $v !== null);
}
public function testClinicOwnerCanReadAndWriteMemberDoctorSchedule(): void
public function testClinicOwnerCanReadAndWriteMemberDoctorScheduleInClinicContext(): void
{
$doctor = $this->makeDoctor('دکتر عضو');
[$owner] = $this->makeClinicWith($doctor);
$address = $this->addressFor($doctor);
$doctor = $this->makeDoctor('دکتر عضو');
[$owner, $clinic] = $this->makeClinicWith($doctor);
$address = $this->clinicAddress($clinic);
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($doctor, $address->getId(), '09:00'));
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($doctor, $address->getId(), '09:00', $clinic));
self::assertSame(201, $this->responseCode());
$this->authJson('GET', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $owner);
$this->authJson('GET', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}?clinic_uuid={$clinic->getUuid()}", $owner);
self::assertSame(200, $this->responseCode());
$this->authJson('PATCH', "/api/v1/appointment-settings/weekly-schedule/{$doctor->getUuid()}", $owner, [
'schedule' => [
'clinic_uuid' => $clinic->getUuid(),
'schedule' => [
['day' => 'saturday', 'sessions' => [
['active' => true, 'location_id' => $address->getId(), 'start' => '10:00', 'end' => '13:00'],
]],
@@ -84,16 +95,77 @@ class ClinicOwnerScheduleAccessTest extends ApiTestCase
self::assertSame(200, $this->responseCode());
}
public function testClinicOwnerCannotTouchOutsideDoctor(): void
public function testClinicOwnerCannotTouchDoctorPersonalSchedule(): void
{
$member = $this->makeDoctor('دکتر عضو');
[$owner] = $this->makeClinicWith($member);
$stranger = $this->makeDoctor('دکتر بیرونی');
$address = $this->addressFor($stranger);
$doctor = $this->makeDoctor('دکتر عضو');
[$owner] = $this->makeClinicWith($doctor);
$address = $this->addressFor($doctor);
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($stranger, $address->getId(), '09:00'));
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($doctor, $address->getId(), '09:00'));
self::assertSame(403, $this->responseCode());
self::assertSame(403, $this->responseCode(), 'مطب شخصی پزشک از دسترس کلینیک خارج است');
}
public function testClinicContextRejectsPersonalAddress(): void
{
$doctor = $this->makeDoctor('دکتر عضو');
[$owner, $clinic] = $this->makeClinicWith($doctor);
$personal = $this->addressFor($doctor);
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($doctor, $personal->getId(), '09:00', $clinic));
self::assertSame(422, $this->responseCode());
}
public function testPersonalContextRejectsClinicAddress(): void
{
$doctor = $this->makeDoctor('دکتر عضو');
[, $clinic] = $this->makeClinicWith($doctor);
$clinicAddress = $this->clinicAddress($clinic);
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($doctor, $clinicAddress->getId(), '09:00'));
self::assertSame(422, $this->responseCode());
}
public function testStrangerClinicUuidIsRejected(): void
{
$doctor = $this->makeDoctor('دکتر مستقل');
$outsider = $this->makeDoctor('دکتر دیگر');
[, $clinic] = $this->makeClinicWith($outsider);
$address = $this->addressFor($doctor);
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($doctor, $address->getId(), '09:00', $clinic));
self::assertSame(422, $this->responseCode(), 'پزشک عضو این کلینیک نیست');
}
public function testPersonalAndClinicSchedulesCoexistIndependently(): void
{
$doctor = $this->makeDoctor('دکتر دو-محیطی');
[$owner, $clinic] = $this->makeClinicWith($doctor);
$personal = $this->addressFor($doctor);
$inClinic = $this->clinicAddress($clinic);
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($doctor, $personal->getId(), '08:00'));
self::assertSame(201, $this->responseCode());
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($doctor, $inClinic->getId(), '16:00', $clinic));
self::assertSame(201, $this->responseCode());
$this->em->clear();
$reloadedDoctor = $this->em->getRepository(Doctor::class)->find($doctor->getId());
$schedules = $this->em->getRepository(WeeklySchedule::class)->findAllByDoctor($reloadedDoctor);
self::assertCount(2, $schedules, 'یک برنامه به ازای هر محیط');
$byContext = [];
foreach ($schedules as $schedule) {
$byContext[$schedule->getClinic() === null ? 'personal' : 'clinic'] = $schedule->getSetting()[0]['sessions'][0]['start'];
}
self::assertSame('08:00', $byContext['personal']);
self::assertSame('16:00', $byContext['clinic']);
}
public function testDoctorKeepsFullAccessToOwnSchedule(): void
@@ -132,45 +204,45 @@ class ClinicOwnerScheduleAccessTest extends ApiTestCase
public function testMemberDoctorLosesAccessWhenPermissionRevoked(): void
{
$doctor = $this->makeDoctor('دکتر عضو');
$other = $this->makeDoctor('دکتر دیگر');
$doctor = $this->makeDoctor('دکتر عضو');
$other = $this->makeDoctor('دکتر دیگر');
[, $clinic] = $this->makeClinicWith($doctor, $other);
$address = $this->addressFor($other);
$address = $this->clinicAddress($clinic);
$perm = static::getContainer()->get(ClinicDoctorPermissionRepository::class)->getOrCreate($clinic, $doctor);
$perm->mergePermissions(['resources' => ['appointment_settings' => ['update' => false, 'view' => false]]]);
$this->em->flush();
// پزشک همچنان به برنامهٔ خودش دسترسی دارد؛ مجوز کلینیک فقط دیگران را محدود می‌کند
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($other, $address->getId(), '09:00'));
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $doctor->getUser(), $this->schedulePayload($other, $address->getId(), '09:00', $clinic));
self::assertSame(403, $this->responseCode());
}
public function testEditingOneDoctorDoesNotAffectAnother(): void
{
$first = $this->makeDoctor('دکتر اول');
$second = $this->makeDoctor('دکتر دوم');
[$owner] = $this->makeClinicWith($first, $second);
$addrFirst = $this->addressFor($first);
$addrSecond = $this->addressFor($second);
$first = $this->makeDoctor('دکتر اول');
$second = $this->makeDoctor('دکتر دوم');
[$owner, $clinic] = $this->makeClinicWith($first, $second);
$address = $this->clinicAddress($clinic);
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($first, $addrFirst->getId(), '08:00'));
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($second, $addrSecond->getId(), '16:00'));
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($first, $address->getId(), '08:00', $clinic));
$this->authJson('POST', '/api/v1/appointment-settings/weekly-schedule', $owner, $this->schedulePayload($second, $address->getId(), '16:00', $clinic));
$this->authJson('PATCH', "/api/v1/appointment-settings/weekly-schedule/{$first->getUuid()}", $owner, [
'schedule' => [
'clinic_uuid' => $clinic->getUuid(),
'schedule' => [
['day' => 'saturday', 'sessions' => [
['active' => true, 'location_id' => $addrFirst->getId(), 'start' => '11:00', 'end' => '15:00'],
['active' => true, 'location_id' => $address->getId(), 'start' => '11:00', 'end' => '15:00'],
]],
],
]);
self::assertSame(200, $this->responseCode());
$this->em->clear();
$reloaded = $this->em->getRepository(WeeklySchedule::class)->findOneBy([
'doctor' => $this->em->getRepository(Doctor::class)->find($second->getId()),
]);
$reloaded = $this->em->getRepository(WeeklySchedule::class)->findByDoctorAndClinic(
$this->em->getRepository(Doctor::class)->find($second->getId()),
$this->em->getRepository(Clinic::class)->find($clinic->getId()),
);
self::assertSame('16:00', $reloaded->getSetting()[0]['sessions'][0]['start'], "the other doctor's schedule is untouched");
}