feat(secretary): implement multi-doctor assignment for clinic secretaries

- Added functionality to assign a single secretary to multiple doctors within a clinic, allowing for scoped access to appointments.
- Introduced `SecretaryService` to handle the logic for assigning and syncing doctors for a secretary.
- Updated `SecretaryController` to support multi-doctor assignment via new endpoints and modified existing ones.
- Enhanced `DoctorSecretary` entity to include secretary UUID in its serialized output.
- Implemented repository methods to facilitate the retrieval and management of doctor-secretary relationships.
- Adjusted appointment filtering in `MyAppointmentsController` to ensure secretaries only see appointments for assigned doctors.
- Created tests to validate the new multi-doctor assignment functionality and appointment access restrictions.
- Updated frontend components to support multi-select for doctors in the secretary management UI.
This commit is contained in:
hamed
2026-07-18 08:49:04 +03:30
parent 6ab7ed38b8
commit 1779e0d6de
13 changed files with 947 additions and 51 deletions
@@ -0,0 +1,122 @@
<?php
namespace App\Tests\Secretary;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Auth\Entity\User;
use App\Tests\ApiTestCase;
/**
* A clinic manager can assign one secretary to several of the clinic's doctors
* at once (many DoctorSecretary rows for one secretary User) and later re-sync
* that set. Doctors outside the clinic are rejected; foreign owners are 403.
*/
class ClinicSharedSecretaryTest extends ApiTestCase
{
/** @return array{0: User, 1: Clinic, 2: Doctor[]} */
private function makeClinicWithDoctors(int $count): array
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new Clinic($owner);
$this->em->persist($clinic);
$doctors = [];
for ($i = 0; $i < $count; $i++) {
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), "دکتر $i");
$this->em->persist($doctor);
$clinic->getDoctors()->add($doctor);
$doctors[] = $doctor;
}
$this->em->flush();
return [$owner, $clinic, $doctors];
}
private function mobile(): string
{
return '09' . str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
}
public function testClinicOwnerAssignsSecretaryToMultipleDoctors(): void
{
[$owner, , $doctors] = $this->makeClinicWithDoctors(3);
$body = $this->authJson('POST', '/api/v1/secretary', $owner, [
'mobile_number' => $this->mobile(),
'name' => 'منشی مشترک',
'doctor_uuids' => [$doctors[0]->getUuid(), $doctors[1]->getUuid()],
]);
$this->assertSame(201, $this->responseCode());
$this->assertCount(2, $body['data']['created']);
$this->assertSame([], $body['data']['skipped_not_in_clinic']);
$this->assertNotEmpty($body['data']['secretary_uuid']);
}
public function testSkipsDuplicatesOnReassign(): void
{
[$owner, , $doctors] = $this->makeClinicWithDoctors(2);
$mobile = $this->mobile();
$uuids = [$doctors[0]->getUuid(), $doctors[1]->getUuid()];
$this->authJson('POST', '/api/v1/secretary', $owner, ['mobile_number' => $mobile, 'doctor_uuids' => $uuids]);
$body = $this->authJson('POST', '/api/v1/secretary', $owner, ['mobile_number' => $mobile, 'doctor_uuids' => $uuids]);
$this->assertSame(201, $this->responseCode());
$this->assertCount(0, $body['data']['created']);
$this->assertCount(2, $body['data']['skipped_duplicate']);
}
public function testRejectsDoctorOutsideClinic(): void
{
[$owner, , $doctors] = $this->makeClinicWithDoctors(1);
$foreignDoctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'خارج از کلینیک');
$this->em->persist($foreignDoctor);
$this->em->flush();
$body = $this->authJson('POST', '/api/v1/secretary', $owner, [
'mobile_number' => $this->mobile(),
'doctor_uuids' => [$doctors[0]->getUuid(), $foreignDoctor->getUuid()],
]);
$this->assertSame(201, $this->responseCode());
$this->assertCount(1, $body['data']['created']);
$this->assertContains($foreignDoctor->getUuid(), $body['data']['skipped_not_in_clinic']);
}
public function testSyncAddsAndRemovesDoctors(): void
{
[$owner, $clinic, $doctors] = $this->makeClinicWithDoctors(3);
$mobile = $this->mobile();
$assigned = $this->authJson('POST', '/api/v1/secretary', $owner, [
'mobile_number' => $mobile,
'doctor_uuids' => [$doctors[0]->getUuid(), $doctors[1]->getUuid()],
]);
$secretaryUuid = $assigned['data']['secretary_uuid'];
// sync to {doctor1, doctor2} → drop doctor0, add doctor2
$body = $this->authJson('PUT', '/api/v1/secretaries/clinic/' . $clinic->getUuid() . '/doctors', $owner, [
'secretary_uuid' => $secretaryUuid,
'doctor_uuids' => [$doctors[1]->getUuid(), $doctors[2]->getUuid()],
]);
$this->assertSame(200, $this->responseCode());
$this->assertSame(1, $body['data']['added']); // doctor2
$this->assertSame(1, $body['data']['removed']); // doctor0
}
public function testForeignOwnerCannotSync(): void
{
[, $clinic, ] = $this->makeClinicWithDoctors(1);
$intruder = $this->createUser(['ROLE_CLINIC']);
$this->authJson('PUT', '/api/v1/secretaries/clinic/' . $clinic->getUuid() . '/doctors', $intruder, [
'secretary_uuid' => 'whatever',
'doctor_uuids' => [],
]);
$this->assertSame(403, $this->responseCode());
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Tests\Secretary;
use App\Appointment\Entity\Appointment;
use App\Auth\Entity\UserActiveContext;
use App\Clinic\Entity\Clinic;
use App\Doctor\Entity\Doctor;
use App\Secretary\Entity\DoctorSecretary;
use App\Tests\ApiTestCase;
/**
* A clinic-owned secretary must only see the appointments of the doctors they
* are actually assigned to — not every doctor in the clinic.
*/
class SecretaryAppointmentScopeTest extends ApiTestCase
{
public function testSecretarySeesOnlyAssignedDoctorsAppointments(): void
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new Clinic($owner);
$this->em->persist($clinic);
$doctorA = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر A');
$doctorB = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر B');
$this->em->persist($doctorA);
$this->em->persist($doctorB);
$clinic->getDoctors()->add($doctorA);
$clinic->getDoctors()->add($doctorB);
// منشی فقط به دکتر A تخصیص داده شده
$secretaryUser = $this->createUser(['ROLE_SECRETARY']);
$rel = new DoctorSecretary($doctorA, $secretaryUser, DoctorSecretary::OWNER_CLINIC, $clinic);
$this->em->persist($rel);
// scope فعالِ منشی = این کلینیک
$this->em->persist(new UserActiveContext($secretaryUser, $clinic->getUuid()));
// یک نوبت برای هر پزشک
$patient = $this->createUser(['ROLE_USER']);
$start = time() + 3600;
$this->em->persist(new Appointment($doctorA, $patient, $start, $start + 900));
$this->em->persist(new Appointment($doctorB, $patient, $start + 1800, $start + 2700));
$this->em->flush();
$body = $this->authJson('GET', '/api/v1/my/appointments', $secretaryUser);
$this->assertSame(200, $this->responseCode());
// فقط نوبت دکتر A دیده می‌شود، نه دکتر B
$this->assertSame(1, $body['meta']['totalRecords']);
}
public function testSecretaryWithNoAssignmentSeesNothing(): void
{
$owner = $this->createUser(['ROLE_CLINIC']);
$clinic = new Clinic($owner);
$this->em->persist($clinic);
$doctor = new Doctor($this->createUser(['ROLE_DOCTOR']), 'دکتر تنها');
$this->em->persist($doctor);
$clinic->getDoctors()->add($doctor);
// منشی context کلینیک دارد ولی رابطه‌ی فعال ندارد
$secretaryUser = $this->createUser(['ROLE_SECRETARY']);
$this->em->persist(new UserActiveContext($secretaryUser, $clinic->getUuid()));
$patient = $this->createUser(['ROLE_USER']);
$start = time() + 3600;
$this->em->persist(new Appointment($doctor, $patient, $start, $start + 900));
$this->em->flush();
$body = $this->authJson('GET', '/api/v1/my/appointments', $secretaryUser);
// بدون رابطه‌ی فعال → resolveSecretaryFilter=null → لیست خالی
$this->assertSame(0, $body['meta']['totalRecords']);
}
}