Files
clinicpro/.claude/prompt/secretary-context-scope.md
T
hamed df7a784701 feat: implement realistic data seeding for doctors, clinics, and secretaries
- Added seed_realistic_data.php to clean existing data and populate the database with realistic entries for doctors, clinics, and secretaries.
- Created a structured approach to generate 100 doctors per city with diverse specialties and services.
- Implemented database cleanup routines to ensure a fresh start for data seeding.
- Enhanced the DoctorSecretaryRepository with improved comments for clarity.
2026-06-15 14:18:25 +03:30

14 KiB
Raw Blame History

منطق scope منشی: تفکیک کلینیک از مطب شخصی

زمینه

در حال حاضر DoctorSecretary فقط یک رابطه (doctor_id, secretary_id) دارد. اگر منشی A به دکتر X اختصاص داده شود — فرق نمی‌کند از طرف کلینیک باشد یا از طرف خود دکتر — در همه جا یکسان رفتار می‌شود. این باعث می‌شود:

  • منشی‌ای که کلینیک برای دکتر X تعریف کرده، به نوبت‌های مطب شخصی همان دکتر هم دسترسی داشته باشد.
  • منشی‌ای که خود دکتر تعریف کرده، در داشبورد همه روابط کلینیکی او را هم می‌بیند.
  • کلینیک می‌تواند منشی‌هایی را ببیند و حذف کند که دکتر شخصاً تعریف کرده است.

هدف

اضافه کردن فیلد owner_type ('doctor' | 'clinic') و clinic_id (nullable FK) به DoctorSecretary تا هر رابطه منشی-پزشک به یک scope تعلق داشته باشد.

قوانین:

ایجادکننده owner_type clinic_id
خود دکتر 'doctor' null
کلینیک 'clinic' <id کلینیک>

دسترسی‌ها، داشبورد، و فیلتر نوبت‌ها باید فقط در scope مربوطه اعمال شوند.

فایل‌های مرتبط

فایل نقش
src/Secretary/Entity/DoctorSecretary.php entity اصلی — باید owner_type و clinic_id اضافه شود
src/Secretary/Controller/SecretaryController.php ایجاد و لیست منشی — باید scope را تنظیم کند
src/Secretary/Repository/DoctorSecretaryRepository.php query ها — باید scope-aware شوند
src/Appointment/Controller/MyAppointmentsController.php فیلتر نوبت‌های منشی — باید scope-aware شود
src/Dashboard/Controller/DashboardController.php داشبورد منشی — باید scope-aware شود
src/Patient/Controller/PatientController.php دسترسی منشی به پرونده بیمار — باید scope-aware شود
src/Auth/Controller/AuthController.php /api/v1/me context builder — باید scope را در context نشان دهد
migrations/ migration جدید برای owner_type + clinic_id
docs/api/secretary.md مستندات API

وضعیت فعلی

Entity (بدون scope)

// src/Secretary/Entity/DoctorSecretary.php
// هیچ فیلد owner_type یا clinic_id وجود ندارد
#[ORM\UniqueConstraint(name: 'idx_doctor_secretaries_pair', columns: ['doctor_id', 'secretary_id'])]
class DoctorSecretary
{
    #[ORM\ManyToOne(targetEntity: Doctor::class)]
    private Doctor $doctor;

    #[ORM\ManyToOne(targetEntity: User::class)]
    private User $secretary;
    // ...
}

Repository — scope-blind

public function findActiveBySecretary(User $user): ?DoctorSecretary
{
    // فقط اولین رابطه فعال را برمی‌گرداند — scope در نظر گرفته نمی‌شود
    return $this->findOneBy(['secretary' => $user, 'active' => true]);
}

Controller — ایجاد بدون scope

// create() در SecretaryController
// هیچ owner_type یا clinic_id ست نمی‌شود
$secretary = new DoctorSecretary($doctor, $secretaryUser);

Auth context builder — scope-blind

// AuthController::buildContexts()
foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
    $contexts[] = [
        'type'        => 'doctor',
        'db_uuid'     => $rel->getDoctor()->getUuid(),
        'name'        => 'مطب ' . $rel->getDoctor()->getName(),
        'role'        => 'secretary',
        'permissions' => $rel->getPermissions(),
    ];
}
// اگر منشی هم در کلینیک هم در مطب شخصی یک دکتر باشد، دو context با db_uuid یکسان می‌سازد
// و هیچ‌کدام نشان نمی‌دهند که کدام scope است

Appointment فیلتر — فقط یک رابطه

