- 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.
326 lines
14 KiB
Markdown
326 lines
14 KiB
Markdown
# منطق 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)
|
||
|
||
```php
|
||
// 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
|
||
|
||
```php
|
||
public function findActiveBySecretary(User $user): ?DoctorSecretary
|
||
{
|
||
// فقط اولین رابطه فعال را برمیگرداند — scope در نظر گرفته نمیشود
|
||
return $this->findOneBy(['secretary' => $user, 'active' => true]);
|
||
}
|
||
```
|
||
|
||
### Controller — ایجاد بدون scope
|
||
|
||
```php
|
||
// create() در SecretaryController
|
||
// هیچ owner_type یا clinic_id ست نمیشود
|
||
$secretary = new DoctorSecretary($doctor, $secretaryUser);
|
||
```
|
||
|
||
### Auth context builder — scope-blind
|
||
|
||
```php
|
||
// 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 فیلتر — فقط یک رابطه
|
||
|
||
```php
|
||
// 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`
|
||
|
||
```php
|
||
// 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()` هم اضافه شود:
|
||
|
||
```php
|
||
'owner_type' => $this->ownerType,
|
||
'clinic_uuid' => $this->clinic?->getUuid(),
|
||
```
|
||
|
||
### ۲. Migration
|
||
|
||
بعد از تغییر Entity:
|
||
|
||
```bash
|
||
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
|
||
|
||
```php
|
||
// 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'` را برگرداند:
|
||
|
||
```php
|
||
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`
|
||
|
||
```php
|
||
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` داشته باشد:
|
||
|
||
```php
|
||
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 بگیر اگر پیادهسازی شده) و سپس:
|
||
|
||
```php
|
||
// اگر 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
|
||
|
||
```php
|
||
// 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ای است.
|