feat(secretary): implement scope separation for secretaries in clinics and personal practices
- Added `owner_type` and `clinic_id` fields to `DoctorSecretary` entity to distinguish between clinic and personal practice relationships. - Updated repository methods to be scope-aware, allowing for specific queries based on the context of the secretary's relationship (clinic or doctor). - Modified `SecretaryController` to handle secretary creation with appropriate scope based on the current user's role. - Enhanced `AuthController` to build contexts that reflect the scope of the secretary's access. - Updated `DashboardController` and `PatientController` to respect the new scope logic when retrieving data. - Created migration to update the database schema accordingly, dropping the old unique constraint and adding the new fields and constraints.
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
# منطق 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ای است.
|
||||
+25
-3
@@ -2,6 +2,19 @@
|
||||
|
||||
> **Prefix:** `/api/v1/secretary`, `/api/v1/secretaries`
|
||||
|
||||
## مدل Scope
|
||||
|
||||
هر رابطه منشی-پزشک دارای یک **scope** است که از تداخل بین محیطهای مختلف جلوگیری میکند:
|
||||
|
||||
| Scope | `owner_type` | تعریفکننده | دسترسی |
|
||||
|-------|-------------|-------------|---------|
|
||||
| مطب شخصی | `doctor` | خود پزشک | فقط نوبتها و دادههای مطب شخصی |
|
||||
| کلینیک | `clinic` | مدیر کلینیک | فقط نوبتها و دادههای کلینیک |
|
||||
|
||||
- یک منشی میتواند هم در مطب شخصی یک دکتر و هم در کلینیک همان دکتر فعال باشد (دو ردیف مجزا)
|
||||
- منشی کلینیک میتواند به چند دکتر در همان کلینیک متصل باشد
|
||||
- scope فعال در runtime از جدول `user_active_context` (db_uuid) خوانده میشود
|
||||
|
||||
Secretaries are linked to a doctor and have granular permissions controlling what they can do on behalf of the doctor.
|
||||
|
||||
---
|
||||
@@ -10,7 +23,7 @@ Secretaries are linked to a doctor and have granular permissions controlling wha
|
||||
|
||||
Create a secretary for a doctor.
|
||||
|
||||
**Permission:** `ROLE_DOCTOR` (must own the doctor) | `ROLE_CLINIC` (must have the doctor in its clinic) | `ROLE_ADMIN`
|
||||
**Permission:** `ROLE_DOCTOR` (must own the doctor — creates `owner_type='doctor'`) | `ROLE_CLINIC` (must have the doctor in its clinic — creates `owner_type='clinic'`) | `ROLE_ADMIN`
|
||||
|
||||
### Request Body (`application/json`)
|
||||
```json
|
||||
@@ -78,6 +91,8 @@ Create a secretary for a doctor.
|
||||
"mobile_number": "09123456789",
|
||||
"doctor_name": "دکتر احمد رضایی",
|
||||
"doctor_uuid": "...",
|
||||
"owner_type": "doctor",
|
||||
"clinic_uuid": null,
|
||||
"is_active": true,
|
||||
"permissions": { ... },
|
||||
"created_at": 1717000000
|
||||
@@ -85,6 +100,12 @@ Create a secretary for a doctor.
|
||||
}
|
||||
```
|
||||
|
||||
**مقادیر `owner_type`:**
|
||||
| مقدار | معنی |
|
||||
|-------|------|
|
||||
| `doctor` | منشی توسط خود دکتر تعریف شده — فقط مطب شخصی |
|
||||
| `clinic` | منشی توسط مدیر کلینیک تعریف شده — فقط کلینیک |
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|------|------|-------------|
|
||||
@@ -257,8 +278,9 @@ Get all secretaries across **all doctors** of a clinic.
|
||||
```
|
||||
|
||||
### Notes
|
||||
- یک منشی میتواند برای یک یا چند دکتر تعریف شود (جداگانه در جدول `doctor_secretaries`)
|
||||
- این endpoint همه منشیان همه دکترهای کلینیک را یکجا برمیگرداند با ستون `doctor_name` برای تشخیص
|
||||
- این endpoint فقط منشیانی را برمیگرداند که با `owner_type='clinic'` تعریف شدهاند
|
||||
- منشیانی که خود دکتر (با `owner_type='doctor'`) تعریف کرده از این لیست مخفی هستند
|
||||
- یک منشی میتواند به چند دکتر در همان کلینیک متصل باشد — در لیست چندبار ظاهر میشود (یک ردیف به ازای هر دکتر)
|
||||
|
||||
### Errors
|
||||
| Code | HTTP | Description |
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260615074107 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('DROP INDEX idx_doctor_secretaries_pair ON doctor_secretaries');
|
||||
$this->addSql('ALTER TABLE doctor_secretaries ADD owner_type VARCHAR(10) DEFAULT \'doctor\' NOT NULL, ADD clinic_id INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE doctor_secretaries ADD CONSTRAINT FK_8DF480E9CC22AD4 FOREIGN KEY (clinic_id) REFERENCES clinics (id) ON DELETE CASCADE');
|
||||
$this->addSql('CREATE INDEX IDX_8DF480E9CC22AD4 ON doctor_secretaries (clinic_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX idx_doctor_secretary_scope ON doctor_secretaries (doctor_id, secretary_id, owner_type)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE doctor_secretaries DROP FOREIGN KEY FK_8DF480E9CC22AD4');
|
||||
$this->addSql('DROP INDEX IDX_8DF480E9CC22AD4 ON doctor_secretaries');
|
||||
$this->addSql('DROP INDEX idx_doctor_secretary_scope ON doctor_secretaries');
|
||||
$this->addSql('ALTER TABLE doctor_secretaries DROP owner_type, DROP clinic_id');
|
||||
$this->addSql('CREATE UNIQUE INDEX idx_doctor_secretaries_pair ON doctor_secretaries (doctor_id, secretary_id)');
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,10 @@ namespace App\Appointment\Controller;
|
||||
|
||||
use App\Appointment\Entity\Appointment;
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Shared\Controller\BaseController;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -18,10 +20,11 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
class MyAppointmentsController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/my/appointment', methods: ['POST'])]
|
||||
@@ -127,16 +130,22 @@ class MyAppointmentsController extends BaseController
|
||||
$qb->andWhere('a.doctor = :doctor')
|
||||
->setParameter('doctor', $doctor);
|
||||
} elseif (in_array('ROLE_SECRETARY', $roles, true)) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretary($user);
|
||||
if ($rel === null) {
|
||||
$filter = $this->resolveSecretaryFilter($user);
|
||||
if ($filter === null) {
|
||||
return $this->paginated([], 0, $page, $limit);
|
||||
}
|
||||
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
|
||||
[$filterType, $filterValue, $canView] = $filter;
|
||||
if (!$canView) {
|
||||
return $this->paginated([], 0, $page, $limit);
|
||||
}
|
||||
$qb->andWhere('a.doctor = :doctor')
|
||||
->setParameter('doctor', $rel->getDoctor());
|
||||
if ($filterType === 'clinic') {
|
||||
$qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
|
||||
->andWhere('c = :clinic')
|
||||
->setParameter('clinic', $filterValue);
|
||||
} else {
|
||||
$qb->andWhere('a.doctor = :doctor')
|
||||
->setParameter('doctor', $filterValue);
|
||||
}
|
||||
} else {
|
||||
return $this->paginated([], 0, $page, $limit);
|
||||
}
|
||||
@@ -217,9 +226,16 @@ class MyAppointmentsController extends BaseController
|
||||
$qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $doctor);
|
||||
}
|
||||
} elseif (in_array('ROLE_SECRETARY', $roles, true)) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretary($user);
|
||||
if ($rel) {
|
||||
$qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $rel->getDoctor());
|
||||
$filter = $this->resolveSecretaryFilter($user);
|
||||
if ($filter !== null) {
|
||||
[$filterType, $filterValue] = $filter;
|
||||
if ($filterType === 'clinic') {
|
||||
$qb->join('App\Clinic\Entity\Clinic', 'c', 'WITH', 'd MEMBER OF c.doctors')
|
||||
->andWhere('c = :clinic')
|
||||
->setParameter('clinic', $filterValue);
|
||||
} else {
|
||||
$qb->andWhere('a.doctor = :doctor')->setParameter('doctor', $filterValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,4 +260,40 @@ class MyAppointmentsController extends BaseController
|
||||
'cancelled' => $cancelled,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* تعیین فیلتر نوبتها برای منشی بر اساس scope فعال:
|
||||
* Returns [type, entity, canView] یا null اگر رابطهای پیدا نشد.
|
||||
* type: 'clinic' | 'doctor'
|
||||
* entity: Clinic | Doctor
|
||||
*/
|
||||
private function resolveSecretaryFilter(User $user): ?array
|
||||
{
|
||||
$activeCtx = $this->contextRepo->findByUser($user);
|
||||
$dbUuid = $activeCtx?->getDbUuid();
|
||||
|
||||
if ($dbUuid === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// بررسی scope کلینیک
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
|
||||
if ($rel === null) return null;
|
||||
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
|
||||
return ['clinic', $clinic, $canView];
|
||||
}
|
||||
|
||||
// بررسی scope مطب شخصی
|
||||
$doctor = $this->doctorRepo->findByUuid($dbUuid);
|
||||
if ($doctor !== null) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
|
||||
if ($rel === null) return null;
|
||||
$canView = (bool) ($rel->getPermissions()['resources']['appointments']['view'] ?? false);
|
||||
return ['doctor', $doctor, $canView];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,15 +649,33 @@ class AuthController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// منشی: همه روابط فعال
|
||||
// منشی: هر رابطه فعال با scope مجزا
|
||||
foreach ($this->secretaryRepo->findAllActiveBySecretary($user) as $rel) {
|
||||
$contexts[] = [
|
||||
'type' => 'doctor',
|
||||
'db_uuid' => $rel->getDoctor()->getUuid(),
|
||||
'name' => 'مطب ' . $rel->getDoctor()->getName(),
|
||||
'role' => 'secretary',
|
||||
'permissions' => $rel->getPermissions(),
|
||||
];
|
||||
if ($rel->getOwnerType() === \App\Secretary\Entity\DoctorSecretary::OWNER_CLINIC && $rel->getClinic() !== null) {
|
||||
// scope کلینیک — یک context به ازای هر کلینیک (نه هر دکتر)
|
||||
$clinicUuid = $rel->getClinic()->getUuid();
|
||||
$alreadyAdded = array_filter($contexts, fn($c) => $c['db_uuid'] === $clinicUuid && ($c['role'] ?? '') === 'secretary');
|
||||
if (empty($alreadyAdded)) {
|
||||
$contexts[] = [
|
||||
'type' => 'clinic',
|
||||
'db_uuid' => $clinicUuid,
|
||||
'name' => 'کلینیک ' . ($rel->getClinic()->getName() ?? ''),
|
||||
'role' => 'secretary',
|
||||
'scope' => 'clinic',
|
||||
'permissions' => $rel->getPermissions(),
|
||||
];
|
||||
}
|
||||
} else {
|
||||
// scope مطب شخصی
|
||||
$contexts[] = [
|
||||
'type' => 'doctor',
|
||||
'db_uuid' => $rel->getDoctor()->getUuid(),
|
||||
'name' => 'مطب ' . $rel->getDoctor()->getName(),
|
||||
'role' => 'secretary',
|
||||
'scope' => 'doctor',
|
||||
'permissions' => $rel->getPermissions(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $contexts;
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
namespace App\Dashboard\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Patient\Repository\PatientRecordRepository;
|
||||
use App\Patient\Repository\PatientSessionRepository;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
use App\Shared\Constant\ErrorCodes;
|
||||
use App\Shared\Controller\BaseController;
|
||||
@@ -21,13 +23,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
class DashboardController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly SmsWalletService $smsWalletService,
|
||||
private readonly PatientRecordRepository $patientRecordRepo,
|
||||
private readonly PatientSessionRepository $patientSessionRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly EntityManagerInterface $em,
|
||||
private readonly SmsWalletService $smsWalletService,
|
||||
private readonly PatientRecordRepository $patientRecordRepo,
|
||||
private readonly PatientSessionRepository $patientSessionRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
) {}
|
||||
|
||||
// ── Clinic Dashboard ────────────────────────────────────────────────────
|
||||
@@ -250,11 +253,33 @@ class DashboardController extends BaseController
|
||||
#[IsGranted('ROLE_SECRETARY')]
|
||||
public function secretary(#[CurrentUser] User $user): JsonResponse
|
||||
{
|
||||
$rel = $this->secretaryRepo->findActiveBySecretary($user);
|
||||
if ($rel === null) {
|
||||
$activeCtx = $this->contextRepo->findByUser($user);
|
||||
$dbUuid = $activeCtx?->getDbUuid();
|
||||
|
||||
if ($dbUuid === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403);
|
||||
}
|
||||
|
||||
// تعیین scope بر اساس db_uuid فعال
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
return $this->secretaryClinicDashboard($user, $clinic);
|
||||
}
|
||||
|
||||
$doctor = $this->doctorRepo->findByUuid($dbUuid);
|
||||
if ($doctor !== null) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
|
||||
if ($rel === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403);
|
||||
}
|
||||
return $this->secretaryDoctorDashboard($user, $rel);
|
||||
}
|
||||
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'context نامعتبر است', 403);
|
||||
}
|
||||
|
||||
private function secretaryDoctorDashboard(User $user, DoctorSecretary $rel): JsonResponse
|
||||
{
|
||||
$doctor = $rel->getDoctor();
|
||||
$permissions = $rel->getPermissions();
|
||||
$canView = (bool) ($permissions['resources']['appointments']['view'] ?? false);
|
||||
@@ -293,6 +318,7 @@ class DashboardController extends BaseController
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'scope' => 'doctor',
|
||||
'doctor' => [
|
||||
'uuid' => $doctor->getUuid(),
|
||||
'name' => $doctor->getName(),
|
||||
@@ -306,4 +332,71 @@ class DashboardController extends BaseController
|
||||
'today_appointments' => $todayAppts,
|
||||
]);
|
||||
}
|
||||
|
||||
private function secretaryClinicDashboard(User $user, \App\Clinic\Entity\Clinic $clinic): JsonResponse
|
||||
{
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
|
||||
if ($rel === null) {
|
||||
return $this->error(ErrorCodes::ERR_FORBIDDEN_001, 'دسترسی منشی تنظیم نشده', 403);
|
||||
}
|
||||
|
||||
$permissions = $rel->getPermissions();
|
||||
$canView = (bool) ($permissions['resources']['appointments']['view'] ?? false);
|
||||
|
||||
$todayStart = strtotime('today midnight');
|
||||
$todayEnd = strtotime('tomorrow midnight') - 1;
|
||||
$tmrStart = strtotime('tomorrow midnight');
|
||||
$tmrEnd = strtotime('tomorrow midnight') + 86399;
|
||||
|
||||
$doctors = $this->secretaryRepo->findDoctorsBySecretaryInClinic($user, $clinic);
|
||||
|
||||
$todayCount = 0;
|
||||
$tmrCount = 0;
|
||||
$todayAppts = [];
|
||||
|
||||
if (!empty($doctors)) {
|
||||
$todayCount = (int) $this->em->createQuery('
|
||||
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
|
||||
WHERE a.doctor IN (:doctors) AND a.slotStart >= :s AND a.slotStart <= :e
|
||||
')->setParameters(['doctors' => $doctors, 's' => $todayStart, 'e' => $todayEnd])
|
||||
->getSingleScalarResult();
|
||||
|
||||
$tmrCount = (int) $this->em->createQuery('
|
||||
SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a
|
||||
WHERE a.doctor IN (:doctors) AND a.slotStart >= :s AND a.slotStart <= :e
|
||||
')->setParameters(['doctors' => $doctors, 's' => $tmrStart, 'e' => $tmrEnd])
|
||||
->getSingleScalarResult();
|
||||
|
||||
if ($canView) {
|
||||
$todayAppts = $this->em->createQuery('
|
||||
SELECT a.uuid, u.realName AS patient_name, u.mobileNumber AS patient_mobile,
|
||||
a.slotStart AS slot_start, a.status, d.name AS doctor_name
|
||||
FROM App\Appointment\Entity\Appointment a
|
||||
JOIN a.user u
|
||||
JOIN a.doctor d
|
||||
WHERE a.doctor IN (:doctors) AND a.slotStart >= :s AND a.slotStart <= :e
|
||||
ORDER BY a.slotStart ASC
|
||||
')->setMaxResults(20)->setParameters([
|
||||
'doctors' => $doctors,
|
||||
's' => $todayStart,
|
||||
'e' => $todayEnd,
|
||||
])->getArrayResult();
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success([
|
||||
'scope' => 'clinic',
|
||||
'clinic' => [
|
||||
'uuid' => $clinic->getUuid(),
|
||||
'name' => $clinic->getName(),
|
||||
],
|
||||
'permissions' => $permissions,
|
||||
'stats' => [
|
||||
'today_appointments' => $todayCount,
|
||||
'tomorrow_appointments' => $tmrCount,
|
||||
],
|
||||
'today_appointments' => $todayAppts,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Patient\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserActiveContextRepository;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
@@ -25,14 +26,15 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
class PatientController extends BaseController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PatientRecordRepository $recordRepo,
|
||||
private readonly PatientSessionRepository $sessionRepo,
|
||||
private readonly PatientService $patientService,
|
||||
private readonly SubscriptionService $subscriptionService,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly PatientRecordRepository $recordRepo,
|
||||
private readonly PatientSessionRepository $sessionRepo,
|
||||
private readonly PatientService $patientService,
|
||||
private readonly SubscriptionService $subscriptionService,
|
||||
private readonly UserRepository $userRepo,
|
||||
private readonly DoctorRepository $doctorRepo,
|
||||
private readonly ClinicRepository $clinicRepo,
|
||||
private readonly DoctorSecretaryRepository $secretaryRepo,
|
||||
private readonly UserActiveContextRepository $contextRepo,
|
||||
) {}
|
||||
|
||||
#[Route('/api/v1/patients', methods: ['GET'])]
|
||||
@@ -173,9 +175,22 @@ class PatientController extends BaseController
|
||||
}
|
||||
|
||||
if ($user->hasRole('ROLE_SECRETARY')) {
|
||||
$secretary = $this->secretaryRepo->findActiveBySecretary($user);
|
||||
if ($secretary !== null && ($secretary->getPermissions()['appointments']['view'] ?? false)) {
|
||||
return ['doctor', $secretary->getDoctor()->getId()];
|
||||
$dbUuid = $this->contextRepo->findByUser($user)?->getDbUuid();
|
||||
if ($dbUuid !== null) {
|
||||
$clinic = $this->clinicRepo->findByUuid($dbUuid);
|
||||
if ($clinic !== null) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForClinic($user, $clinic);
|
||||
if ($rel !== null) {
|
||||
return ['clinic', $clinic->getId()];
|
||||
}
|
||||
}
|
||||
$doctor = $this->doctorRepo->findByUuid($dbUuid);
|
||||
if ($doctor !== null) {
|
||||
$rel = $this->secretaryRepo->findActiveBySecretaryForDoctor($user, $doctor);
|
||||
if ($rel !== null) {
|
||||
return ['doctor', $doctor->getId()];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,7 @@ namespace App\Secretary\Controller;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Auth\Repository\UserRepository;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Clinic\Repository\ClinicRepository;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use App\Doctor\Repository\DoctorRepository;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Secretary\Repository\DoctorSecretaryRepository;
|
||||
@@ -48,14 +46,24 @@ class SecretaryController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (!$this->canManageDoctor($doctor, $currentUser)) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
// تعیین scope بر اساس نقش کاربر جاری
|
||||
$ownerClinic = null;
|
||||
if ($currentUser->hasRole('ROLE_CLINIC')) {
|
||||
$ownerClinic = $this->clinicRepo->findByUser($currentUser);
|
||||
if ($ownerClinic === null || !$this->secretaryRepo->isDoctorInClinic($doctor, $ownerClinic)) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
} elseif (!$currentUser->hasRole('ROLE_ADMIN')) {
|
||||
// دکتر فقط برای خودش منشی تعریف میکند
|
||||
if ($doctor->getUser()->getId() !== $currentUser->getId()) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Check plan limit (dynamic via SubscriptionService)
|
||||
$entityType = 'doctor';
|
||||
$entityId = $doctor->getId();
|
||||
$limit = $this->subscriptionService->getSecretaryLimit($entityType, $entityId);
|
||||
$ownerType = $ownerClinic !== null ? DoctorSecretary::OWNER_CLINIC : DoctorSecretary::OWNER_DOCTOR;
|
||||
|
||||
// Check plan limit
|
||||
$limit = $this->subscriptionService->getSecretaryLimit('doctor', $doctor->getId());
|
||||
$activeCount = $this->secretaryRepo->countActiveByDoctor($doctor);
|
||||
if ($activeCount >= $limit) {
|
||||
return $this->error(ErrorCodes::ERR_SECRETARY_001, ErrorCodes::message(ErrorCodes::ERR_SECRETARY_001), 422);
|
||||
@@ -65,7 +73,6 @@ class SecretaryController extends BaseController
|
||||
$secretaryUser = $this->userRepo->findByMobile($mobile);
|
||||
if ($secretaryUser === null) {
|
||||
$secretaryUser = new User($mobile);
|
||||
// Set a temporary password if provided
|
||||
if (!empty($data['password'])) {
|
||||
$hash = $this->hasher->hashPassword($secretaryUser, $data['password']);
|
||||
$secretaryUser->setPasswordHash($hash);
|
||||
@@ -80,15 +87,18 @@ class SecretaryController extends BaseController
|
||||
}
|
||||
$this->userRepo->save($secretaryUser);
|
||||
|
||||
// Check duplicate
|
||||
$existing = $this->secretaryRepo->findOneBy(['doctor' => $doctor, 'secretary' => $secretaryUser]);
|
||||
// Check duplicate within same scope
|
||||
$existing = $this->secretaryRepo->findOneBy([
|
||||
'doctor' => $doctor,
|
||||
'secretary' => $secretaryUser,
|
||||
'ownerType' => $ownerType,
|
||||
]);
|
||||
if ($existing !== null) {
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این منشی قبلاً اضافه شده است', 409);
|
||||
}
|
||||
|
||||
$secretary = new DoctorSecretary($doctor, $secretaryUser);
|
||||
$secretary = new DoctorSecretary($doctor, $secretaryUser, $ownerType, $ownerClinic);
|
||||
|
||||
// Apply custom permissions if provided
|
||||
if (!empty($data['permissions'])) {
|
||||
$secretary->mergePermissions($data['permissions']);
|
||||
}
|
||||
@@ -157,6 +167,7 @@ class SecretaryController extends BaseController
|
||||
return $this->success(['message' => 'منشی با موفقیت حذف شد']);
|
||||
}
|
||||
|
||||
/** لیست منشیان مطب شخصی یک دکتر */
|
||||
#[Route('/api/v1/secretaries/{doctorUuid}', methods: ['GET'])]
|
||||
public function list(string $doctorUuid, #[CurrentUser] User $currentUser): JsonResponse
|
||||
{
|
||||
@@ -165,18 +176,20 @@ class SecretaryController extends BaseController
|
||||
return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404);
|
||||
}
|
||||
|
||||
if (!$this->canManageDoctor($doctor, $currentUser)) {
|
||||
// فقط خود دکتر یا ادمین میتوانند منشیان مطب شخصی را ببینند
|
||||
if (!$currentUser->hasRole('ROLE_ADMIN') && $doctor->getUser()->getId() !== $currentUser->getId()) {
|
||||
return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403);
|
||||
}
|
||||
|
||||
$secretaries = array_map(
|
||||
fn(DoctorSecretary $s) => $s->toArray(),
|
||||
$this->secretaryRepo->findByDoctor($doctor)
|
||||
$this->secretaryRepo->findByDoctorScope($doctor)
|
||||
);
|
||||
|
||||
return $this->success(['data' => $secretaries]);
|
||||
return $this->success($secretaries);
|
||||
}
|
||||
|
||||
/** لیست همه منشیان کلینیک (از همه دکترها، owner_type='clinic') */
|
||||
#[Route('/api/v1/secretaries/clinic/{clinicUuid}', methods: ['GET'])]
|
||||
public function listByClinic(string $clinicUuid, #[CurrentUser] User $currentUser): JsonResponse
|
||||
{
|
||||
@@ -194,30 +207,28 @@ class SecretaryController extends BaseController
|
||||
$this->secretaryRepo->findByClinic($clinic)
|
||||
);
|
||||
|
||||
return $this->success(['data' => $secretaries]);
|
||||
return $this->success($secretaries);
|
||||
}
|
||||
|
||||
/** بررسی دسترسی برای ویرایش/حذف یک رابطه منشی — scope-aware */
|
||||
private function canManage(DoctorSecretary $secretary, User $user): bool
|
||||
{
|
||||
return $this->canManageDoctor($secretary->getDoctor(), $user);
|
||||
}
|
||||
|
||||
private function canManageDoctor(Doctor $doctor, User $user): bool
|
||||
{
|
||||
if ($user->hasRole('ROLE_ADMIN')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($doctor->getUser()->getId() === $user->getId()) {
|
||||
return true;
|
||||
if ($secretary->getOwnerType() === DoctorSecretary::OWNER_DOCTOR) {
|
||||
// فقط خود دکتر میتواند منشی مطب شخصیاش را مدیریت کند
|
||||
return $secretary->getDoctor()->getUser()->getId() === $user->getId();
|
||||
}
|
||||
|
||||
// clinic owner can manage secretaries of its own doctors
|
||||
if ($user->hasRole('ROLE_CLINIC')) {
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
if ($clinic !== null && $this->secretaryRepo->isDoctorInClinic($doctor, $clinic)) {
|
||||
return true;
|
||||
if ($secretary->getOwnerType() === DoctorSecretary::OWNER_CLINIC) {
|
||||
// فقط مالک کلینیک میتواند منشیان کلینیکی را مدیریت کند
|
||||
if (!$user->hasRole('ROLE_CLINIC')) {
|
||||
return false;
|
||||
}
|
||||
$clinic = $this->clinicRepo->findByUser($user);
|
||||
return $clinic !== null && $secretary->getClinic()?->getId() === $clinic->getId();
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -3,15 +3,19 @@
|
||||
namespace App\Secretary\Entity;
|
||||
|
||||
use App\Auth\Entity\User;
|
||||
use App\Clinic\Entity\Clinic;
|
||||
use App\Doctor\Entity\Doctor;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 'doctor_secretaries')]
|
||||
#[ORM\UniqueConstraint(name: 'idx_doctor_secretaries_pair', columns: ['doctor_id', 'secretary_id'])]
|
||||
#[ORM\UniqueConstraint(name: 'idx_doctor_secretary_scope', columns: ['doctor_id', 'secretary_id', 'owner_type'])]
|
||||
class DoctorSecretary
|
||||
{
|
||||
public const OWNER_DOCTOR = 'doctor';
|
||||
public const OWNER_CLINIC = 'clinic';
|
||||
|
||||
public const DEFAULT_PERMISSIONS = [
|
||||
'version' => 1,
|
||||
'resources' => [
|
||||
@@ -38,6 +42,13 @@ class DoctorSecretary
|
||||
#[ORM\JoinColumn(name: 'secretary_id', referencedColumnName: 'id', nullable: false)]
|
||||
private User $secretary;
|
||||
|
||||
#[ORM\Column(name: 'owner_type', type: 'string', length: 10, options: ['default' => 'doctor'])]
|
||||
private string $ownerType;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Clinic::class)]
|
||||
#[ORM\JoinColumn(name: 'clinic_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Clinic $clinic = null;
|
||||
|
||||
#[ORM\Column(name: 'permission', type: 'json', nullable: true)]
|
||||
private ?array $permissions = null;
|
||||
|
||||
@@ -50,25 +61,30 @@ class DoctorSecretary
|
||||
#[ORM\Column(name: 'updated_at', type: 'integer')]
|
||||
private int $updatedAt;
|
||||
|
||||
public function __construct(Doctor $doctor, User $secretary)
|
||||
public function __construct(Doctor $doctor, User $secretary, string $ownerType = self::OWNER_DOCTOR, ?Clinic $clinic = null)
|
||||
{
|
||||
$this->uuid = Uuid::v4()->toRfc4122();
|
||||
$this->doctor = $doctor;
|
||||
$this->secretary = $secretary;
|
||||
$this->ownerType = $ownerType;
|
||||
$this->clinic = $clinic;
|
||||
$this->permissions = self::DEFAULT_PERMISSIONS;
|
||||
$this->createdAt = time();
|
||||
$this->updatedAt = time();
|
||||
}
|
||||
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getSecretary(): User { return $this->secretary; }
|
||||
public function getId(): ?int { return $this->id; }
|
||||
public function getUuid(): string { return $this->uuid; }
|
||||
public function getDoctor(): Doctor { return $this->doctor; }
|
||||
public function getSecretary(): User { return $this->secretary; }
|
||||
public function getOwnerType(): string { return $this->ownerType; }
|
||||
public function getClinic(): ?Clinic { return $this->clinic; }
|
||||
public function getPermissions(): array { return $this->permissions ?? self::DEFAULT_PERMISSIONS; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function isActive(): bool { return $this->active; }
|
||||
public function getCreatedAt(): int { return $this->createdAt; }
|
||||
public function getUpdatedAt(): int { return $this->updatedAt; }
|
||||
|
||||
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
|
||||
public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; }
|
||||
public function setPermissions(array $v): self { $this->permissions = $v; $this->touch(); return $this; }
|
||||
|
||||
/** Deep merge: only provided resources/actions are updated */
|
||||
@@ -102,6 +118,8 @@ class DoctorSecretary
|
||||
'mobile_number' => $this->secretary->getMobileNumber(),
|
||||
'doctor_name' => $this->doctor->getName(),
|
||||
'doctor_uuid' => $this->doctor->getUuid(),
|
||||
'owner_type' => $this->ownerType,
|
||||
'clinic_uuid' => $this->clinic?->getUuid(),
|
||||
'is_active' => $this->active,
|
||||
'permissions' => $this->getPermissions(),
|
||||
'created_at' => $this->createdAt,
|
||||
|
||||
@@ -38,37 +38,87 @@ class DoctorSecretaryRepository extends ServiceEntityRepository
|
||||
return $this->findBy(['doctor' => $doctor], ['createdAt' => 'DESC']);
|
||||
}
|
||||
|
||||
public function isDoctorInClinic(Doctor $doctor, Clinic $clinic): bool
|
||||
/** منشیان مطب شخصی یک دکتر (owner_type='doctor') */
|
||||
public function findByDoctorScope(Doctor $doctor): array
|
||||
{
|
||||
return $clinic->getDoctors()->contains($doctor);
|
||||
return $this->findBy(
|
||||
['doctor' => $doctor, 'ownerType' => DoctorSecretary::OWNER_DOCTOR],
|
||||
['createdAt' => 'DESC']
|
||||
);
|
||||
}
|
||||
|
||||
/** @return DoctorSecretary[] — all secretaries across all doctors of a clinic */
|
||||
public function findByClinic(Clinic $clinic): array
|
||||
/** رابطه منشی در scope مطب شخصی */
|
||||
public function findActiveBySecretaryForDoctor(User $user, Doctor $doctor): ?DoctorSecretary
|
||||
{
|
||||
$doctorIds = $clinic->getDoctors()->map(fn(Doctor $d) => $d->getId())->toArray();
|
||||
if (empty($doctorIds)) {
|
||||
return [];
|
||||
}
|
||||
return $this->findOneBy([
|
||||
'secretary' => $user,
|
||||
'doctor' => $doctor,
|
||||
'ownerType' => DoctorSecretary::OWNER_DOCTOR,
|
||||
'active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/** اولین رابطه فعال منشی در یک کلینیک (برای چک دسترسی کلی) */
|
||||
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', DoctorSecretary::OWNER_CLINIC)
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult();
|
||||
}
|
||||
|
||||
/** همه دکترهای کلینیک که این منشی به آنها متصل است */
|
||||
public function findDoctorsBySecretaryInClinic(User $user, Clinic $clinic): array
|
||||
{
|
||||
return $this->createQueryBuilder('s')
|
||||
->select('d')
|
||||
->join('s.doctor', 'd')
|
||||
->where('d.id IN (:ids)')
|
||||
->setParameter('ids', $doctorIds)
|
||||
->orderBy('s.createdAt', 'DESC')
|
||||
->where('s.secretary = :user')
|
||||
->andWhere('s.clinic = :clinic')
|
||||
->andWhere('s.ownerType = :type')
|
||||
->andWhere('s.active = true')
|
||||
->setParameter('user', $user)
|
||||
->setParameter('clinic', $clinic)
|
||||
->setParameter('type', DoctorSecretary::OWNER_CLINIC)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
/** همه روابط فعال یک منشی — برای auth context builder */
|
||||
public function findAllActiveBySecretary(User $user): array
|
||||
{
|
||||
return $this->findBy(['secretary' => $user, 'active' => true]);
|
||||
}
|
||||
|
||||
/** اولین رابطه فعال — backward compat برای کدهایی که هنوز migrate نشدهاند */
|
||||
public function findActiveBySecretary(User $user): ?DoctorSecretary
|
||||
{
|
||||
return $this->findOneBy(['secretary' => $user, 'active' => true]);
|
||||
}
|
||||
|
||||
/** @return DoctorSecretary[] */
|
||||
public function findAllActiveBySecretary(User $user): array
|
||||
public function isDoctorInClinic(Doctor $doctor, Clinic $clinic): bool
|
||||
{
|
||||
return $this->findBy(['secretary' => $user, 'active' => true]);
|
||||
return $clinic->getDoctors()->contains($doctor);
|
||||
}
|
||||
|
||||
/** همه منشیان کلینیک (owner_type='clinic') */
|
||||
public function findByClinic(Clinic $clinic): array
|
||||
{
|
||||
return $this->createQueryBuilder('s')
|
||||
->where('s.clinic = :clinic')
|
||||
->andWhere('s.ownerType = :type')
|
||||
->setParameter('clinic', $clinic)
|
||||
->setParameter('type', DoctorSecretary::OWNER_CLINIC)
|
||||
->orderBy('s.createdAt', 'DESC')
|
||||
->getQuery()
|
||||
->getResult();
|
||||
}
|
||||
|
||||
public function save(DoctorSecretary $entity, bool $flush = true): void
|
||||
|
||||
Reference in New Issue
Block a user