// MyAppointmentsController — منشی فقط به نوبت‌های یک دکتر دسترسی دارد
} elseif (in_array('ROLE_SECRETARY', $roles, true)) {
    $rel = $this->secretaryRepo->findActiveBySecretary($user); // اولین رابطه
    $qb->andWhere('a.doctor = :doctor')
       ->setParameter('doctor', $rel->getDoctor());
}

وظایف

۱. Entity: اضافه کردن owner_type و clinic_id

// src/Secretary/Entity/DoctorSecretary.php

// unique constraint باید scope را هم در نظر بگیرد:
// یک منشی می‌تواند هم از طرف کلینیک هم از طرف خود دکتر به همان پزشک متصل باشد
#[ORM\UniqueConstraint(
    name: 'idx_doctor_secretary_scope',
    columns: ['doctor_id', 'secretary_id', 'owner_type', 'clinic_id']
)]

#[ORM\Column(name: 'owner_type', type: 'string', length: 10)]
private string $ownerType; // 'doctor' | 'clinic'

#[ORM\ManyToOne(targetEntity: Clinic::class)]
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
private ?Clinic $clinic = null;

public function __construct(Doctor $doctor, User $secretary, string $ownerType = 'doctor', ?Clinic $clinic = null)
{
    // ...
    $this->ownerType = $ownerType;
    $this->clinic    = $clinic;
}

public function getOwnerType(): string  { return $this->ownerType; }
public function getClinic(): ?Clinic    { return $this->clinic; }

در toArray() هم اضافه شود:

'owner_type'  => $this->ownerType,
'clinic_uuid' => $this->clinic?->getUuid(),

۲. Migration

بعد از تغییر Entity:

ddev exec php bin/console doctrine:migrations:diff --no-interaction
ddev exec php bin/console doctrine:migrations:migrate --no-interaction

unique constraint قدیمی idx_doctor_secretaries_pair باید drop شود و جدید جای آن را بگیرد.

۳. Repository: query های scope-aware

// src/Secretary/Repository/DoctorSecretaryRepository.php

/** منشی در context مطب شخصی (owner_type='doctor') */
public function findActiveBySecretaryForDoctor(User $user, Doctor $doctor): ?DoctorSecretary
{
    return $this->findOneBy([
        'secretary' => $user,
        'doctor'    => $doctor,
        'ownerType' => 'doctor',
        'active'    => true,
    ]);
}

/** منشی در context کلینیک */
public function findActiveBySecretaryForClinic(User $user, Clinic $clinic): ?DoctorSecretary
{
    // یک منشی می‌تواند به چند دکتر در یک کلینیک متصل باشد — اولین را برگردان برای چک دسترسی کلی
    return $this->createQueryBuilder('s')
        ->where('s.secretary = :user')
        ->andWhere('s.clinic = :clinic')
        ->andWhere('s.ownerType = :type')
        ->andWhere('s.active = true')
        ->setParameter('user', $user)
        ->setParameter('clinic', $clinic)
        ->setParameter('type', 'clinic')
        ->setMaxResults(1)
        ->getQuery()
        ->getOneOrNullResult();
}

/** همه روابط فعال یک منشی — برای auth context builder */
public function findAllActiveBySecretary(User $user): array
{
    return $this->findBy(['secretary' => $user, 'active' => true]);
}

/** findActiveBySecretary قدیمی — نگه داشته شود برای backward compat اما deprecated */
public function findActiveBySecretary(User $user): ?DoctorSecretary
{
    return $this->findOneBy(['secretary' => $user, 'active' => true]);
}

/** همه دکترهای یک کلینیک که این منشی به آن‌ها متصل است */
public function findDoctorsBySecretaryInClinic(User $user, Clinic $clinic): array
{
    return $this->createQueryBuilder('s')
        ->select('d')
        ->join('s.doctor', 'd')
        ->where('s.secretary = :user')
        ->andWhere('s.clinic = :clinic')
        ->andWhere('s.ownerType = :type')
        ->andWhere('s.active = true')
        ->setParameter('user', $user)
        ->setParameter('clinic', $clinic)
        ->setParameter('type', 'clinic')
        ->getQuery()
        ->getResult();
}

همچنین findByClinic باید فقط منشی ها با owner_type='clinic' را برگرداند:

public function findByClinic(Clinic $clinic): array
{
    return $this->createQueryBuilder('s')
        ->join('s.doctor', 'd')
        ->where('s.clinic = :clinic')  // اصلاح: به جای d.id IN (:ids)
        ->andWhere('s.ownerType = :type')
        ->setParameter('clinic', $clinic)
        ->setParameter('type', 'clinic')
        ->orderBy('s.createdAt', 'DESC')
        ->getQuery()
        ->getResult();
}

۴. SecretaryController: ایجاد با scope

در create():

  • اگر currentUser دارای ROLE_CLINIC بود → ownerType = 'clinic'، clinic = clinicRepo->findByUser($currentUser)
  • اگر ROLE_DOCTOR بود → ownerType = 'doctor'، clinic = null
if ($currentUser->hasRole('ROLE_CLINIC')) {
    $clinic    = $this->clinicRepo->findByUser($currentUser);
    $secretary = new DoctorSecretary($doctor, $secretaryUser, 'clinic', $clinic);
} else {
    $secretary = new DoctorSecretary($doctor, $secretaryUser, 'doctor', null);
}

همچنین canManageDoctor() باید scope را چک کند:

  • ROLE_DOCTOR فقط می‌تواند روابط owner_type='doctor' را مدیریت کند
  • ROLE_CLINIC فقط می‌تواند روابط owner_type='clinic' متعلق به کلینیک خودش را مدیریت کند

۵. Auth context builder: scope-aware

در AuthController::buildContexts() هر رابطه باید context مجزا با scope داشته باشد:

foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
    if ($rel->getOwnerType() === 'clinic' && $rel->getClinic() !== null) {
        // context کلینیک — اگر قبلاً اضافه نشده
        $clinicUuid = $rel->getClinic()->getUuid();
        $alreadyAdded = array_filter($contexts, fn($c) => $c['db_uuid'] === $clinicUuid && $c['role'] === 'secretary_clinic');
        if (empty($alreadyAdded)) {
            $contexts[] = [
                'type'        => 'clinic',
                'db_uuid'     => $clinicUuid,
                'name'        => 'کلینیک ' . $rel->getClinic()->getName(),
                'role'        => 'secretary',
                'scope'       => 'clinic',
                'permissions' => $rel->getPermissions(),
            ];
        }
    } else {
        // context مطب شخصی
        $contexts[] = [
            'type'        => 'doctor',
            'db_uuid'     => $rel->getDoctor()->getUuid(),
            'name'        => 'مطب ' . $rel->getDoctor()->getName(),
            'role'        => 'secretary',
            'scope'       => 'doctor',
            'permissions' => $rel->getPermissions(),
        ];
    }
}

۶. Appointment و Dashboard: scope-aware

در MyAppointmentsController و DashboardController به جای findActiveBySecretary:

  • اگر db_uuid در JWT به یک کلینیک اشاره دارد → همه دکترهای کلینیک که این منشی به آن‌ها متصل است
  • اگر به یک دکتر اشاره دارد → فقط همان دکتر با owner_type='doctor'

برای تشخیص context فعلی در runtime: JWT payload را parse کن (یا db_uuid را از request header بگیر اگر پیاده‌سازی شده) و سپس:

// اگر db_uuid کلینیک بود:
$doctors = $this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic);
$qb->andWhere('a.doctor IN (:doctors)')->setParameter('doctors', $doctors);

// اگر db_uuid دکتر بود:
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
$qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $rel->getDoctor());

۷. PatientController: scope-aware

// PatientController::resolveEntity()
if ($user->hasRole('ROLE_SECRETARY')) {
    // تعیین scope از db_uuid در JWT
    // اگر کلینیک: entityType='clinic'
    // اگر مطب شخصی: entityType='doctor'
}

نکات مهم

  • Unique constraint جدید: (doctor_id, secretary_id, owner_type, clinic_id) — یک منشی می‌تواند هم از کلینیک هم از خود دکتر به پزشک مشابه متصل باشد (دو ردیف جداگانه با owner_type متفاوت).
  • Migration: فیلد owner_type باید default 'doctor' داشته باشد تا رکوردهای قبلی break نشوند. clinic_id nullable است.
  • تشخیص scope در runtime: JWT یا header باید db_uuid فعلی را حمل کند. ابتدا بررسی کن آیا این uuid متعلق به یک کلینیک است یا دکتر — با clinicRepo->findByUuid() و doctorRepo->findByUuid().
  • canManage در SecretaryController: رابطه owner_type='clinic' را فقط صاحب آن کلینیک می‌تواند ویرایش/حذف کند، نه خود دکتر. و بالعکس.
  • findByClinic باید clinic_id = :clinic را به جای d.id IN (:ids) استفاده کند تا صحیح باشد.
  • Frontend: فیلد scope را در context برگشتی از /me نمایش بده. در authStore نگه دار تا صفحات بدانند منشی در چه scope‌ای است.